ロード済みコレクションの NULL タイムスタンプで Relation#cache_key がクラッシュするバグを修正

rails/rails

Relation#cache_keycache_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 がデフォルトの環境でも再現し、sqlite3postgresqlmysql2 いずれでも同様の失敗が報告されていました。結果として、開発者はキャッシュキー生成が不安定になるケースに遭遇し、デバッグコストが増大していました。

技術的な変更

ロード済み経路の実装を 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

これにより、sqlite3postgresqlmysql2 の全テスト環境で cache_key の一致が保証され、以前の例外は完全に解消されました。

設計判断

filter_map の採用は、未ロード時に既に採用されている NULL スキップロジックとロード済み時の挙動を揃える選択です。新たな設定キーやオプションを導入することなく、既存メソッド内部だけで安全性を向上させた点が後方互換性を保つ設計として評価できます。

また、filter_map は Ruby 2.7 以降で標準提供されるメソッドであり、追加の依存や大きなパフォーマンスコストを伴わないため、実装リスクが最小化されています。この変更は API の一貫性例外安全性 を同時に高めるという設計原則に沿ったものです。

まとめ

ロード済みコレクションに NULL タイムスタンプが混在するケースで Relation#cache_key が例外を投げていた問題は、filter_map に置き換えるシンプルな修正で解決しました。CHANGELOG への記載とテスト追加により、修正の可視性と回帰防止が確保され、Rails のキャッシュ機構がより堅牢になりました。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
31ddb629

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文がタイトル直下にあり、背景・技術的な変更・設計判断・まとめの各セクションが揃っている。まとめは単なる繰り返しではなく、変更の意義と影響を述べている。

カスタムMarkdown構文 ⚠ WARNING

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックのファイル名付きシンタックスハイライトは正しい形式(```ruby:activerecord/lib/active_record/relation.rb`)。しかし PR 番号のリンクが「PR #57603」の形式で、要求される `[#57603](URL)` の記法になっていない。コミットIDのリンクも欠如しているが、内容理解には影響しないため警告に留めた。

対象読者への適合性 ✓ PASS

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

Rails エンジニアを想定した高度な技術内容で、初心者向けの過剰な説明はない。

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

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

各セクションは総論・各論・結論の構成になっており、段落はトピックセンテンスで始まり 1 段落 1 トピックが保たれている。段落長も適切で空行で区切られている。

Diff内容との照合 ✓ PASS

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

記事中のコードブロックは提供された Diff と完全に一致している。ファイル名・変更行ともに正確。テストコードの追加部分も Diff の追加行と合致している。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`cache_key`, `cache_version`, `filter_map`, `Relation#compute_cache_version` など PR で使用されている用語と一致し、誤用はない。

説明の技術的正確性 ✓ PASS

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

変更理由・実装内容・影響の説明が PR の記述と整合しており、技術的に誤りはない。

事実の突合 ✓ PASS

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

記事の主張はすべて PR の説明・Diff で裏付けられている。根拠のない推測はなく、ハルシネーションは見られない。

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

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

PR 番号 #57603 の記載は正しい。その他数値・固有名詞の誤りはない。

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

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

記事タイトルは PR のタイトル内容を日本語で的確に要約しており、意味的に一致している。

外部知識の正確性 ✓ PASS

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

Ruby 2.7 以降で `filter_map` が利用可能という記述は事実であり、PR に記載のないが外部知識の不適切な付加ではない。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PR の「loaded」/「unloaded」の状態に合わせた記述となっている。