トランザクションロールバック後に`lock_version`が古い値のままになるバグを修正
トランザクション内でオプティミスティックロックを使用しているレコードを保存した後、そのトランザクションがロールバックされると、インメモリの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.rbのrestore_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.rbにOptimisticLockingRollbackTestクラスとして追加されました。self.use_transactional_tests = falseとしてテストフレームワーク外のトランザクションを使用しているのは、テストフレームワーク内のセーブポイントがrestore_transaction_record_stateの復元ブランチをスキップしてしまうためです。テストはロールバック後にlock_versionが0に戻っていること、ダーティとしてマークされていないこと、そして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のような大規模フレームワークでのバグ修正のアプローチとして参考になります。