並列テスト終了後に Active Record Pool Reaper スレッドがリークする問題を修正

rails/rails

並列テスト実行後、親プロセスで 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#shutdownrun_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.rbdiscard! 内に 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::RefErrorreject! ブロック内で捕捉してデッドリファレンスを同時に除去する実装は、GC との協調を維持しながらレジストリを一貫した状態に保つ判断です。

テスト側では、test_discard_pool_does_not_kill_thread_when_other_pools_remain として同じ frequency に複数プールが存在する場合にスレッドが維持されることも検証されており、単一プール消滅時の誤ったスレッド停止を防ぐ境界条件が明示的にカバーされています。

まとめ

本 PR は「シャットダウンフックが呼ばれない」「フックが登録されていない」「discard! が Reaper に通知しない」という3つの独立したバグをすべて修正することで、並列テスト後の AR Pool Reaper スレッドリークを根本から解消しています。各修正が単独では不完全であり、3つが揃って初めて機能するという連鎖構造を丁寧に解体した変更といえます。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
8e431814

この記事は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リンク記法の正確性

ファイル名付きシンタックスハイライト(```言語:ファイルパス)とGitHubリンク記法([#番号](URL))が、ガイドラインに完全に準拠して正しく使用されています。

対象読者への適合性 ✓ PASS

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

Active Recordのコネクションプーリングや並列テストの内部実装に関する深い内容を、専門知識を持つエンジニアを対象として的確に記述しています。不要な初心者向けの説明がなく、簡潔です。

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

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

各セクション、各パラグラフが「総論→各論」の原則に従っています。特に、各段落の冒頭がトピックセンテンスとして機能しており、見出しと1文目だけで記事の骨子を追える高い可読性を実現しています。

Diff内容との照合 ✓ PASS

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

記事内で引用されているすべてのコードブロックが、提供されたDiff情報と完全に一致しています。ファイルパスの指定も正確であり、信頼性が非常に高いです。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「Active Record Pool Reaper」「WeakRef」「reaping frequency」など、関連する技術用語が文脈に沿って正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

「3層のバグ」という複雑な問題の根本原因と、それに対する各修正がどのように作用するかが、Diffのコード変更に基づいて論理的かつ正確に説明されています。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのDescriptionやDiff内のコード変更によって裏付けられています。「設計判断」セクションもコードの意図を的確に分析したものであり、根拠のない憶測や創作(ハルシネーション)は一切見られません。

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

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

PR番号(#57406)、Issue番号(#57402)、およびコード内で使用されているクラス名、メソッド名、ファイルパスがすべて正確に記載されています。

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

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

記事のタイトル「並列テスト終了後に Active Record Pool Reaper スレッドがリークする問題を修正」は、元のPRの主題を正確に反映しており、内容との乖離はありません。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報に完全に準拠しており、バージョンのサポート状況やリリース日程といったPR外の知識を持ち込んでいません。

時間表現の正確性 ✓ PASS

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

「報告されていました」「修正されました」といった時間表現が、PRの文脈(既存の問題を修正したこと)と正確に一致しています。