Skip to content

Commit a365211

Browse files
authored
Merge pull request rails#55176 from eileencodes/change-transaction-isolation-for-all-poolswithin-block
Add ability to change the transaction isolation for all pools within a block
2 parents e9626a3 + 4de8518 commit a365211

File tree

6 files changed

+203
-39
lines changed

6 files changed

+203
-39
lines changed

activerecord/CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1+
* Add ability to change transaction isolation for all pools within a block.
2+
3+
This functionality is useful if your application needs to change the database
4+
transaction isolation for a request or action.
5+
6+
Calling `ActiveRecord.with_transaction_isolation_level(level) {}` in an around filter or
7+
middleware will set the transaction isolation for all pools accessed within the block,
8+
but not for the pools that aren't.
9+
10+
This works with explicit and implicit transactions:
11+
12+
```ruby
13+
ActiveRecord.with_transaction_isolation_level(:read_committed) do
14+
Tag.transaction do # opens a transaction explicitly
15+
Tag.create!
16+
end
17+
end
18+
```
19+
20+
```ruby
21+
ActiveRecord.with_transaction_isolation_level(:read_committed) do
22+
Tag.create! # opens a transaction implicitly
23+
end
24+
```
25+
26+
*Eileen M. Uchitelle*
27+
128
* `:class_name` is now invalid in polymorphic `belongs_to` associations.
229

330
Reason is `:class_name` does not make sense in those associations because

activerecord/lib/active_record.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,23 @@ def self.all_open_transactions # :nodoc:
574574
end
575575
open_transactions
576576
end
577+
578+
def self.default_transaction_isolation_level=(isolation_level) # :nodoc:
579+
ActiveSupport::IsolatedExecutionState[:active_record_transaction_isolation] = isolation_level
580+
end
581+
582+
def self.default_transaction_isolation_level # :nodoc:
583+
ActiveSupport::IsolatedExecutionState[:active_record_transaction_isolation]
584+
end
585+
586+
# Sets a transaction isolation level for all connection pools within the block.
587+
def self.with_transaction_isolation_level(isolation_level, &block)
588+
original_level = self.default_transaction_isolation_level
589+
self.default_transaction_isolation_level = isolation_level
590+
yield
591+
ensure
592+
self.default_transaction_isolation_level = original_level
593+
end
577594
end
578595

579596
ActiveSupport.on_load(:active_record) do

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

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ def dirties_query_cache
4949
true
5050
end
5151

52-
def default_isolation_level; end
53-
def default_isolation_level=(isolation_level)
52+
def pool_transaction_isolation_level; end
53+
def pool_transaction_isolation_level=(isolation_level)
5454
raise NotImplementedError, "This method should never be called"
5555
end
5656
end
@@ -428,6 +428,24 @@ def with_connection(prevent_permanent_checkout: false)
428428
end
429429
end
430430

431+
def with_pool_transaction_isolation_level(isolation_level, transaction_open, &block) # :nodoc:
432+
if !ActiveRecord.default_transaction_isolation_level.nil?
433+
begin
434+
if transaction_open && self.pool_transaction_isolation_level != ActiveRecord.default_transaction_isolation_level
435+
raise ActiveRecord::TransactionIsolationError, "cannot set default isolation level while transaction is open"
436+
end
437+
438+
old_level = self.pool_transaction_isolation_level
439+
self.pool_transaction_isolation_level = isolation_level
440+
yield
441+
ensure
442+
self.pool_transaction_isolation_level = old_level
443+
end
444+
else
445+
yield
446+
end
447+
end
448+
431449
# Returns true if a connection has already been opened.
432450
def connected?
433451
synchronize { @connections.any?(&:connected?) }
@@ -711,13 +729,13 @@ def new_connection # :nodoc:
711729
raise ex.set_pool(self)
712730
end
713731

