Skip to content

Commit 1eb5761

Browse files
authored
Merge pull request rails#52104 from Shopify/user-transaction-continued
Harden the `.current_transaction` API
2 parents 827f4ef + fadb683 commit 1eb5761

File tree

8 files changed

+186
-156
lines changed

8 files changed

+186
-156
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ def transaction(requires_new: nil, isolation: nil, joinable: true, &block)
356356
if isolation
357357
raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction"
358358
end
359-
yield current_transaction
359+
yield current_transaction.user_transaction
360360
else
361361
within_new_transaction(isolation: isolation, joinable: joinable, &block)
362362
end

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ def cache_notification_info(sql, name, binds)
277277
type_casted_binds: -> { type_casted_binds(binds) },
278278
name: name,
279279
connection: self,
280-
transaction: current_transaction.presence,
280+
transaction: current_transaction.user_transaction.presence,
281281
cached: true
282282
}
283283
end

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

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,8 @@ def finish(outcome)
108108
end
109109

110110
class NullTransaction # :nodoc:
111-
def initialize; end
112111
def state; end
113112
def closed?; true; end
114-
alias_method :blank?, :closed?
115113
def open?; false; end
116114
def joinable?; false; end
117115
def add_record(record, _ = true); end
@@ -123,12 +121,31 @@ def invalidate!; end
123121
def materialized?; false; end
124122
def before_commit; yield; end
125123
def after_commit; yield; end
126-
def after_rollback; end # noop
127-
def uuid; Digest::UUID.nil_uuid; end
124+
def after_rollback; end
125+
def user_transaction; ActiveRecord::Transaction::NULL_TRANSACTION; end
128126
end
129127

130-
class Transaction < ActiveRecord::Transaction # :nodoc:
131-
attr_reader :connection, :state, :savepoint_name, :isolation_level
128+
class Transaction # :nodoc:
129+
class Callback # :nodoc:
130+
def initialize(event, callback)
131+
@event = event
132+
@callback = callback
133+
end
134+
135+
def before_commit
136+
@callback.call if @event == :before_commit
137+
end
138+
139+
def after_commit
140+
@callback.call if @event == :after_commit
141+
end
142+
143+
def after_rollback
144+
@callback.call if @event == :after_rollback
145+
end
146+
end
147+
148+
attr_reader :connection, :state, :savepoint_name, :isolation_level, :user_transaction
132149
attr_accessor :written
133150

