Ractor 安全なカウンターキャッシュ設定の導入
ActiveRecord の内部で使用される ActiveRecord::CounterCaches._counter_cache_columns と counter_cached_association_names をデフォルトで freeze し、Ractor 間で安全に共有できるようにしました。機能的な振る舞いは変わらず、マルチスレッド/マルチRactor 環境での不変性が確保されます。
背景
これまで _counter_cache_columns と counter_cached_association_names は可変の配列 として定義されており、Ractor にオブジェクトを渡す際に共有状態が予期せぬ変更を招くリスクがありました。Ractor はデータをコピーせずに共有できるオブジェクトに対して不変性を要求しますが、Mutable 配列はその条件を満たさないためエラーが発生する可能性がありました。この PR はその安全性の欠如を解消し、Ractor 環境でもカウンターキャッシュ機能をそのまま利用できるようにすることが目的です。
技術的な変更
CounterCache モジュールの class_attribute 定義を変更し、デフォルト値を凍結した空配列に置き換えました。class_attribute :_counter_cache_columns, instance_accessor: false, default: [].freeze と class_attribute :counter_cached_association_names, instance_writer: false, default: [].freeze にすることで、属性が生成時点から変更不可となります。
@@
- class_attribute :_counter_cache_columns, instance_accessor: false, default: []
- class_attribute :counter_cached_association_names, instance_writer: false, default: []
+ class_attribute :_counter_cache_columns, instance_accessor: false, default: [].freeze
+ class_attribute :counter_cached_association_names, instance_writer: false, default: [].freeze
belongs_to ビルダーでのカウンターキャッシュ追加ロジックを凍結付き代入に変更しました。古い実装は |= 演算子で配列を拡張していましたが、変更後は (existing | [new]).freeze の結果を再代入することで、常に凍結された配列が保持されます。
@@
- klass._counter_cache_columns |= [cache_column] if klass && klass.respond_to?(:_counter_cache_columns)
- model.counter_cached_association_names |= [reflection.name]
+ klass._counter_cache_columns = (klass._counter_cache_columns | [cache_column]).freeze if klass && klass.respond_to?(:_counter_cache_columns)
+ model.counter_cached_association_names = (model.counter_cached_association_names | [reflection.name]).freeze
スキーマ読み込み時の集合演算でも凍結を適用し、load_schema! 内で counter_cached_association_names を更新した直後に .freeze しています。これにより、スキーマロード後の属性も不変性が保証されます。
@@
- self.counter_cached_association_names |= association_names
+ self.counter_cached_association_names = (counter_cached_association_names | association_names).freeze
テストスイートに Ractor 共有性の検証を追加し、ActiveSupport::Testing::RactorsAssertions をインクルードして assert_ractor_shareable マクロで属性が共有可能であることを確認しています。このテストは新しい凍結ロジックが正しく機能していることを CI で保証します。
@@
- require "active_support/core_ext/enumerable"
+ require "active_support/core_ext/enumerable"
+ require "active_support/testing/ractors_assertions"
@@
- class CounterCacheTest < ActiveRecord::TestCase
+ class CounterCacheTest < ActiveRecord::TestCase
+ include ActiveSupport::Testing::RactorsAssertions
@@
- test "counter cache configuration is ractor shareable" do
- assert_ractor_shareable SpecialReply.counter_cached_association_names
- assert_ractor_shareable SpecialReply._counter_cache_columns
- end
+ test "counter cache configuration is ractor shareable" do
+ assert_ractor_shareable SpecialReply.counter_cached_association_names
+ assert_ractor_shareable SpecialReply._counter_cache_columns
+ end
設計判断
属性を凍結する方針は、既存の API をそのまま残しつつ不変性を保証する最小侵襲のアプローチです。代替案としては新しい設定キーやコピーオンライトのロジックを導入することが考えられましたが、freeze による単一行の代入で済むため、互換性リスクが低く、コードベースへの影響範囲も限定的です。さらに、class_attribute のデフォルトを凍結した配列にすることで、オブジェクト生成時点から安全性が確保され、Ractor での共有に関するバグが事前に防止されます。この設計は「不変性をデフォルトに」する Ruby の慣例に沿っており、将来的な拡張や他の内部キャッシュ実装への適用もしやすい形となっています。