Blobアナライズ時に親レコードの`lock_version`を更新しないよう修正
ActiveStorage::AnalyzeJobがBlobを解析するとき、attachされた親レコードのlock_versionを誤って更新してしまう問題が修正されました。これにより、Blobアナライズと並行してフォーム編集を行うユーザーがActiveRecord::StaleObjectErrorを受け取るという競合状態が解消されます。
背景
Optimistic lockingを使用するモデルにファイルをattachすると、バックグラウンドで実行されるActiveStorage::AnalyzeJobがlock_versionを誤って更新し、虚偽のStaleObjectErrorを引き起こす競合状態が存在していました(#55764)。
問題のルートは、Blobのアナライズ完了後に呼ばれるtouch_attachmentsがrecord.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_rowのensure節でインスタンスフラグをクリアしているのも、同様のクリーンアップの考慮です。
選択されなかった代替案として、PRでは「アップロードごとにanalyze_blobs_async: falseを設定する」「カスケード全体を無効化する(touch_attachment_records = false)」「ドキュメントのみで対処する」の3案が検討されましたが、それぞれ同期処理のコスト増加、キャッシュ無効化の退行、バグの温存といった問題があり採用されていません。
まとめ
本PRはActiveStorageとActiveRecordのoptimistic lockingが交差する箇所の長年の設計上の盲点を修正しています。キャッシュ無効化のためのタッチカスケードは維持しつつ、BlobアナライズというActiveRecord側のフィールドを変更しないコードパスに限定してlock_versionの更新を抑制するという、最小侵襲の解決策が実現されています。