非同期クエリの `caller_runs` フォールバックによるインストゥルメンターの汚染を修正

rails/rails

async_query_executor:global_thread_pool を使用している環境で、スレッドプールの飽和時に同期クエリの sql.active_record 通知がサイレントに失われるバグが修正されました。

背景

async_loadasync_pick を使った非同期クエリは、global_thread_pool のスレッドが枯渇すると呼び出し元スレッドで実行されることがあります。これは fallback_policy: :caller_runs の挙動によるもので、global_thread_pool はデフォルトの concurrency: 4 スレッドと最大キューサイズ16を合わせた計20タスクが上限となっています。

Active Recordの接続プールリーパーもこの global_thread_pool で非同期メンテナンスタスクを実行するため、Pumaのスレッド数が少ない環境でもプールは徐々に飽和していきます。PR作者が実際に観測した症状は「デプロイ直後は問題ゼロ、その後リクエストごとにクエリログが欠落する割合が増加し、次のデプロイで再びゼロに戻る」という周期的なパターンで、これはスレッドが汚染されるとワーカーが再起動されるまで永続的に汚染され続けることを示しています。

根本原因は QueryIntent#execute_or_skip にありました。このメソッドは非同期クエリを実行する際に ActiveSupport::IsolatedExecutionState[:active_record_instrumenter]EventBuffer に置き換えますが、終了後に元の値を復元していませんでした。バックグラウンドスレッドで実行される通常ケースでは IsolatedExecutionState がスレッドローカルであるため問題になりませんが、caller_runs により呼び出し元スレッド上で実行されると、その後の全 sql.active_record 通知が EventBuffer に吸収されたまま公開されなくなります。

技術的な変更

修正は query_intent.rbexecute_or_skip メソッドに2行を追加するだけのシンプルなものです。EventBuffer のセットアップ前に既存のインストゥルメンターを保存し、ensure ブロックで確実に復元します。

変更前:

@pool.with_connection do |connection|
  return unless @mutex.try_lock
  begin
    if pending?
      @event_buffer = EventBuffer.new(self, ActiveSupport::Notifications.instrumenter)
      # ...
    end
  rescue => error
    @error = error
  ensure
    @mutex.unlock
  end
end

変更後:

@pool.with_connection do |connection|
  return unless @mutex.try_lock
  previous_instrumenter = ActiveSupport::IsolatedExecutionState[:active_record_instrumenter]
  begin
    if pending?
      @event_buffer = EventBuffer.new(self, ActiveSupport::Notifications.instrumenter)
      # ...
    end
  rescue => error
    @error = error
  ensure
    ActiveSupport::IsolatedExecutionState[:active_record_instrumenter] = previous_instrumenter
    @mutex.unlock
  end
end

バックグラウンドスレッドで実行される通常ケースでは previous_instrumenternil になるため、実質的にno-opです。caller_runs フォールバックで呼び出し元スレッド上で実行された場合は、ensure ブロックが呼び出し元の実インストゥルメンターを復元します。

テストでは、schedule_query メソッドをモンキーパッチして execute_or_skip を現在のスレッドで直接実行することで caller_runs の状況を再現しています。非同期クエリ実行後に ActiveSupport::Notifications.subscribed で同期クエリの通知が正常に届くことを検証します。

def test_execute_or_skip_does_not_contaminate_caller_thread_instrumenter
  skip unless ActiveRecord::Base.connection.async_enabled?

  pool = Post.connection_pool
  old_schedule_query = pool.method(:schedule_query)
  pool.singleton_class.undef_method(:schedule_query)
  pool.singleton_class.define_method(:schedule_query) do |future_result|
    future_result.execute_or_skip  # caller_runs をシミュレート
  end

  Post.async_count  # execute_or_skip を現在スレッドで実行

  notification_called = false
  ActiveSupport::Notifications.subscribed(->(*) { notification_called = true }, "sql.active_record") do
    Post.count  # 通知が正常に届くか確認
  end

  assert notification_called,
    "sql.active_record notification was not published after execute_or_skip ran on the caller thread"
ensure
  # ...
  ActiveSupport::IsolatedExecutionState.delete(:active_record_instrumenter)
end

設計判断

「復元しない」という設計の前提が caller_runs で崩れることへの対処 として、最小限のスコープで save/restore パターンを適用する方針が採られました。

元の実装は非同期クエリが常に独立したスレッドで実行されるという前提の下では正しく機能していました。IsolatedExecutionState はスレッド間で共有されないため、バックグラウンドスレッドが EventBuffer を設定しても呼び出し元スレッドに影響はありません。しかし caller_runs というフォールバックポリシーはその前提を崩すため、スレッド境界を越えて副作用が生じていました。修正は execute_or_skip 内の mutex のロック/アンロックと同じスコープで IsolatedExecutionState の値を管理することで、呼び出しコンテキストを問わず安全に動作させています。

ensure ブロックでの復元はエラーが発生した場合にも確実に実行されるため、例外経路でのスレッド汚染も防止されます。

まとめ

本PRは、非同期実行の前提が崩れた際に通知システムが静かに壊れるという診断困難なバグを、2行の save/restore で修正しています。IsolatedExecutionState を変更する箇所は常にその変更をスコープ内に閉じ込めるべきという教訓を、この変更は明確に示しています。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

「リード文→背景→技術詳細→設計判断→まとめ」という総論→各論→結論の構成が明確で、読者が変更の全体像を理解しやすい構成になっています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライトやPRへのリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Railsの内部実装に関する専門用語が適切に使用されており、専門知識を持つエンジニアという対象読者に適合した内容です。

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

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

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

Diff内容との照合 ✓ PASS

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

記事内のコードブロックは、提供されたDiffの内容を正確に反映しており、変更点を的確に示しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PR内で使用されている`IsolatedExecutionState`や`caller_runs`といった技術用語が正確に使用されています。

説明の技術的正確性 ✓ PASS

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

スレッドプールの飽和からインストゥルメンターの汚染に至るまでの因果関係の説明が、PRの情報に基づき技術的に正確かつ論理的に記述されています。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのDescriptionやDiffから裏付けが取れており、ハルシネーション(捏造)は見られません。

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

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

PR番号、スレッドプールの設定値(concurrency: 4, queue size: 16)などの数値や固有名詞はすべて正確です。

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

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

記事のタイトルはPRのタイトルよりも具体的に問題の背景と影響を要約しており、内容を的確に表現しています。

外部知識の正確性 ✓ PASS

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

記事の内容はPR情報に限定されており、PRに記載のない外部知識(バージョンのサポート状況など)の追加はありません。

時間表現の正確性 ✓ PASS

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

PRで記述されている事象の時系列が正確に反映されており、時間表現の歪曲はありません。