CIハングを防ぐ汎用タイムアウトヘルパー `wait_for` の導入
Active Recordのテストで「無限ループするポーリング」によりCIジョブが永久に止まる問題を解消するため、タイムアウト付きの汎用ヘルパー wait_for が追加されました。合わせて既存の WaitForAsyncTestHelper が WaitForTestHelper に統合されています。
背景
test_checkout_fairness_by_group は、スレッドの完了を loop { sleep 0.1; break if ... } という上限なしのポーリングループで待機していました。何らかの理由でスレッドが成功・エラーパスのいずれにも到達しない場合、ループは終了せず、CIエージェントのジョブタイムアウトに達するまでビルドが占有されてしまいます。
この問題は Buildkite上の実際のハングビルド で観測されており、同種のフレーキーなテストは過去にも #53591 でチェックアウトタイムアウトを延長して対処した経緯があります。根本的な解決として、ポーリング自体にタイムアウト上限を設けるアプローチが採られました。
技術的な変更
WaitForAsyncTestHelper が WaitForTestHelper に改名され、非同期クエリ専用だったヘルパーが汎用的なポーリングユーティリティへと拡張されました。
新たに追加された 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_query も wait_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
WaitForTestHelper は ConnectionPoolTests モジュールに include され、接続プールテスト全体で利用可能になりました。また、belongs_to・has_many・has_one の非同期アソシエーションテストや LoadAsyncTest 群でも WaitForAsyncTestHelper の参照が WaitForTestHelper に統一されています。
設計判断
Timeout.timeout ではなく、モノトニッククロックによるデッドライン管理が採用されました。Timeout.timeout はRubyの標準ライブラリですが、スレッドへの割り込みタイミングに関する既知の問題があります。Process.clock_gettime(Process::CLOCK_MONOTONIC) を直接使用することで、より予測可能なタイムアウト挙動を実現しています。
また、message と interval をキーワード引数として外部から制御可能にしたことで、待機対象の性質に応じたチューニングが可能になっています。接続プールテストでは interval: 0.1 を指定してポーリング間隔を広げており、非同期クエリのデフォルト interval: 0.01 とは別に調整できます。
PR本文では、さらなる改善策として Concurrent::CountDownLatch(このファイルの他の箇所で既に使用されている)へのリプレースが示唆されています。これにより sleep ループ自体を廃止できますが、今回はまず「無限ループを有界にする」という最小限の変更が優先されました。
まとめ
本PRは、テストコードにおける「タイムアウトなしのポーリング」という潜在的なCIリソース浪費のパターンを、汎用の wait_for ヘルパーで一掃した変更です。WaitForTestHelper という単一の抽象を導入することで、非同期クエリ待機と接続プールスレッド待機という異なるユースケースを一貫したインターフェースで扱えるようになり、将来のテスト追加時にも無限ループが生まれにくい設計基盤が整いました。