`lock_candidates`内で発生する意図しない`FOR UPDATE`クエリを抑制
select_candidatesがArrayではなくActiveRecord::Relationを返すようになった副作用として、lock_candidates内のnone?呼び出しが不要なSELECT ... FOR UPDATE SKIP LOCKEDを発行していた。.to_aを追加してリレーションを即時評価することで、この余分なロッキングクエリを排除する。
背景
コミット 408b84c は、デッドロック回避のためにready_executionsの削除方法をjob_id指定から主キー指定へ変更した際、select_candidatesの戻り値をArrayからActiveRecord::Relationへ変更した。これはデッドロック対策の実装上の副産物であり、意図した設計変更ではなかった。
この変更以前、select_candidatesは.to_a相当の即時評価済みの結果を返していたため、lock_candidates内のexecutions.none?はメモリ上の配列に対する操作として完結していた。リレーションを返すようになったことで、none?の呼び出しが遅延評価のトリガーとなり、以下のSQLが追加で発行されるようになった。
SELECT 1 AS one FROM `solid_queue_ready_executions` LIMIT 1 FOR UPDATE SKIP LOCKED;
この余分なロッキングクエリは、Solid Queueへの移行評価中のパフォーマンステストにおいてボトルネックとして顕在化した。
技術的な変更
修正はselect_candidatesメソッドの末尾に.to_aを追加するだけの最小限の変更だが、その意味は明確だ。リレーションの評価タイミングをselect_candidates内に固定することで、呼び出し元が受け取る値を常にArrayに統一する。
変更前:
def select_candidates(queue_relation, limit)
queue_relation.ordered.limit(limit).non_blocking_lock.select(:id, :job_id)
end
変更後:
def select_candidates(queue_relation, limit)
# Force query execution here with #to_a to avoid unintended FOR UPDATE query executions
queue_relation.ordered.limit(limit).non_blocking_lock.select(:id, :job_id).to_a
end
ActiveRecord::Relationに対してnone?を呼び出すと、ActiveRecordはSELECT 1 ... LIMIT 1で存在確認クエリを発行する。non_blocking_lockが適用されたリレーションから派生したクエリであるため、FOR UPDATE SKIP LOCKEDが付加され、本来不要なロックが発生していた。.to_aによってselect_candidatesの時点でSQLが確定・実行されるため、lock_candidatesが受け取るのは純粋なRubyの配列となり、none?はデータベースに触れずにメモリ上で評価される。
設計判断
select_candidatesの内部で評価を完結させる アプローチが選ばれた。lock_candidates側でexecutions.to_a.none?とする選択肢もあるが、それではlock_candidatesがリレーションと配列の両方を受け取り得るという曖昧さを残す。変更の意図をコメントで明記した上でselect_candidates内に.to_aを置くことで、このメソッドの契約(返り値の型)を明示的にArrayへ固定し、呼び出し元が型を意識しなくて済む設計になっている。
また、FOR UPDATE SKIP LOCKEDというロック付きクエリが高頻度なポーリングループ内で余分に発行されることは、ロック競合の増大につながる。Solid Queueのように複数ワーカーが同一テーブルを競合して読む構造では、不要なロックの抑制が直接的なスループット改善に寄与する。
まとめ
リレーションの遅延評価とFOR UPDATE SKIP LOCKEDの組み合わせが引き起こす副作用を、.to_a一行で封じ込めた変更だ。返り値の型を明示的にArrayへ固定することで、ロッキングクエリの不用意な派生を構造的に防ぎ、高負荷環境でのポーリング性能を改善する。