`lock_candidates`内で発生する意図しない`FOR UPDATE`クエリを抑制

rails/solid_queue

select_candidatesArrayではなく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へ固定することで、ロッキングクエリの不用意な派生を構造的に防ぎ、高負荷環境でのポーリング性能を改善する。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
d7168c7a

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「総論→各論→結論」の3部構成が明確です。リード文、背景、技術的な変更、設計判断、まとめの各要素が適切に配置され、記事全体の流れが非常に分かりやすいです。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト、コミットIDの短縮形リンク、PR番号のリンクなど、カスタムMarkdown構文がすべて正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

ActiveRecordの遅延評価やDBロックなど、専門的なトピックを前提としており、対象読者であるエンジニアに適した技術レベルと表現です。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが「総論→各論」で構成され、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、可読性が高いです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のコードブロックは、提供されたDiffの内容を正確に反映しています。ファイル名も一致しており、コードの改変や省略はありません。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「ActiveRecord::Relation」「FOR UPDATE SKIP LOCKED」「遅延評価」などの技術用語が、文脈に応じて正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

ActiveRecordの`none?`の挙動や、`.to_a`によるクエリ発行の抑制など、技術的な説明は正確で論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張は、PRのDescriptionやDiff内のコードで裏付けられており、ハルシネーション(創作)は見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#720)やコミットID(408b84c)などの固有名詞はすべて正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRのタイトル「Avoid an unintended FOR UPDATE query execution inside `lock_candidates`」の内容を的確に要約しており、主題の乖離はありません。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR情報に記載のないバージョン情報やリリース日程といった外部知識の追加はなく、提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「〜ようになった」「〜していた」など、変更の経緯や現状を示す時間表現がPR情報と一致しており、正確です。