Blobアナライズ時に親レコードの`lock_version`を更新しないよう修正

rails/rails

ActiveStorage::AnalyzeJobがBlobを解析するとき、attachされた親レコードのlock_versionを誤って更新してしまう問題が修正されました。これにより、Blobアナライズと並行してフォーム編集を行うユーザーがActiveRecord::StaleObjectErrorを受け取るという競合状態が解消されます。

背景

Optimistic lockingを使用するモデルにファイルをattachすると、バックグラウンドで実行されるActiveStorage::AnalyzeJoblock_versionを誤って更新し、虚偽のStaleObjectErrorを引き起こす競合状態が存在していました(#55764)。

問題のルートは、Blobのアナライズ完了後に呼ばれるtouch_attachmentsrecord.touchを呼び出し、それがLocking::Optimistic#_update_rowを経由することでlock_versionを更新してしまう点にあります。この「親レコードへのタッチ(カスケード)」自体は意図的な実装で、#45567(コミット294c271)で追加されました。アナライズ前後でキャッシュキーを無効化し、古い未解析のビューが返され続けるのを防ぐためです。

lock_versionの更新は、このカスケードの副作用として生じていました。Blobアナライズは親レコードのフィールドを一切変更しないため、lock_versionを更新する理由は存在しません。既存の設定項目touch_attachment_records = false#49723)ではカスケード自体を無効化してしまい、#45567のキャッシュ無効化の問題が再発するため、ワークアラウンドとして使えませんでした。

技術的な変更

本PRでは、lock_versionの更新を抑制しつつupdated_atは正常に更新するブロックヘルパーActiveRecord::Locking::Optimistic.preserve_lock_version_on_touchを新設し、touch_attachments内でそれを使用する形に変更しています。

activerecord/lib/active_record/locking/optimistic.rbへの変更:

ブロックヘルパーpreserve_lock_version_on_touchは、ActiveSupport::IsolatedExecutionStateを使ってフラグを管理します。Fiberローカルなストレージを使用することで、並行する他のリクエストや非同期処理のコンテキストには影響しません。

def self.preserve_lock_version_on_touch # :nodoc:
  prior = ActiveSupport::IsolatedExecutionState[:active_record_preserve_lock_version_on_touch]
  ActiveSupport::IsolatedExecutionState[:active_record_preserve_lock_version_on_touch] = true
  yield
ensure
  ActiveSupport::IsolatedExecutionState[:active_record_preserve_lock_version_on_touch] = prior
end

_touch_rowでは、フラグが立っている場合にlocking_column(デフォルトではlock_version)を@_touch_attr_namesに追加しないよう変更されています。これにより、dirty trackingがlock_versionを変更済みとして扱わなくなります。また、_update_rowではattempted_action == "touch" かつフラグが立っているとき、Persistence#_update_row(locking制約なし)へフォールスルーします。このattempted_actionガードにより、通常のsaveはブロック内であっても引き続きlockingパスを通ります。

def _touch_row(attribute_names, time)
  if locking_enabled? && !_skip_locking_column_on_touch?
    @_touch_attr_names << self.class.locking_column
  end
  super
ensure
  @_skip_locking_column_on_touch = nil
end

belongs_to ..., touch: trueは実際のタッチをbefore_committed!まで遅延させるため、ブロックを抜けた後で親レコードへのタッチが発生します。この問題に対処するため、DeferredTouchモジュールが新設されています。このモジュールはtouch_laterをフックし、ブロック実行中にフラグが立っていれば、レコードインスタンスへ@_skip_locking_column_on_touch = trueを記録します。遅延タッチが実際に発火するとき、このインスタンスフラグを参照してlocking制約なしで実行されます。

module DeferredTouch # :nodoc:
  def touch_later(*)
    if Optimistic.preserving_lock_version_on_touch?
      @_skip_locking_column_on_touch = true
    end
    super
  end
end

activestorage/app/models/active_storage/blob.rbへの変更:

touch_attachments全体をpreserve_lock_version_on_touchブロックで囲む変更が加えられました。

変更前:

def touch_attachments
  attachments.then do |relation|
    if ActiveStorage.touch_attachment_records
      relation.includes(:record)
    else
      relation
    end
  end.each do |attachment|
    attachment.touch
  end
end

変更後:

def touch_attachments
  # The cascade exists to invalidate cache keys; it must not bump
  # +lock_version+ on parents, since blob analysis does not modify any
  # field of theirs and would otherwise race with concurrent edits.
  ActiveRecord::Locking::Optimistic.preserve_lock_version_on_touch do
    attachments.then do |relation|
      if ActiveStorage.touch_attachment_records
        relation.includes(:record)
      else
        relation
      end
    end.each do |attachment|
      attachment.touch
    end
  end
end

テストでは、Userテーブルにlock_versionカラムを追加したうえで、以下の2点を確認しています:

  • Blobアナライズ後にupdated_atは変化するがlock_versionは変化しないこと
  • アナライズ後にstaleになったレコードへのupdate!StaleObjectErrorを raise しないこと

設計判断

preserve_lock_version_on_touchを汎用ヘルパーとしてActiveRecordに実装し、ActiveStorage側からそれを呼び出すレイヤード設計が選ばれました。

Blobアナライズ以外にも「タッチによるキャッシュ無効化は必要だがlock_versionの更新は不要」というケースは存在しうるため、ヘルパーをActiveRecord側に置くことで再利用性を確保しています。一方で、ActiveStorage側の変更はtouch_attachmentsの1箇所に限定されており、影響範囲が最小化されています。

スレッドセーフティの面では、IsolatedExecutionStateを使用することで、Fiber単位のスコープでフラグを管理しています。また、ensureで確実にフラグを復元する実装により、ブロックが例外で終了しても他の処理への影響を防いでいます。_touch_rowensure節でインスタンスフラグをクリアしているのも、同様のクリーンアップの考慮です。

選択されなかった代替案として、PRでは「アップロードごとにanalyze_blobs_async: falseを設定する」「カスケード全体を無効化する(touch_attachment_records = false)」「ドキュメントのみで対処する」の3案が検討されましたが、それぞれ同期処理のコスト増加、キャッシュ無効化の退行、バグの温存といった問題があり採用されていません。

まとめ

本PRはActiveStorageとActiveRecordのoptimistic lockingが交差する箇所の長年の設計上の盲点を修正しています。キャッシュ無効化のためのタッチカスケードは維持しつつ、BlobアナライズというActiveRecord側のフィールドを変更しないコードパスに限定してlock_versionの更新を抑制するという、最小侵襲の解決策が実現されています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
85197122

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)の3部構成が明確に適用されており、ガイドラインに完全に準拠しています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト、GitHubのPR/Issue/コミットへのリンク記法がすべて正しく使用されています。

