Skip to content

Commit 4de8518

Browse files
committed
Add ability to change the transaction isolation for all pools within a block
rails#54836 introduced the ability to change the isolation for the current pool. If you're changing the default transaction isolation for an app with many dbs/shards you would need to loop through all pools and apply the isolation. If there are hundreds of connections, that might create a performance issue. This change adds a new method `ActiveRecord.with_transaction_isolation_level` which will change the transaction isolation for any pool accessed within the block. The method can be used in an around filter to update all transactions for an action or middleware to switch all pools accessed in a request. I chose to call this from `with_transaction_returning_status` and `transaction` because called from within `connection.transaction` made it too difficult to tell if we were changing the isolation on an open transaction. It would either error when it wasn't actually changing or fail to error when it was supposed to. Wrapping the calls solves this. While implementing this I felt that the original method had a confusing name paired with the new one so I updated it to be more explicit that it's for only the currently current pool.
1 parent 4b7fb1d commit 4de8518

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)