並列テスト終了後に Active Record Pool Reaper スレッドがリークする問題を修正
並列テスト実行後、親プロセスで Active Record Pool Reaper スレッドがリークし、コネクションプールとファイルディスクリプタを保持し続けるバグが修正されました。3層に重なったバグを一度に解消した本 PR は、長時間稼働する CI 環境でのリソース枯渇を防ぎます。
背景
並列テストのシャットダウン後、親プロセスの AR Pool Reaper スレッドが永続し続けるという問題が #57402 として報告されていました。ActiveRecord::ConnectionAdapters::ConnectionPool::Reaper は reaping frequency ごとにクラスレベルのシングルトンスレッドを起動し、その frequency に登録されたすべてのプールが discard! されるか GC されるまでスレッドを終了しません。
問題の根本は、ActiveSupport::Testing::Parallelization#shutdown がワーカー終了後に親プロセス側のクリーンアップを一切実行していなかった点にあります。その結果、多数のテストスイートを連続実行する CI プロセスではスレッドと DB コネクションが際限なく蓄積されていました。
技術的な変更
PR 作者が特定した原因は、独立した3つのバグが積み重なったものでした。
バグ1: Parallelization#shutdown が run_cleanup_hooks を呼ばないため、親プロセス側のクリーンアップが一切実行されませんでした。修正は activesupport/lib/active_support/testing/parallelization.rb への2行追加で、ワーカー終了を waitpid で待ち合わせた直後に run_cleanup_hooks を呼び出すようにします。
def shutdown
# ...ワーカーの終了待ち...
rescue Errno::ECHILD
nil
end
Parallelization.run_cleanup_hooks.each(&:call) # 追加
end
バグ2: Active Record が run_cleanup_hook を登録していなかったため、フック機構が正常化されても AR 側でプールを破棄する処理が存在しませんでした。activerecord/lib/active_record/test_databases.rb に以下のフック登録が追加されます。
ActiveSupport::Testing::Parallelization.run_cleanup_hook do
ActiveRecord::Base.connection_handler.each_connection_pool.each(&:discard!)
end
バグ3: clear_all_connections! が内部で disconnect! を呼ぶのに対し、Reaper スレッドを停止させるには discard! が必要でした。discard! メソッド自体も Reaper へ通知する処理を持っていなかったため、仮にフックが正しく登録されていたとしても Reaper スレッドは停止しませんでした。activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb の discard! 内に Reaper.discard_pool(self) の呼び出しが追加されます。
def discard! # :nodoc:
@reaper_lock.synchronize do
synchronize do
return if self.discarded?
Reaper.discard_pool(self) # 追加
@connections.each do |conn|
conn.discard!
end
これに対応して Reaper.discard_pool クラスメソッドが reaper.rb に追加されました。このメソッドは @mutex を取得してから @pools のすべての frequency エントリを走査し、対象プールへの WeakRef を取り除きます。WeakRef::RefError が発生した参照(GC 済みオブジェクト)も同時に除去します。その frequency のプールリストが空になった場合は @pools と @threads のエントリを削除し、対応するスレッドに対して t.kill; t.join を呼び出してスレッドを即座に停止・回収します。
def discard_pool(pool) # :nodoc:
@mutex.synchronize do
@pools.each do |frequency, refs|
refs.reject! do |ref|
ref.__getobj__ == pool
rescue WeakRef::RefError
true
end
if refs.empty?
@pools.delete(frequency)
@threads.delete(frequency)&.tap { |t| t.kill; t.join }
end
end
end
end
t.kill の後に t.join を呼び出すことで、スレッドが完全に終了するまでブロックし、スレッドオブジェクトが確実に解放されることを保証しています。
設計判断
discard! を起点としたトップダウンの責務分離が採用されました。プールを破棄する責務(discard!)と Reaper スレッドを管理する責務(Reaper.discard_pool)を分離し、discard! が呼ばれたタイミングで Reaper への通知を確実にトリガーする設計です。これにより、呼び出し元(テストフレームワーク、アプリケーションコードなど)がプールの破棄経路を意識せずとも Reaper のクリーンアップが自動的に行われます。
discard! 内で Reaper.discard_pool(self) を呼ぶ位置は、既存の return if self.discarded? ガードの直後であり、二重実行を防ぐ既存の冪等性チェックを活用しています。また、WeakRef::RefError を reject! ブロック内で捕捉してデッドリファレンスを同時に除去する実装は、GC との協調を維持しながらレジストリを一貫した状態に保つ判断です。
テスト側では、test_discard_pool_does_not_kill_thread_when_other_pools_remain として同じ frequency に複数プールが存在する場合にスレッドが維持されることも検証されており、単一プール消滅時の誤ったスレッド停止を防ぐ境界条件が明示的にカバーされています。
まとめ
本 PR は「シャットダウンフックが呼ばれない」「フックが登録されていない」「discard! が Reaper に通知しない」という3つの独立したバグをすべて修正することで、並列テスト後の AR Pool Reaper スレッドリークを根本から解消しています。各修正が単独では不完全であり、3つが揃って初めて機能するという連鎖構造を丁寧に解体した変更といえます。