対象読者への適合性 ✓ PASS

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

内容はActiveRecordのOptimistic LockingやActiveStorageの内部実装に関するもので、専門知識を持つエンジニアという対象読者に完全に適合しています。

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

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

各セクションが「総論→各論」で構成され、各パラグラフがトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、非常に読みやすい構成です。

Diff内容との照合 ⚠ WARNING

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

ほとんどのコード引用はDiffと正確に一致していますが、「_touch_row」メソッドの引用部分で、提供されたDiff情報には含まれていない「_skip_locking_column_on_touch?」メソッドが使用されています。これは提供されたDiffが不完全なため照合できないものであり、技術的な文脈は正しいため理解を妨げませんが、厳密にはDiffとの完全な一致ではありません。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「Optimistic locking」「StaleObjectError」「Fiberローカル」「dirty tracking」など、Railsに関連する技術用語が正確かつ文脈に適した形で使用されています。

説明の技術的正確性 ✓ PASS

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

PRで行われた技術的な変更(`preserve_lock_version_on_touch`ヘルパーの追加、`_touch_row`や`_update_row`の挙動変更など)に関する説明は、PR DescriptionやDiffの内容と一致しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張(問題の背景、解決策、設計判断、代替案など)は、PRのDescriptionやDiffの内容によって裏付けられており、ハルシネーションは検出されませんでした。

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

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

PR番号(#57284)、関連Issue番号(#55764)、コミットID(294c271)など、すべての数値・固有名詞は正確に記載されています。

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

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

記事のタイトル「Blobアナライズ時に親レコードの`lock_version`を更新しないよう修正」は、PRのタイトル「Don't bump lock_version on records during blob analysis」の内容を正確に反映しています。

外部知識の正確性 ✓ PASS

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

記事には、PR情報に記載のないバージョンサポート状況やリリース日程などの外部知識は含まれていません。

時間表現の正確性 ✓ PASS

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

記事内の時間表現(問題の発生経緯、今回の修正など)は、PR情報と整合性が取れており、正確です。