ActiveJob::TestHelperのO(N)スキャンをO(1)リセットに置き換えるリファクタリング
ActiveJob::TestHelperがテストのsetup/teardownごとに全Jobサブクラスをスキャンしていたパフォーマンス問題と、class_attributeの実装変更に起因するアダプター検出バグを、レジストリベースのデータ構造に置き換えることで同時に解決しました。
背景
ActiveJob::TestHelperのsetup/teardownで発生していたO(N)スキャンが、大規模アプリケーションでのテスト実行を遅延させていました。問題の核心は queue_adapter_changed_jobs メソッドにあり、このメソッドはActiveJob::Base.descendantsを全件走査してclass_attributeの上書きを検出していました。
Jobクラスが2,500クラス存在するアプリケーションでは、このスキャンが各テストのbefore_setup・after_teardownで計2回実行されます(#57440)。ActiveJobを使わないテストでも、ActiveJob::TestHelperをincludeしているだけでこのコストが発生していました。
さらに深刻な問題として、ActiveSupport 8.1.3でのclass_attributeの実装変更により、サブクラスが独自のキューアダプターを設定していても検出できなくなるバグも発生していました(#57441)。以下のような検出ロジックに依存していたためです:
def queue_adapter_changed_jobs
(ActiveJob::Base.descendants << ActiveJob::Base).select do |klass|
# only override explicitly set adapters, a quirk of `class_attribute`
klass.singleton_class.public_instance_methods(false).include?(:_queue_adapter)
end
end
この実装はclass_attributeの内部挙動(サブクラスが値を上書きするとsingleton_classにメソッドが生える)に依存した「quirk」であり、実装詳細が変わると動作しなくなる脆弱な設計でした。
技術的な変更
TestQueueAdapterモジュールをConcernベースからモジュールレベルのレジストリに全面改修し、アダプターの変更履歴を明示的なデータ構造で追跡するようになりました。
変更前はclass_attribute :_test_adapterを各Jobクラスのインスタンス変数として保持していましたが、変更後はTestQueueAdapterモジュール自体がレジストリとして機能します:
module TestQueueAdapter # :nodoc:
@excluded_jobs = Set.new
@cache = {}
@adapters = {}
class << self
attr_accessor :test_adapter
attr_reader :excluded_jobs
def excluded?(job)
@cache.fetch(job) do
@cache[job] = compute_excluded(job)
end
end
def exclude(job)
excluded_jobs << job
@cache.clear
end
def reset
excluded_jobs.clear
@cache.clear
@adapters.clear
@test_adapter = nil
@default_test_adapter = nil
end
def adapter_for(job)
return if excluded?(job)
if test_adapter
test_adapter
# ...
end
end
end
end
teardownでの処理は、queue_adapter_changed_jobs.each { |klass| klass.disable_test_adapter }という全件走査から、TestQueueAdapter.resetという単一メソッド呼び出しに変わりました。これにより、teardownのコストはJobクラス数に依存しないO(1)になります。
キャッシュ機構も追加されています。excluded?メソッドは@cacheに結果をメモ化し、同一Jobクラスへの重複計算を避けます。excludeやresetが呼ばれるとキャッシュはクリアされます。
また、ActiveJob::Base._queue_adapter = nilのリセットが必要になったケースへの対応として、railties/test/application/active_storage/analyzers_integration_test.rbにも修正が加えられています。appメソッド内でactive_job.set_configsイニシャライザがActiveJob::TestHelper#before_setupの後に実行されることで:asyncアダプターが設定されてしまう問題を、明示的なリセットで回避しています。
設計判断
「各Jobクラスが自身の状態を保持する」モデルから「TestQueueAdapterモジュールが変更を集中管理する」レジストリモデルへの転換が核心的な設計変更です。
旧設計ではclass_attributeを使って各JobクラスのAPIレイヤーに状態を分散させており、teardown時にはその状態を見つけるために全サブクラスを走査する必要がありました。新設計では変更されたJobクラスが登録時にTestQueueAdapterへ自身を記録するため、teardown時にはレジストリをクリアするだけで済みます。
@excluded_jobs と @cache を分離している点も注目に値します。excluded_jobsは「このJobクラスはテストアダプターから除外する」という明示的な意図を持つ集合であり、@cacheはcompute_excludedの計算結果のメモ化です。両者を分離することで、excludeの呼び出し時にキャッシュのみをクリアし、除外リスト自体はリセットまで保持するといった細かい制御が可能になっています。
まとめ
本PRは、class_attributeの実装詳細への依存という技術的負債を解消しながら、テストsuite全体のsetup/teardownコストをJobクラス数に依存しないO(1)に改善しました。変更の規模は小さいですが、レジストリパターンへの移行という設計思想の転換により、将来的な実装変更への耐性も高まっています。