ネストされたセーブポイントロールバック後に `lock_version` を正しくリセット

rails/rails

ネストされたトランザクション(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_statefalse かつ restore_state[:level] > 1 の条件で早期リターンするため、ロッキングカラムを含む何も復元されませんでした。_update_row がセーブポイント内でインメモリの lock_version をインクリメントしても、セーブポイントロールバック後にその値は元に戻らず、次回の保存で WHERE 句との不整合が生じていました。

技術的な変更

restore_transaction_record_stateelsif 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 を解消しており、既存の完全リストアロジックには手を加えない堅実な修正といえます。

記事メタデータ

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

この記事は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リンク記法の正確性

ファイル名付きのシンタックスハイライト(```ruby:path/to/file)とGitHubのPR番号リンク([#123](URL))の両方が、ガイドラインに沿って正しく使用されています。

対象読者への適合性 ✓ PASS

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

「セーブポイント」「楽観的ロック」などの専門用語が前提知識として扱われており、専門知識を持つエンジニアという対象読者に適切です。

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

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

各セクションが総論→各論→結論で構成され、各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則を完全に満たしており、非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事内のコードブロックは、提供されたDiff情報(`activerecord/lib/active_record/transactions.rb`への変更)を正確に反映しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`lock_version`、`requires_new: true`、`StaleObjectError`といった技術用語が、PRの文脈に沿って正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

ネストされたセーブポイントロールバック時に`restore_transaction_record_state`が早期リターンする根本原因や、`elsif`ブロック追加による解決策の説明が、PR Descriptionの内容と一致しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張(#57363のフォローアップであること、未対応のコーナーケースの存在など)は、PRのDescriptionやDiffで裏付けられており、ハルシネーションは検出されませんでした。

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

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

PR番号(#57400)、関連PR番号(#57363)などの数値や固有名詞はすべて正確です。

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

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

記事のタイトルはPRのタイトル「Reset `lock_version` after a nested savepoint rollback」を正確に反映しており、内容との整合性も取れています。

外部知識の正確性 ✓ PASS

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

記事内容はすべて提供されたPR情報に基づいており、LTSやリリース日程といったPRに記載のない外部知識の追加はありません。

時間表現の正確性 ✓ PASS

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

「問題が修正されました」といった表現は、PRが問題を解決したという事実と時間的に一致しており、表現の歪曲はありません。