714-
def default_isolation_level
715-
isolation_level_key = "activerecord_default_isolation_level_#{db_config.name}"
732+
def pool_transaction_isolation_level
733+
isolation_level_key = "activerecord_pool_transaction_isolation_level_#{db_config.name}"
716734
ActiveSupport::IsolatedExecutionState[isolation_level_key]
717735
end
718736

719-
def default_isolation_level=(isolation_level)
720-
isolation_level_key = "activerecord_default_isolation_level_#{db_config.name}"
737+
def pool_transaction_isolation_level=(isolation_level)
738+
isolation_level_key = "activerecord_pool_transaction_isolation_level_#{db_config.name}"
721739
ActiveSupport::IsolatedExecutionState[isolation_level_key] = isolation_level
722740
end
723741

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,7 @@ def rollback_transaction(transaction = nil)
620620
end
621621

622622
def within_new_transaction(isolation: nil, joinable: true)
623-
isolation ||= @connection.pool.default_isolation_level
623+
isolation ||= @connection.pool.pool_transaction_isolation_level
624624
@connection.lock.synchronize do
625625
transaction = begin_transaction(isolation: isolation, joinable: joinable)
626626
begin

activerecord/lib/active_record/transactions.rb

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -230,27 +230,28 @@ module ClassMethods
230230
# See the ConnectionAdapters::DatabaseStatements#transaction API docs.
231231
def transaction(**options, &block)
232232
with_connection do |connection|
233-
connection.transaction(**options, &block)
233+
connection.pool.with_pool_transaction_isolation_level(ActiveRecord.default_transaction_isolation_level, connection.transaction_open?) do
234+
connection.transaction(**options, &block)
235+
end
234236
end
235237
end
236238

237-
# Makes all transactions initiated within the block use the isolation level
238-
# that you set as the default. Useful for gradually migrating apps onto new isolation level.
239-
def with_default_isolation_level(isolation_level, &block)
239+
# Makes all transactions the current pool use the isolation level initiated within the block.
240+
def with_pool_transaction_isolation_level(isolation_level, &block)
240241
if current_transaction.open?
241242
raise ActiveRecord::TransactionIsolationError, "cannot set default isolation level while transaction is open"
242243
end
243244

244-
old_level = connection_pool.default_isolation_level
245-
connection_pool.default_isolation_level = isolation_level
245+
old_level = connection_pool.pool_transaction_isolation_level
246+
connection_pool.pool_transaction_isolation_level = isolation_level
246247
yield
247248
ensure
248-
connection_pool.default_isolation_level = old_level
249+
connection_pool.pool_transaction_isolation_level = old_level
249250
end
250251

251-
# Returns the default isolation level for the connection pool, set earlier by #with_default_isolation_level.
252-
def default_isolation_level
253-
connection_pool.default_isolation_level
252+
# Returns the default isolation level for the connection pool, set earlier by #with_pool_transaction_isolation_level.
253+
def pool_transaction_isolation_level
254+
connection_pool.pool_transaction_isolation_level
254255
end
255256

256257
# Returns a representation of the current transaction state,
@@ -426,18 +427,20 @@ def rolledback!(force_restore_state: false, should_run_callbacks: true) # :nodoc
426427
# instance.
427428
def with_transaction_returning_status
428429
self.class.with_connection do |connection|
429-
status = nil
430-
ensure_finalize = !connection.transaction_open?
430+
connection.pool.with_pool_transaction_isolation_level(ActiveRecord.default_transaction_isolation_level, connection.transaction_open?) do
431+
status = nil
432+
ensure_finalize = !connection.transaction_open?
431433

432-
connection.transaction do
433-
add_to_transaction(ensure_finalize || has_transactional_callbacks?)
434-
remember_transaction_record_state
434+
connection.transaction do
435+
add_to_transaction(ensure_finalize || has_transactional_callbacks?)
436+
remember_transaction_record_state
435437

436-
status = yield
437-
raise ActiveRecord::Rollback unless status
438+
status = yield
439+
raise ActiveRecord::Rollback unless status
440+
end
441+
@_last_transaction_return_status = status
442+
status
438443
end
439-
@_last_transaction_return_status = status
440-
status
441444
end
442445
end
443446

