ネストされたセーブポイントロールバック後に `lock_version` を正しくリセット
ネストされたトランザクション(requires_new: true)がロールバックした後、インメモリの lock_version がデータベースの状態と乖離する問題が修正されました。これにより、セーブポイントのロールバック後に続く保存操作が誤って ActiveRecord::StaleObjectError を発生させることがなくなります。
背景
この修正は #57363 のフォローアップです。#57363 は、楽観的ロックを使用するレコードが最外層のトランザクションロールバック後にインメモリの lock_version が古い値のままになる問題を修正しましたが、requires_new: true のセーブポイントが単独でロールバックするケースは対象外とされていました。
問題の再現パターンは2つあります。ひとつはセーブポイントのロールバック後、外側のトランザクション内で再度保存するケースです。
widget = Widget.create!(name: "a") # lock_version: 0
Widget.transaction do
Widget.transaction(requires_new: true) do
widget.update!(name: "b") # lock_version: 0 -> 1
raise ActiveRecord::Rollback # セーブポイントロールバック: DB lock_version -> 0
end
widget.lock_version # => 1 (stale; DB は 0)
widget.update!(name: "c") # ActiveRecord::StaleObjectError が発生
end
もうひとつは外側のトランザクションがコミットした後に再保存するケースです。いずれも、セーブポイントのロールバックによってデータベース側の lock_version が戻されるにもかかわらず、インメモリの値が更新後の値のままになることが原因です。
根本原因は restore_transaction_record_state の実装にあります。このメソッドはセーブポイントロールバック時(SavepointTransaction#full_rollback? が false を返す場合)に force_restore_state が false かつ restore_state[:level] > 1 の条件で早期リターンするため、ロッキングカラムを含む何も復元されませんでした。_update_row がセーブポイント内でインメモリの lock_version をインクリメントしても、セーブポイントロールバック後にその値は元に戻らず、次回の保存で WHERE 句との不整合が生じていました。
技術的な変更
restore_transaction_record_state に elsif self.class.locking_enabled? ブランチを追加することで、セーブポイントロールバック時にも lock_version を正しく復元するようになりました。
変更前:
def restore_transaction_record_state(force_restore_state = false)
# ...
if force_restore_state || restore_state[:level] <= 1
# 既存の完全リストアロジック
# ...
freeze if restore_state[:frozen?]
end
# elif がなく、ネストされたセーブポイントのロールバック時は何も行われない
end
変更後:
def restore_transaction_record_state(force_restore_state = false)
# ...
if force_restore_state || restore_state[:level] <= 1
# 既存の完全リストアロジック
# ...
freeze if restore_state[:frozen?]
elsif self.class.locking_enabled?
# ネストされたセーブポイントロールバック専用の処理
locking_column = self.class.locking_column
attr = restore_state[:attributes][locking_column]
if attr
@attributes.write_from_database(locking_column, attr.original_value)
end
end
end
スナップショット(restore_state[:attributes])から対象カラムの original_value を取り出し、write_from_database で @attributes に書き戻します。これは #57363 が最外層のロールバックに対して採用したアプローチと同じです。この処理によって、WHERE 句の値とダーティトラッキングの状態がいずれも「セーブポイント前の状態」に戻り、次回の保存が正常に通るようになります。
テストは既存の OptimisticLockingRollbackTest クラスに2件追加されています。
-
test_lock_version_restored_after_savepoint_rollback: セーブポイントのロールバック後、外側のトランザクション内での再保存が成功することを確認 -
test_lock_version_restored_after_savepoint_rollback_when_outer_commits: セーブポイントのロールバック後、外側のトランザクションがコミットし、その後の再保存が成功することを確認
設計判断
完全リストアとロッキングカラム限定リストアを分岐させる設計が採用されました。
セーブポイントロールバック時に完全なレコードリストアを行わないのは、外側のトランザクション内で行われた他の変更を失わせないためです。今回の修正はロッキングカラムのみを対象として最小限の復元を行います。変更の影響範囲を locking_enabled? の場合のみに絞ることで、楽観的ロックを使用しないモデルへの影響を回避しています。
PR本文では、対応できないコーナーケースについても明示されています。外側のトランザクションが同じレコードをセーブポイント前に保存している場合、スナップショットの original_value がセーブポイント直前のデータベース値と一致しなくなるため、本修正では対処できません。この問題を根本解決するにはセーブポイントごとのスナップショット(per-level snapshots)が必要ですが、スコープが大きくなるとして今回は持ち越されています。
まとめ
本PRは、#57363 で残されていた「ネストされたセーブポイントロールバック」のケースを補完するものです。restore_transaction_record_state に最小限の elsif ブランチを追加するだけで、requires_new: true を使う一般的なトランザクションパターンにおける StaleObjectError を解消しており、既存の完全リストアロジックには手を加えない堅実な修正といえます。