CIハングを防ぐ汎用タイムアウトヘルパー `wait_for` の導入

rails/rails

Active Recordのテストで「無限ループするポーリング」によりCIジョブが永久に止まる問題を解消するため、タイムアウト付きの汎用ヘルパー wait_for が追加されました。合わせて既存の WaitForAsyncTestHelperWaitForTestHelper に統合されています。

背景

test_checkout_fairness_by_group は、スレッドの完了を loop { sleep 0.1; break if ... } という上限なしのポーリングループで待機していました。何らかの理由でスレッドが成功・エラーパスのいずれにも到達しない場合、ループは終了せず、CIエージェントのジョブタイムアウトに達するまでビルドが占有されてしまいます。

この問題は Buildkite上の実際のハングビルド で観測されており、同種のフレーキーなテストは過去にも #53591 でチェックアウトタイムアウトを延長して対処した経緯があります。根本的な解決として、ポーリング自体にタイムアウト上限を設けるアプローチが採られました。

技術的な変更

WaitForAsyncTestHelperWaitForTestHelper に改名され、非同期クエリ専用だったヘルパーが汎用的なポーリングユーティリティへと拡張されました。

新たに追加された wait_for メソッドは、ブロックが true を返すまで指定間隔でリトライし、デッドラインを超えた場合は Timeout::Error を発生させます。実装には Process.clock_gettime(Process::CLOCK_MONOTONIC) を使用しており、システムクロックの巻き戻しに対して堅牢な計時を行います。

def wait_for(message: "condition not met", timeout: 5, interval: 0.01)
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
  loop do
    return if yield
    if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
      raise Timeout::Error, "#{message} after #{timeout} seconds"
    end
    sleep interval
  end
end

既存の wait_for_async_querywait_for を使って書き直されました。変更前は (timeout * 100).times による固定回数ループで、タイムアウト到達後に raise するという構造でしたが、変更後は wait_for へのデリゲートに簡略化されています。

変更前:

def wait_for_async_query(connection = ActiveRecord::Base.lease_connection, timeout: 5)
  return unless connection.async_enabled?

  executor = connection.pool.async_executor
  (timeout * 100).times do
    return unless executor.scheduled_task_count > executor.completed_task_count
    sleep 0.01
  end

  raise Timeout::Error, "The async executor wasn't drained after #{timeout} seconds"
end

変更後:

def wait_for_async_query(connection = ActiveRecord::Base.lease_connection, timeout: 5)
  return unless connection.async_enabled?

  executor = connection.pool.async_executor
  wait_for(message: "The async executor wasn't drained", timeout: timeout) do
    executor.scheduled_task_count <= executor.completed_task_count
  end
end

test_checkout_fairness_by_group では、無限ループだったポーリングが wait_for の呼び出し1行に置き換えられています。

変更前:

loop do
  sleep 0.1
  break if mutex.synchronize { (successes.size + errors.size) == group1.size }
end

変更後:

wait_for(message: "group1 threads did not finish", interval: 0.1) do
  mutex.synchronize { (successes.size + errors.size) == group1.size }
end

WaitForTestHelperConnectionPoolTests モジュールに include され、接続プールテスト全体で利用可能になりました。また、belongs_tohas_manyhas_one の非同期アソシエーションテストや LoadAsyncTest 群でも WaitForAsyncTestHelper の参照が WaitForTestHelper に統一されています。

設計判断

Timeout.timeout ではなく、モノトニッククロックによるデッドライン管理が採用されました。Timeout.timeout はRubyの標準ライブラリですが、スレッドへの割り込みタイミングに関する既知の問題があります。Process.clock_gettime(Process::CLOCK_MONOTONIC) を直接使用することで、より予測可能なタイムアウト挙動を実現しています。

また、messageinterval をキーワード引数として外部から制御可能にしたことで、待機対象の性質に応じたチューニングが可能になっています。接続プールテストでは interval: 0.1 を指定してポーリング間隔を広げており、非同期クエリのデフォルト interval: 0.01 とは別に調整できます。

PR本文では、さらなる改善策として Concurrent::CountDownLatch(このファイルの他の箇所で既に使用されている)へのリプレースが示唆されています。これにより sleep ループ自体を廃止できますが、今回はまず「無限ループを有界にする」という最小限の変更が優先されました。

まとめ

本PRは、テストコードにおける「タイムアウトなしのポーリング」という潜在的なCIリソース浪費のパターンを、汎用の wait_for ヘルパーで一掃した変更です。WaitForTestHelper という単一の抽象を導入することで、非同期クエリ待機と接続プールスレッド待機という異なるユースケースを一貫したインターフェースで扱えるようになり、将来のテスト追加時にも無限ループが生まれにくい設計基盤が整いました。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
9b55b420

この記事は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のPR番号へのリンク記法(`[#123](URL)`)が、ガイドラインに沿って正しく使用されています。

対象読者への適合性 ✓ PASS

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

Railsのテスト内部実装に関する深い内容を扱っており、専門知識を持つエンジニアという対象読者に完全に適合しています。用語のレベルや説明の粒度が適切です。

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

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

各セクションが総論→各論→結論の構造を持ち、各パラグラフがトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が非常によく守られています。これにより、記事の論理構造が明快で読みやすくなっています。

Diff内容との照合 ✓ PASS

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

記事内で引用されているすべてのコードブロック(変更前・変更後)が、提供されたDiff情報と完全に一致しています。ファイルパスも正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「モノトニッククロック」「ポーリングループ」「CIエージェント」など、文脈に応じた技術用語が正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

モノトニッククロックを使用する利点や、`wait_for` ヘルパーによるリファクタリングの効果など、技術的な説明はすべて正確で論理的です。

事実の突合 ✓ PASS

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

記事内のすべての主張(ハングしたビルドのURL、関連PR番号、将来の改善案など)が、PRのDescriptionやDiffの内容によって裏付けられています。ハルシネーションは見られません。

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

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

PR番号(#57309, #53591)や各種設定値、メソッド名、モジュール名などの固有名詞はすべて正確に記載されています。

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

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

記事のタイトル「CIハングを防ぐ汎用タイムアウトヘルパー `wait_for` の導入」は、PRの具体的な修正内容とその本質(汎用ヘルパーの導入)を的確に要約しており、PRの内容と完全に一致しています。

外部知識の正確性 ⚠ WARNING

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

「設計判断」セクションで言及されている `Timeout.timeout` の既知の問題点は、PRに明記されていない外部知識です。しかし、これはDiffのコード(`Process.clock_gettime`の採用)の背景を説明するために不可欠かつ有益な情報であり、記事の品質を向上させています。

時間表現の正確性 ✓ PASS

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

記事内の時間表現に問題はなく、PRの文脈と矛盾する部分はありません。