Skip to content

Commit 0237da2

Browse files
committed
Apply record state based on parent transaction state
Let's say you have a nested transaction and both records are saved. Before the outer transaction closes, a rollback is performed. Previously the record in the outer transaction would get marked as not persisted but the inner transaction would get persisted. ```ruby Post.transaction do post_one.save # will get rolled back Post.transaction(requires_new: true) do post_two.save # incorrectly remains marked as persisted end raise ActiveRecord::Rollback end ``` To fix this the PR changes transaction handling to have the child transaction ask the parent how the records should be marked. When there are child transactions, it will always be a SavpointTransaction because the stack isn't empty. From there we pass the parent_transaction to the child SavepointTransaction where we add the children to the parent so the parent can mark the inner transaction as rolledback and thus mark the record as not persisted. `update_attributes_from_transaction_state` uses the `completed?` check to correctly mark all the transactions as rolledback and the inner record as not persisted. ```ruby Post.transaction do post_one.save # will get rolled back Post.transaction(requires_new: true) do post_two.save # with new behavior, correctly marked as not persisted on rollback end raise ActiveRecord::Rollback end ``` Fixes #29320
1 parent 608ebcc commit 0237da2

File tree

4 files changed

+96
-3
lines changed

4 files changed

+96
-3
lines changed

activerecord/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
* Fix transactions to apply state to child transactions
2+
3+
Previously if you had a nested transaction and the outer transaction was rolledback the record from the
4+
inner transaction would still be marked as persisted.
5+
6+
This change fixes that by applying the state of the parent transaction to the child transaction when the
7+
parent transaction is rolledback. This will correctly mark records from the inner transaction as not persisted.
8+
9+
*Eileen M. Uchitelle*, *Aaron Patterson*
10+
111
* Deprecate `set_state` method in `TransactionState`
212

313
Deprecated the `set_state` method in favor of setting the state via specific methods. If you need to mark the

activerecord/lib/active_record/connection_adapters/abstract/transaction.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ module ConnectionAdapters
33
class TransactionState
44
def initialize(state = nil)
55
@state = state
6+
@children = []
7+
end
8+
9+
def add_child(state)
10+
@children << state
611
end
712

813
def finalized?
@@ -17,6 +22,10 @@ def rolledback?
1722
@state == :rolledback
1823
end
1924

25+
def fully_completed?
26+
completed?
27+
end
28+
2029
def completed?
2130
committed? || rolledback?
2231
end
@@ -40,6 +49,7 @@ def set_state(state)
4049
end
4150

4251
def rollback!
52+
@children.each { |c| c.rollback! }
4353
@state = :rolledback
4454
end
4555

@@ -121,8 +131,11 @@ def open?; !closed?; end
121131
end
122132

123133
class SavepointTransaction < Transaction
124-
def initialize(connection, savepoint_name, options, *args)
134+
def initialize(connection, savepoint_name, parent_transaction, options, *args)
125135
super(connection, options, *args)
136+
137+
parent_transaction.state.add_child(@state)
138+
126139
if options[:isolation]
127140
raise ActiveRecord::TransactionIsolationError, "cannot set transaction isolation in a nested transaction"
128141
end
@@ -176,7 +189,7 @@ def begin_transaction(options = {})
176189
if @stack.empty?
177190
RealTransaction.new(@connection, options, run_commit_callbacks: run_commit_callbacks)
178191
else
179-
SavepointTransaction.new(@connection, "active_record_#{@stack.size}", options,
192+
SavepointTransaction.new(@connection, "active_record_#{@stack.size}", @stack.last, options,
180193
run_commit_callbacks: run_commit_callbacks)
181194
end
182195

activerecord/lib/active_record/transactions.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,7 @@ def sync_with_transaction_state
490490
def update_attributes_from_transaction_state(transaction_state)
491491
if transaction_state && transaction_state.finalized?
492492
restore_transaction_record_state if transaction_state.rolledback?
493-
clear_transaction_record_state
493+
clear_transaction_record_state if transaction_state.fully_completed?
494494
end
495495
end
496496
end

activerecord/test/cases/transactions_test.rb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,76 @@ def test_nested_explicit_transactions
304304
assert !Topic.find(2).approved?, "Second should have been unapproved"
305305
end
306306

307+
def test_nested_transaction_with_new_transaction_applies_parent_state_on_rollback
308+
topic_one = Topic.new(title: "A new topic")
309+
topic_two = Topic.new(title: "Another new topic")
310+
311+
Topic.transaction do
312+
topic_one.save
313+
314+
Topic.transaction(requires_new: true) do
315+
topic_two.save
316+
317+
assert_predicate topic_one, :persisted?
318+
assert_predicate topic_two, :persisted?
319+
end
320+
321+
raise ActiveRecord::Rollback
322+
end
323+
324+
refute_predicate topic_one, :persisted?
325+
refute_predicate topic_two, :persisted?
326+
end
327+
328+
def test_nested_transaction_without_new_transaction_applies_parent_state_on_rollback
329+
topic_one = Topic.new(title: "A new topic")
330+
topic_two = Topic.new(title: "Another new topic")
331+
332+
Topic.transaction do
333+
topic_one.save
334+
335+
Topic.transaction do
336+
topic_two.save
337+
338+
assert_predicate topic_one, :persisted?
339+
assert_predicate topic_two, :persisted?
340+
end
341+
342+
raise ActiveRecord::Rollback
343+
end
344+
345+
refute_predicate topic_one, :persisted?
346+
refute_predicate topic_two, :persisted?
347+
end
348+
349+
def test_double_nested_transaction_applies_parent_state_on_rollback
350+
topic_one = Topic.new(title: "A new topic")
351+
topic_two = Topic.new(title: "Another new topic")
352+
topic_three = Topic.new(title: "Another new topic of course")
353+
354+
Topic.transaction do
355+
topic_one.save
356+
357+
Topic.transaction do
358+
topic_two.save
359+
360+
Topic.transaction do
361+
topic_three.save
362+
end
363+
end
364+
365+
assert_predicate topic_one, :persisted?
366+
assert_predicate topic_two, :persisted?
367+
assert_predicate topic_three, :persisted?
368+
369+
raise ActiveRecord::Rollback
370+
end
371+
372+
refute_predicate topic_one, :persisted?
373+
refute_predicate topic_two, :persisted?
374+
refute_predicate topic_three, :persisted?
375+
end
376+
307377
def test_manually_rolling_back_a_transaction
308378
Topic.transaction do
309379
@first.approved = true

0 commit comments

Comments
 (0)