ロード済みコレクションの NULL タイムスタンプで Relation#cache_key がクラッシュするバグを修正
Relation#cache_key と cache_version が、ロード済みコレクションに NULL タイムスタンプを持つレコードが混在している場合に ArgumentError: comparison of Time with nil failed を送出していた問題を解消し、未ロード時と同様のキー生成を保証します。
背景
ロード済み状態で cache_key を呼び出すと例外が発生するのは、Relation#compute_cache_version がロード有無で二つの実装パスを持っているためです。ロード済みの場合は records.map { |r| r.read_attribute(timestamp_column) }.max が実行され、[Time, nil, …] の配列に対して Array#max が比較を試みて nil との比較に失敗します。一方、未ロード時は SQL の MAX(updated_at) が実行され、NULL が自動的に除外されるため例外は起きません。この不一致により、ビューでコレクションがイテレートされた後にキャッシュ (cache @collection) を行うとクラッシュし、イテレートせずに同じコードを走らせると成功するというロード状態依存の挙動が生まれていました。
この挙動は collection_cache_versioning = false がデフォルトの環境でも再現し、sqlite3、postgresql、mysql2 いずれでも同様の失敗が報告されていました。結果として、開発者はキャッシュキー生成が不安定になるケースに遭遇し、デバッグコストが増大していました。
技術的な変更
ロード済み経路の実装を filter_map に置き換えることで、nil を除外した配列を生成し、Array#max が安全に動作するように修正しました。この変更は既存の API を変更せず、内部ロジックだけで一貫性を確保します。
@@
- timestamp = records.map { |record| record.read_attribute(timestamp_column) }.max
+ timestamp = records.filter_map { |record| record.read_attribute(timestamp_column) }.max
同時に CHANGELOG へ本修正の概要を追記し、リリースノートとして利用できるようにしました。
@@ -1,3 +1,8 @@
+* Fix `ActiveRecord::Relation#cache_key` / `cache_version` for a loaded collection
+ containing a record without a timestamp.
+
+ *Kenta Ishizaki*
+
* Fix `ActiveRecord::MessagePack` serialization raising `NoMethodError`
for any record with a populated `time` column, which made such records
uncacheable through the MessagePack cache serializer.
テストスイートにも検証を追加し、ロード済みコレクションと未ロードコレクションでキーが一致することを自動確認できるようにしました。テストファイルの diff では追加行のみを + で示しています。
+ test "cache_key for a loaded relation with a NULL timestamp matches the unloaded key" do
+ with_timestamp = Topic.create!(title: "with timestamp")
+ without_timestamp = Topic.create!(title: "without timestamp")
+ Topic.where(id: without_timestamp.id).update_all(updated_at: nil)
+
+ relation = -> { Topic.where(id: [with_timestamp.id, without_timestamp.id]) }
+
+ assert_equal relation.call.cache_key, relation.call.load.cache_key
+ end
これにより、sqlite3、postgresql、mysql2 の全テスト環境で cache_key の一致が保証され、以前の例外は完全に解消されました。
設計判断
filter_map の採用は、未ロード時に既に採用されている NULL スキップロジックとロード済み時の挙動を揃える選択です。新たな設定キーやオプションを導入することなく、既存メソッド内部だけで安全性を向上させた点が後方互換性を保つ設計として評価できます。
また、filter_map は Ruby 2.7 以降で標準提供されるメソッドであり、追加の依存や大きなパフォーマンスコストを伴わないため、実装リスクが最小化されています。この変更は API の一貫性 と 例外安全性 を同時に高めるという設計原則に沿ったものです。
まとめ
ロード済みコレクションに NULL タイムスタンプが混在するケースで Relation#cache_key が例外を投げていた問題は、filter_map に置き換えるシンプルな修正で解決しました。CHANGELOG への記載とテスト追加により、修正の可視性と回帰防止が確保され、Rails のキャッシュ機構がより堅牢になりました。