134151
delegate :invalidate!, :invalidated?, to: :@state
@@ -137,14 +154,16 @@ def initialize(connection, isolation: nil, joinable: true, run_commit_callbacks:
137154
super()
138155
@connection = connection
139156
@state = TransactionState.new
157+
@callbacks = nil
140158
@records = nil
141159
@isolation_level = isolation
142160
@materialized = false
143161
@joinable = joinable
144162
@run_commit_callbacks = run_commit_callbacks
145163
@lazy_enrollment_records = nil
146164
@dirty = false
147-
@instrumenter = TransactionInstrumenter.new(connection: connection, transaction: self)
165+
@user_transaction = joinable ? ActiveRecord::Transaction.new(self) : ActiveRecord::Transaction::NULL_TRANSACTION
166+
@instrumenter = TransactionInstrumenter.new(connection: connection, transaction: @user_transaction)
148167
end
149168

150169
def dirty!
@@ -155,6 +174,14 @@ def dirty?
155174
@dirty
156175
end
157176

177+
def open?
178+
true
179+
end
180+
181+
def closed?
182+
false
183+
end
184+
158185
def add_record(record, ensure_finalize = true)
159186
@records ||= []
160187
if ensure_finalize
@@ -165,6 +192,30 @@ def add_record(record, ensure_finalize = true)
165192
end
166193
end
167194

195+
def before_commit(&block)
196+
if @state.finalized?
197+
raise ActiveRecordError, "Cannot register callbacks on a finalized transaction"
198+
end
199+
200+
(@callbacks ||= []) << Callback.new(:before_commit, block)
201+
end
202+
203+
def after_commit(&block)
204+
if @state.finalized?
205+
raise ActiveRecordError, "Cannot register callbacks on a finalized transaction"
206+
end
207+
208+
(@callbacks ||= []) << Callback.new(:after_commit, block)
209+
end
210+
211+
def after_rollback(&block)
212+
if @state.finalized?
213+
raise ActiveRecordError, "Cannot register callbacks on a finalized transaction"
214+
end
215+
216+
(@callbacks ||= []) << Callback.new(:after_rollback, block)
217+
end
218+
168219
def records
169220
if @lazy_enrollment_records
170221
@records.concat @lazy_enrollment_records.values
@@ -276,6 +327,11 @@ def commit_records
276327
def full_rollback?; true; end
277328
def joinable?; @joinable; end
278329

330+
protected
331+
def append_callbacks(callbacks) # :nodoc:
332+
(@callbacks ||= []).concat(callbacks)
333+
end
334+
279335
private
280336
def unique_records
281337
records.uniq(&:__id__)
@@ -557,7 +613,7 @@ def within_new_transaction(isolation: nil, joinable: true)
557613
@connection.lock.synchronize do
558614
transaction = begin_transaction(isolation: isolation, joinable: joinable)
559615
begin
560-
yield transaction
616+
yield transaction.user_transaction
561617
rescue Exception => error
562618
rollback_transaction
563619
after_failure_actions(transaction, error)

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1118,7 +1118,7 @@ def log(sql, name = "SQL", binds = [], type_casted_binds = [], statement_name =
11181118
statement_name: statement_name,
11191119
async: async,
11201120
connection: self,
1121-
transaction: current_transaction.presence,
1121+
transaction: current_transaction.user_transaction.presence,
11221122
row_count: 0,
11231123
&block
11241124
)

activerecord/lib/active_record/transaction.rb

Lines changed: 38 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,17 @@
33
require "active_support/core_ext/digest"
44

55
module ActiveRecord
6-
# This abstract class specifies the interface to interact with the current transaction state.
6+
# Class specifies the interface to interact with the current transaction state.
77
#
8-
# Any other methods not specified here are considered to be private interfaces.
8+
# It can either map to an actual transaction or represent the abscence of a transaction.
9+
#
10+
# == State
11+
#
12+
# You can check whether a transaction is open with the +open?+ or +closed?+ methods
13+
#
14+
# if Article.current_transaction.open?
15+
# # We are inside a transaction
16+
# end
917
#
1018
# == Callbacks
1119
#
@@ -42,90 +50,67 @@ module ActiveRecord
4250
# == Caveats
4351
#
4452
# When using after_commit callbacks, it is important to note that if the callback raises an error, the transaction
45-
# won't be rolled back. Relying solely on these to synchronize state between multiple systems may lead to consistency issues.
53+
# won't be rolled back as it was already committed. Relying solely on these to synchronize state between multiple
54+
# systems may lead to consistency issues.
4655
class Transaction
47-
class Callback # :nodoc:
48-
def initialize(event, callback)
49-
@event = event
50-
@callback = callback
51-
end
52-
53-
def before_commit
54-
@callback.call if @event == :before_commit
55-
end
56-
57-
def after_commit
58-
@callback.call if @event == :after_commit
59-
end
60-
61-
def after_rollback
62-
@callback.call if @event == :after_rollback
63-
end
64-
end
65-
66-
def initialize # :nodoc:
67-
@callbacks = nil
56+
def initialize(internal_transaction) # :nodoc:
57+
@internal_transaction = internal_transaction
6858
@uuid = nil
6959
end
7060

71-
# Registers a block to be called before the current transaction is fully committed.
72-
#
73-
# If there is no currently open transactions, the block is called immediately.
74-
#
75-
# If the current transaction has a parent transaction, the callback is transferred to
76-
# the parent when the current transaction commits, or dropped when the current transaction
77-
# is rolled back. This operation is repeated until the outermost transaction is reached.
78-
#
79-
# If the callback raises an error, the transaction is rolled back.
80-
def before_commit(&block)
81-
(@callbacks ||= []) << Callback.new(:before_commit, block)
82-
end
83-
84-
# Registers a block to be called after the current transaction is fully committed.
61+
# Registers a block to be called after the transaction is fully committed.
8562
#
8663
# If there is no currently open transactions, the block is called immediately.
8764
#
88-
# If the current transaction has a parent transaction, the callback is transferred to
65+
# If the transaction has a parent transaction, the callback is transferred to
8966
# the parent when the current transaction commits, or dropped when the current transaction
9067
# is rolled back. This operation is repeated until the outermost transaction is reached.
9168
#
9269
# If the callback raises an error, the transaction remains committed.
70+
#
71+
# If the transaction is already finalized, attempting to register a callback
72+
# will raise ActiveRecord::ActiveRecordError
9373
def after_commit(&block)
94-
(@callbacks ||= []) << Callback.new(:after_commit, block)
74+
if @internal_transaction.nil?
75+
yield
76+
else
77+
@internal_transaction.after_commit(&block)
78+
end
9579
end
9680

97-
# Registers a block to be called after the current transaction is rolled back.
81+
# Registers a block to be called after the transaction is rolled back.
9882
#
9983
# If there is no currently open transactions, the block is never called.
10084
#
101-
# If the current transaction is successfully committed but has a parent
85+
# If the transaction is successfully committed but has a parent
10286
# transaction, the callback is automatically added to the parent transaction.
10387
#
10488
# If the entire chain of nested transactions are all successfully committed,
10589
# the block is never called.
90+
#
91+
# If the transaction is already finalized, attempting to register a callback
92+
# will raise ActiveRecord::ActiveRecordError
10693
def after_rollback(&block)
107-
(@callbacks ||= []) << Callback.new(:after_rollback, block)
94+
@internal_transaction&.after_rollback(&block)
10895
end
10996

110-
# Returns true if a transaction was started.
11197
def open?
112-
true
98+
@internal_transaction&.open?
11399
end
114100

115-
# Returns true if no transaction is currently active.
116101
def closed?
117-
false
102+
!open?
118103
end
104+
119105
alias_method :blank?, :closed?
120106

121-
# Returns a UUID for this transaction.
107+
# Returns a UUID for this transaction or +nil+ if no transaction is open.
122108
def uuid
123-
@uuid ||= Digest::UUID.uuid_v4
109+
if @internal_transaction
110+
@uuid ||= Digest::UUID.uuid_v4
111+
end
124112
end
125113

126-
protected
127-
def append_callbacks(callbacks) # :nodoc:
128-
(@callbacks ||= []).concat(callbacks)
129-
end
114+
NULL_TRANSACTION = new(nil).freeze
130115
end
131116
end

activerecord/lib/active_record/transactions.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ def transaction(**options, &block)
243243
#
244244
# See the ActiveRecord::Transaction documentation for detailed behavior.
245245
def current_transaction
246-
connection_pool.active_connection&.current_transaction || ConnectionAdapters::TransactionManager::NULL_TRANSACTION
246+
connection_pool.active_connection&.current_transaction&.user_transaction || Transaction::NULL_TRANSACTION
247247
end
248248

249249
def before_commit(*args, &block) # :nodoc:

activerecord/test/cases/instrumentation_test.rb

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -171,47 +171,54 @@ def test_payload_connection_with_query_cache_enabled
171171
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
172172
end
173173
end
174-
end
175-
176-
class ActiveRecord::TransactionInSqlActiveRecordPayloadTest < ActiveRecord::TestCase
177-
# We need current_transaction to return the null transaction.
178-
self.use_transactional_tests = false
179174

180-
def test_payload_without_an_open_transaction
181-
asserted = false
175+
module TransactionInSqlActiveRecordPayloadTests
176+
def test_payload_without_an_open_transaction
177+
asserted = false
182178

183-
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
184-
if event.payload.fetch(:name) == "Book Count"
185-
assert_nil event.payload.fetch(:transaction)
186-
asserted = true
179+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
180+
if event.payload.fetch(:name) == "Book Count"
181+
assert_nil event.payload.fetch(:transaction)
182+
asserted = true
183+
end
187184
end
188-
end
189185

190-
Book.count
186+
Book.count
191187

192-
assert asserted
193-
ensure
194-
ActiveSupport::Notifications.unsubscribe(subscriber)
195-
end
188+
assert asserted
189+
ensure
190+
ActiveSupport::Notifications.unsubscribe(subscriber)
191+
end
192+
193+
def test_payload_with_an_open_transaction
194+
asserted = false
195+
expected_transaction = nil
196196

197-
def test_payload_with_an_open_transaction
198-
asserted = false
199-
expected_transaction = nil
197+
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
198+
if event.payload.fetch(:name) == "Book Count"
199+
assert_same expected_transaction, event.payload.fetch(:transaction)
200+
asserted = true
201+
end
202+
end
200203

201-
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
202-
if event.payload.fetch(:name) == "Book Count"
203-
assert_same expected_transaction, event.payload.fetch(:transaction)
204-
asserted = true
204+
Book.transaction do |transaction|
205+
expected_transaction = transaction
206+
Book.count
205207
end
206-
end
207208

208-
Book.transaction do |transaction|
209-
expected_transaction = transaction
210-
Book.count
209+
assert asserted
210+
ensure
211+
ActiveSupport::Notifications.unsubscribe(subscriber)
211212
end
213+
end
214+
215+
class TransactionInSqlActiveRecordPayloadTest < ActiveRecord::TestCase
216+
include TransactionInSqlActiveRecordPayloadTests
217+
end
218+
219+
class TransactionInSqlActiveRecordPayloadNonTransactionalTest < ActiveRecord::TestCase
220+
include TransactionInSqlActiveRecordPayloadTests
212221

213-
assert asserted
214-
ensure
215-
ActiveSupport::Notifications.unsubscribe(subscriber)
222+
self.use_transactional_tests = false
216223
end
217224
end

0 commit comments

Comments
 (0)