activerecord/test/cases/transaction_isolation_test.rb

Lines changed: 112 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,16 @@ class Tag2 < ActiveRecord::Base
2929
self.table_name = "tags"
3030
end
3131

32+
class Dog < ARUnit2Model
33+
self.table_name = "dogs"
34+
end
35+
3236
setup do
3337
Tag.establish_connection :arunit
3438
Tag2.establish_connection :arunit
39+
Dog.establish_connection :arunit2
3540
Tag.destroy_all
41+
Dog.destroy_all
3642
end
3743

3844
# It is impossible to properly test read uncommitted. The SQL standard only
@@ -62,16 +68,16 @@ class Tag2 < ActiveRecord::Base
6268
assert_equal 1, Tag.count
6369
end
6470

65-
test "default_isolation_level" do
66-
assert_nil Tag.default_isolation_level
71+
test "pool_transaction_isolation_level" do
72+
assert_nil Tag.pool_transaction_isolation_level
6773

6874
events = []
6975
ActiveSupport::Notifications.subscribed(
7076
-> (event) { events << event.payload[:sql] },
7177
"sql.active_record",
7278
) do
73-
Tag.with_default_isolation_level(:read_committed) do
74-
assert_equal :read_committed, Tag.default_isolation_level
79+
Tag.with_pool_transaction_isolation_level(:read_committed) do
80+
assert_equal :read_committed, Tag.pool_transaction_isolation_level
7581
Tag.transaction do
7682
Tag.create!(name: "jon")
7783
end
@@ -80,24 +86,24 @@ class Tag2 < ActiveRecord::Base
8086
assert_begin_isolation_level_event(events)
8187
end
8288

83-
test "default_isolation_level cannot be set within open transaction" do
89+
test "pool_transaction_isolation_level cannot be set within open transaction" do
8490
assert_raises(ActiveRecord::TransactionIsolationError) do
8591
Tag.transaction do
86-
Tag.with_default_isolation_level(:read_committed) { }
92+
Tag.with_pool_transaction_isolation_level(:read_committed) { }
8793
end
8894
end
8995
end
9096

91-
test "default_isolation_level but transaction overrides isolation" do
92-
assert_nil Tag.default_isolation_level
97+
test "pool_transaction_isolation_level but transaction overrides isolation" do
98+
assert_nil Tag.pool_transaction_isolation_level
9399

94100
events = []
95101
ActiveSupport::Notifications.subscribed(
96102
-> (event) { events << event.payload[:sql] },
97103
"sql.active_record",
98104
) do
99-
Tag.with_default_isolation_level(:read_committed) do
100-
assert_equal :read_committed, Tag.default_isolation_level
105+
Tag.with_pool_transaction_isolation_level(:read_committed) do
106+
assert_equal :read_committed, Tag.pool_transaction_isolation_level
101107

102108
Tag.transaction(isolation: :repeatable_read) do
103109
Tag.create!(name: "jon")
@@ -108,6 +114,99 @@ class Tag2 < ActiveRecord::Base
108114
assert_begin_isolation_level_event(events, isolation: "REPEATABLE READ")
109115
end
110116

