スレッドプール飽和時のインストゥルメンターリーク修正
FutureResult#execute_or_skip がスレッドプール飽和時にカレントスレッドのインストゥルメンターを EventBuffer で上書きしたまま復元しない不具合が修正されました。これにより、caller_runs フォールバック発生後も sql.active_record 通知が正常に配信されるようになります。
背景
async_query_executor を :global_thread_pool に設定している環境で、一部のリクエストの同期クエリがインストゥルメンテーションおよびログに記録されない問題が報告されていました。この問題はスレッドプールが飽和した際に発生していました。
グローバルスレッドプールは fallback_policy: :caller_runs を採用しており、スレッドおよびキューが飽和した際には非同期タスクをカレントスレッドで実行します。この動作は activerecord/lib/active_record.rb の設定に明示されています。
根本原因は execute_or_skip の設計上の想定にあります。このメソッドはバックグラウンドスレッドで実行されることを前提として設計されており、スレッドローカルなインストゥルメンターの復元処理を持っていませんでした。caller_runs によってリクエストスレッド上で実行された場合、ActiveSupport::IsolatedExecutionState[:active_record_instrumenter] に設定された EventBuffer がそのスレッドに残り続けます。Rails はリクエスト間で IsolatedExecutionState の :active_record_instrumenter キーをクリアしないため、汚染はスレッドのライフタイム全体にわたって持続します。
技術的な変更
execute_or_skip のミューテックス確保後、EventBuffer のセットアップ前にインストゥルメンターの現在値を保存し、ensure ブロックで復元するよう変更されました。
変更箇所は activerecord/lib/active_record/future_result.rb の2行追加のみです。
変更前:
@pool.with_connection do |connection|
return unless @mutex.try_lock
begin
if pending?
@event_buffer = EventBuffer.new(self, @instrumenter)
ActiveSupport::IsolatedExecutionState[:active_record_instrumenter] = @event_buffer
execute_query(connection, async: true)
end
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, @instrumenter)
ActiveSupport::IsolatedExecutionState[:active_record_instrumenter] = @event_buffer
execute_query(connection, async: true)
end
ensure
ActiveSupport::IsolatedExecutionState[:active_record_instrumenter] = previous_instrumenter
@mutex.unlock
end
end
バックグラウンドスレッドで実行される通常ケースでは previous_instrumenter は nil になるため、復元後も従来と同じ動作を維持します。caller_runs によってリクエストスレッドで実行される場合は、元のインストゥルメンターが ensure で確実に復元されます。
あわせて、caller_runs フォールバックをシミュレートするテストが activerecord/test/cases/relation/load_async_test.rb に追加されました。テストでは pool.singleton_class に schedule_query を再定義してカレントスレッドで execute_or_skip を実行させ、その後の同期クエリで sql.active_record 通知が発行されることを ActiveSupport::Notifications.subscribed で検証しています。
設計判断
ミューテックス取得直後 に previous_instrumenter を保存し、ensure ブロックで復元する配置が選ばれました。
ミューテックス取得後に保存することで、EventBuffer が設定される直前の状態を確実にキャプチャできます。また、ensure ブロックはミューテックスのアンロックと同じブロックに置かれており、インストゥルメンターの復元とロック解放が常にペアで実行されることを保証します。return unless @mutex.try_lock でロック取得に失敗した場合は即座にリターンするため、インストゥルメンターの変更は発生せず、復元処理も不要になるという整合性も保たれています。
本修正は #56963 の 8.1 バックポートとして実装されています。
まとめ
2行の変更でスレッドプール飽和時のインストゥルメンター汚染を根本的に解消し、sql.active_record 通知の配信保証を回復します。IsolatedExecutionState を変更するコードでは「変更前の値を保存して ensure で復元する」パターンが重要であることを、このPRは改めて示しています。