トランザクションロールバック後に`lock_version`が古い値のままになるバグを修正

rails/rails

トランザクション内でオプティミスティックロックを使用しているレコードを保存した後、そのトランザクションがロールバックされると、インメモリのlock_versionがインクリメントされた値のまま残り、次の保存でStaleObjectErrorが発生するバグが修正されました。

背景

オプティミスティックロックを持つレコードをトランザクション内で保存し、そのトランザクションがロールバックされると、インメモリとデータベースのlock_versionが不一致になる問題がありました。以下のようなコードで再現します。

widget = Widget.create!(name: "a")  # lock_version: 0

Widget.transaction do
  widget.update!(name: "b")          # lock_version: 0 -> 1
  raise ActiveRecord::Rollback
end

widget.lock_version                  # => 1(DBは0に戻っているため、古い値)
widget.update!(name: "c")            # => ActiveRecord::StaleObjectError!

ロールバック後のワークアラウンドとしてrecord.reloadを呼ぶことは文書化されていましたが、次の保存まで問題が顕在化しないサイレントな失敗でした。transaction { update; rollback } + retryという構造はよくあるパターンであり、ロールバック後のインメモリ状態はデータベースと一致すべきであるというのが、この修正の動機です。

技術的な変更

根本原因は、restore_transaction_record_state における@attributesのスナップショットが参照コピーになっていたことにあります。

保存開始時に呼ばれるremember_transaction_record_stateは、@attributesをディープコピーではなく参照として保持します。その後、Locking::Optimistic#_update_row がロッキングカラムをself[locking_column] += 1でインクリメントすると、この変更がスナップショットの保持するAttributeSetにも反映されてしまいます。保存成功後にDirty#forget_attribute_assignments@attributesを新しいAttributeSetに差し替えますが、スナップショット側はAttribute(value=N+1, original=N)という「汚れた」状態のまま残ります。

ロールバック時の復元ループはattr.value == current_value(どちらもN+1)の場合に早期リターンし、この汚れたスナップショット属性をそのまま維持します。次の保存では_lock_value_for_databaseがインクリメント済みの値をWHERE句で使用するため、データベース上には存在しない値でのマッチを試み、StaleObjectErrorが発生します。

修正はactiverecord/lib/active_record/transactions.rbrestore_transaction_record_state内で、ロッキングカラムを特別扱いすることで対処しています。

変更前:

@attributes = restore_state[:attributes].map do |attr|
  value = @attributes.fetch_value(attr.name)
  attr = attr.with_value_from_user(value) if attr.value != value
  attr
end

変更後:

locking_column = self.class.locking_column if self.class.locking_enabled?
@attributes = restore_state[:attributes].map do |attr|
  if attr.name == locking_column
    # スナップショットに残った汚れた属性を、
    # 元の値(original_value)から再構築して、
    # Attribute(value=N, original=N)というクリーンな状態にする
    next attr.with_value_from_database(attr.original_value)
  end
  value = @attributes.fetch_value(attr.name)
  attr = attr.with_value_from_user(value) if attr.value != value
  attr
end

attr.with_value_from_database(attr.original_value) でロッキングカラムを再構築することにより、Attribute(value=N, original=N)というクリーンな状態が得られ、WHERE句もダーティトラッキングもインクリメントが起きなかった状態として動作します。

テストはactiverecord/test/cases/locking_test.rbOptimisticLockingRollbackTestクラスとして追加されました。self.use_transactional_tests = falseとしてテストフレームワーク外のトランザクションを使用しているのは、テストフレームワーク内のセーブポイントがrestore_transaction_record_stateの復元ブランチをスキップしてしまうためです。テストはロールバック後にlock_version0に戻っていること、ダーティとしてマークされていないこと、そしてStaleObjectErrorなしに保存できることを検証します。

設計判断

ロールバック対象をロッキングカラムのみに限定する アプローチが採用されました。

根本的な解決策として@attributesのスナップショット自体をディープコピーする方法も考えられますが、PRではより局所的な修正が選ばれています。また、PRの説明では、この修正が対象とするスコープが明示されています。外側のトランザクションがロールバックする場合(force_restore_state: trueまたはrestore_state[:level] <= 1)は修正されますが、transaction { transaction(requires_new: true) { update!; raise Rollback } }のようなネストされたセーブポイントのロールバックではrestore_transaction_record_stateが早期リターンするため、同様の問題が残ることがPR内で明示されています。

with_value_from_databaseを使ってoriginal_valueから再構築するアプローチは、データベースから再読み込みしたかのような状態(value と original が一致したクリーンな属性)を再現するもので、既存の属性操作APIを最大限に活用した最小限の変更です。

まとめ

この修正は、@attributesを参照共有するスナップショットの設計上の盲点を、ロッキングカラムの復元ロジックに特別処理を追加することで解消したものです。スナップショット全体のディープコピーという広範な変更を避け、影響を最小限に絞った判断は、Railsのような大規模フレームワークでのバグ修正のアプローチとして参考になります。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

「リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)」という構成が明確で、理想的な構造です。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライトとGitHub PRへのリンク記法が、ガイドラインに沿って正しく使用されています。

対象読者への適合性 ✓ PASS

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

Active Recordの内部実装に関する深い内容を扱っており、専門知識を持つエンジニアという対象読者に完全に適合しています。

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

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

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

Diff内容との照合 ✓ PASS

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

記事内のコードブロックは、提供されたDiffの内容を正確に反映しており、ファイルパスも一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「オプティミスティックロック」「StaleObjectError」「AttributeSet」など、技術用語が正確かつ文脈に即して適切に使用されています。

説明の技術的正確性 ✓ PASS

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

参照コピーが原因であること、復元ロジックの挙動、修正による効果など、技術的な説明がPRの内容と一致しており、論理的で正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張(問題の再現方法、原因、解決策、設計判断)が、提供されたPR情報(Description、Diff)によって裏付けられており、ハルシネーションは見られません。

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

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

PR番号(#57363)、ファイルパス、メソッド名などの固有名詞がすべて正確に記載されています。

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

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

記事のタイトルはPR「Reset `lock_version` after transaction rollback」の内容を的確に要約しており、主題との一致も完璧です。

外部知識の正確性 ✓ PASS

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

記事の内容はすべてPR情報に基づいており、バージョン情報やリリース予定といったPR外の外部知識の追加はありません。

時間表現の正確性 ✓ PASS

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

「以前は〜だったが、この修正により〜」といった時間的な前後関係や状態変化の表現が、PRの内容と一致しており正確です。