117+
test "with_transaction_isolation_level explicit transaction" do
118+
assert_nil ActiveRecord.default_transaction_isolation_level
119+
120+
events = []
121+
ActiveSupport::Notifications.subscribed(
122+
-> (event) { events << event.payload[:sql] },
123+
"sql.active_record",
124+
) do
125+
assert_nil Tag.connection_pool.pool_transaction_isolation_level
126+
assert_nil Dog.connection_pool.pool_transaction_isolation_level
127+
128+
ActiveRecord.with_transaction_isolation_level(:read_committed) do
129+
assert_equal :read_committed, ActiveRecord.default_transaction_isolation_level
130+
Tag.transaction do
131+
assert_equal :read_committed, Tag.connection_pool.pool_transaction_isolation_level
132+
assert_equal :read_committed, Dog.connection_pool.pool_transaction_isolation_level
133+
134+
Tag.create!(name: "jon")
135+
Dog.create!
136+
end
137+
end
138+
end
139+
140+
assert_nil Tag.connection_pool.pool_transaction_isolation_level
141+
assert_nil Dog.connection_pool.pool_transaction_isolation_level
142+
assert_begin_isolation_level_event(events, count: 2)
143+
end
144+
145+
test "with_transaction_isolation_level implicit transaction" do
146+
assert_nil ActiveRecord.default_transaction_isolation_level
147+
148+
events = []
149+
ActiveSupport::Notifications.subscribed(
150+
-> (event) { events << event.payload[:sql] },
151+
"sql.active_record",
152+
) do
153+
ActiveRecord.with_transaction_isolation_level(:read_committed) do
154+
assert_equal :read_committed, ActiveRecord.default_transaction_isolation_level
155+
156+
Tag.create!(name: "jon")
157+
Dog.create!
158+
end
159+
end
160+
161+
assert_begin_isolation_level_event(events, count: 2)
162+
end
163+
164+
test "with_transaction_isolation_level cannot be set within open transaction" do
165+
Tag.transaction do
166+
assert_raises(ActiveRecord::TransactionIsolationError) do
167+
ActiveRecord.with_transaction_isolation_level(:repeatable_read) do
168+
Tag.create!(name: "some tag")
169+
end
170+
end
171+
end
172+
end
173+
174+
test "with_transaction_isolation_level cannot be changed within the block" do
175+
Tag.transaction do
176+
assert_raises(ActiveRecord::TransactionIsolationError) do
177+
ActiveRecord.with_transaction_isolation_level(:repeatable_read) do
178+
Tag.transaction do
179+
ActiveRecord.with_transaction_isolation_level(:serializable) do
180+
assert_raises do
181+
Tag.create!(name: "some tag")
182+
end
183+
end
184+
end
185+
end
186+
end
187+
end
188+
end
189+
190+
test "with_transaction_isolation_level but transaction overrides isolation" do
191+
assert_nil ActiveRecord.default_transaction_isolation_level
192+
193+
events = []
194+
ActiveSupport::Notifications.subscribed(
195+
-> (event) { events << event.payload[:sql] },
196+
"sql.active_record",
197+
) do
198+
ActiveRecord.with_transaction_isolation_level(:read_committed) do
199+
assert_equal :read_committed, ActiveRecord.default_transaction_isolation_level
200+
201+
Dog.transaction(isolation: :repeatable_read) do
202+
Dog.create!
203+
end
204+
end
205+
end
206+
207+
assert_begin_isolation_level_event(events, isolation: "REPEATABLE READ")
208+
end
209+
111210
# We are testing that a nonrepeatable read does not happen
112211
if ActiveRecord::Base.lease_connection.transaction_isolation_levels.include?(:repeatable_read)
113212
test "repeatable read" do
@@ -154,11 +253,11 @@ class Tag2 < ActiveRecord::Base
154253
end
155254

156255
private
157-
def assert_begin_isolation_level_event(events, isolation: "READ COMMITTED")
256+
def assert_begin_isolation_level_event(events, isolation: "READ COMMITTED", count: 1)
158257
if current_adapter?(:PostgreSQLAdapter)
159-
assert_equal 1, events.select { _1.match(/BEGIN ISOLATION LEVEL #{isolation}/) }.size
258+
assert_equal count, events.select { _1.match(/BEGIN ISOLATION LEVEL #{isolation}/) }.size
160259
else
161-
assert_equal 1, events.select { _1.match(/SET TRANSACTION ISOLATION LEVEL #{isolation}/) }.size
260+
assert_equal count, events.select { _1.match(/SET TRANSACTION ISOLATION LEVEL #{isolation}/) }.size
162261
end
163262
end
164263
end

0 commit comments

Comments
 (0)