Skip to content

Commit bf24af7

Browse files
authored
Invalidate transaction as early as possible
This should allow framework to avoid issuing `ROLLBACK` statements for cases when db has already rolled back the transaction
1 parent b75fc7b commit bf24af7

File tree

5 files changed

+92
-4
lines changed

5 files changed

+92
-4
lines changed

activerecord/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
* Invalidate transaction as early as possible
2+
3+
After rescuing a `TransactionRollbackError` exception Rails invalidates transactions earlier in the flow
4+
allowing the framework to skip issuing the `ROLLBACK` statement in more cases.
5+
Only affects adapters that have `savepoint_errors_invalidate_transactions?` configured as `true`,
6+
which at this point is only applicable to the `mysql2` adapter.
7+
8+
*Nikita Vasilevsky*
9+
110
* Allow configuring columns list to be used in SQL queries issued by an `ActiveRecord::Base` object
211

312
It is now possible to configure columns list that will be used to build an SQL query clauses when

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -457,10 +457,6 @@ def within_new_transaction(isolation: nil, joinable: true)
457457
ret
458458
rescue Exception => error
459459
if transaction
460-
if error.is_a?(ActiveRecord::TransactionRollbackError) &&
461-
@connection.savepoint_errors_invalidate_transactions?
462-
transaction.state.invalidate!
463-
end
464460
rollback_transaction
465461
after_failure_actions(transaction, error)
466462
end

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,7 @@ def with_raw_connection(allow_retry: false, uses_transaction: true)
972972
result
973973
rescue => original_exception
974974
translated_exception = translate_exception_class(original_exception, nil, nil)
975+
invalidate_transaction(translated_exception)
975976
retry_deadline_exceeded = deadline && deadline < Process.clock_gettime(Process::CLOCK_MONOTONIC)
976977

977978
if !retry_deadline_exceeded && retries_available > 0
@@ -1007,6 +1008,13 @@ def retryable_connection_error?(exception)
10071008
exception.is_a?(ConnectionNotEstablished) || exception.is_a?(ConnectionFailed)
10081009
end
10091010

1011+
def invalidate_transaction(exception)
1012+
return unless exception.is_a?(TransactionRollbackError)
1013+
return unless savepoint_errors_invalidate_transactions?
1014+
1015+
current_transaction.state.invalidate! if current_transaction
1016+
end
1017+
10101018
def retryable_query_error?(exception)
10111019
# We definitely can't retry if we were inside a transaction that was instantly
10121020
# rolled back by this error

activerecord/test/cases/adapter_test.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,3 +785,27 @@ def test_advisory_locks_enabled?
785785
end
786786
end
787787
end
788+
789+
if ActiveRecord::Base.connection.savepoint_errors_invalidate_transactions?
790+
class InvalidateTransactionTest < ActiveRecord::TestCase
791+
def test_invalidates_transaction_on_rollback_error
792+
@invalidated = false
793+
connection = ActiveRecord::Base.connection
794+
795+
connection.transaction do
796+
connection.send(:with_raw_connection) do
797+
raise ActiveRecord::Deadlocked, "made-up deadlock"
798+
end
799+
800+
rescue ActiveRecord::Deadlocked => error
801+
flunk("Rescuing wrong error") unless error.message == "made-up deadlock"
802+
803+
@invalidated = connection.current_transaction.state.invalidated?
804+
end
805+
806+
# asserting outside of the transaction to make sure we actually reach the end of the test
807+
# and perform the assertion
808+
assert @invalidated
809+
end
810+
end
811+
end

activerecord/test/cases/adapters/mysql2/nested_deadlock_test.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,57 @@ class Sample < ActiveRecord::Base
7878
assert_predicate connection, :active?
7979
end
8080

81+
test "rollback exception is swallowed after a rollback" do
82+
barrier = Concurrent::CyclicBarrier.new(2)
83+
deadlocks = 0
84+
85+
s1 = Sample.create value: 1
86+
s2 = Sample.create value: 2
87+
88+
thread = Thread.new do
89+
Sample.transaction(requires_new: false) do
90+
make_parent_transaction_dirty
91+
Sample.transaction(requires_new: true) do
92+
assert_current_transaction_is_savepoint_transaction
93+
s1.lock!
94+
barrier.wait
95+
s2.update value: 4
96+
97+
rescue ActiveRecord::Deadlocked
98+
deadlocks += 1
99+
100+
# This rollback is actually wrong as mysql automatically rollbacks the transaction
101+
# which means we have nothing to rollback on the db side
102+
# but we expect the framework to handle our mistake gracefully
103+
raise ActiveRecord::Rollback
104+
end
105+
106+
s2.update value: 10
107+
end
108+
end
109+
110+
begin
111+
Sample.transaction(requires_new: false) do
112+
make_parent_transaction_dirty
113+
Sample.transaction(requires_new: true) do
114+
assert_current_transaction_is_savepoint_transaction
115+
s2.lock!
116+
barrier.wait
117+
s1.update value: 3
118+
rescue ActiveRecord::Deadlocked
119+
deadlocks += 1
120+
raise ActiveRecord::Rollback
121+
end
122+
s1.update value: 10
123+
end
124+
ensure
125+
thread.join
126+
end
127+
128+
assert_equal 1, deadlocks, "deadlock is required for the test setup"
129+
assert_equal [10, 10], Sample.pluck(:value)
130+
end
131+
81132
test "deadlock inside nested SavepointTransaction is recoverable" do
82133
barrier = Concurrent::CyclicBarrier.new(2)
83134
deadlocks = 0

0 commit comments

Comments
 (0)