Skip to content

Commit 26de25d

Browse files
committed
Gracefully handle transactional fixtures leaks
Extracted from: rails#50999 Some tests may use the connections in ways that cause the fixtures transaction to be committed or rolled back. The typical case being doing schema change query in MySQL, which automatically commits the transaction. But ther eare more subtle cases. The general idea here is to ensure our transaction is correctly rolling back during teardown. If it fails, then we assume something might have mutated some of the inserted fixtures, so we invalidate the cache to ensure the next test will reset them. This issue is particularly common in Active Record's own test suite since transaction fixtures are enabled by default but we have many tests create tables and such. We could treat this case as an error, but since we can gracefully recover from it, I don't think it's worth it.
1 parent bab4aa7 commit 26de25d

File tree

7 files changed

+66
-38
lines changed

7 files changed

+66
-38
lines changed

activerecord/lib/active_record/test_fixtures.rb

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -130,25 +130,53 @@ def setup_fixtures(config = ActiveRecord::Base)
130130
end
131131

132132
@fixture_cache = {}
133-
@fixture_connections = []
133+
@fixture_connections = {}
134134
@fixture_cache_key = [self.class.fixture_table_names.dup, self.class.fixture_paths.dup, self.class.fixture_class_names.dup]
135135
@@already_loaded_fixtures ||= {}
136136
@connection_subscriber = nil
137137
@saved_pool_configs = Hash.new { |hash, key| hash[key] = {} }
138138

139-
# Load fixtures once and begin transaction.
140139
if run_in_transaction?
140+
# Load fixtures once and begin transaction.
141141
@loaded_fixtures = @@already_loaded_fixtures[@fixture_cache_key]
142142
unless @loaded_fixtures
143143
@@already_loaded_fixtures.clear
144144
@loaded_fixtures = @@already_loaded_fixtures[@fixture_cache_key] = load_fixtures(config)
145145
end
146146

147+
setup_transactional_fixtures
148+
else
149+
# Load fixtures for every test.
150+
ActiveRecord::FixtureSet.reset_cache
151+
invalidate_already_loaded_fixtures
152+
@loaded_fixtures = load_fixtures(config)
153+
end
154+
155+
# Instantiate fixtures for every test if requested.
156+
instantiate_fixtures if use_instantiated_fixtures
157+
end
158+
159+
def teardown_fixtures
160+
# Rollback changes if a transaction is active.
161+
if run_in_transaction?
162+
teardown_transactional_fixtures
163+
else
164+
ActiveRecord::FixtureSet.reset_cache
165+
end
166+
167+
ActiveRecord::Base.connection_handler.clear_active_connections!(:all)
168+
end
169+
170+
private
171+
def setup_transactional_fixtures
172+
setup_shared_connection_pool
173+
147174
# Begin transactions for connections already established
148-
@fixture_connections = enlist_fixture_connections
149-
@fixture_connections.each do |connection|
150-
connection.begin_transaction joinable: false, _lazy: false
151-
connection.pool.lock_thread = true if lock_threads
175+
@fixture_connections = ActiveRecord::Base.connection_handler.connection_pool_list(:writing).to_h do |pool|
176+
pool.lock_thread = true if lock_threads
177+
connection = pool.connection
178+
transaction = connection.begin_transaction(joinable: false, _lazy: false)
179+
[connection, transaction]
152180
end
153181

154182
# When connections are established in the future, begin a transaction too
@@ -167,50 +195,38 @@ def setup_fixtures(config = ActiveRecord::Base)
167195
if connection
168196
setup_shared_connection_pool
169197

170-
if !@fixture_connections.include?(connection)
171-
connection.begin_transaction joinable: false, _lazy: false
198+
if !@fixture_connections.key?(connection)
172199
connection.pool.lock_thread = true if lock_threads
173-
@fixture_connections << connection
200+
@fixture_connections[connection] = connection.begin_transaction(joinable: false, _lazy: false)
174201
end
175202
end
176203
end
177204
end
178-
179-
# Load fixtures for every test.
180-
else
181-
ActiveRecord::FixtureSet.reset_cache
182-
@@already_loaded_fixtures.clear
183-
@loaded_fixtures = load_fixtures(config)
184205
end
185206

186-
# Instantiate fixtures for every test if requested.
187-
instantiate_fixtures if use_instantiated_fixtures
188-
end
189-
190-
def teardown_fixtures
191-
# Rollback changes if a transaction is active.
192-
if run_in_transaction?
207+
def teardown_transactional_fixtures
193208
ActiveSupport::Notifications.unsubscribe(@connection_subscriber) if @connection_subscriber
194-
@fixture_connections.each do |connection|
195-
connection.rollback_transaction if connection.transaction_open?
209+
@fixture_connections.each do |connection, transaction|
210+
begin
211+
connection.rollback_transaction(transaction)
212+
rescue ActiveRecord::StatementInvalid
213+
# Something commited or rolled back the transaction.
214+
# We can no longer trust the database state is clean.
215+
invalidate_already_loaded_fixtures
216+
# We also don't know for sure the connection wasn't
217+
# mutated in dangerous ways.
218+
connection.disconnect!
219+
end
196220
connection.pool.lock_thread = false
197221
end
198222
@fixture_connections.clear
199223
teardown_shared_connection_pool
200-
else
201-
ActiveRecord::FixtureSet.reset_cache
202224
end
203225

204-
ActiveRecord::Base.connection_handler.clear_active_connections!(:all)
205-
end
206-
207-
def enlist_fixture_connections
208-
setup_shared_connection_pool
209-
210-
ActiveRecord::Base.connection_handler.connection_pool_list(:writing).map(&:connection)
211-
end
226+
def invalidate_already_loaded_fixtures
227+
@@already_loaded_fixtures.clear
228+
end
212229

213-
private
214230
# Shares the writing connection pool with connections on
215231
# other handlers.
216232
#

activerecord/test/cases/active_record_test.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
require "rack"
55

66
class ActiveRecordTest < ActiveRecord::TestCase
7+
self.use_transactional_tests = false
8+
79
unless in_memory_db?
810
test ".disconnect_all! closes all connections" do
9-
ActiveRecord::Base.connection.active?
11+
ActiveRecord::Base.connection.connect!
1012
assert_predicate ActiveRecord::Base, :connected?
1113

1214
ActiveRecord.disconnect_all!

activerecord/test/cases/adapters/abstract_mysql_adapter/connection_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def test_no_automatic_reconnection_after_timeout
3737
assert_not_predicate @connection, :active?
3838
ensure
3939
# Repair all fixture connections so other tests won't break.
40-
@fixture_connections.each(&:verify!)
40+
@fixture_connections.each_key(&:verify!)
4141
end
4242

4343
def test_successful_reconnection_after_timeout_with_manual_reconnect

activerecord/test/cases/adapters/postgresql/connection_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ def test_reconnection_after_actual_disconnection_with_verify
145145
assert_predicate @connection, :active?
146146
ensure
147147
# Repair all fixture connections so other tests won't break.
148-
@fixture_connections.each(&:verify!)
148+
@fixture_connections.each_key(&:verify!)
149149
end
150150

151151
def test_set_session_variable_true

activerecord/test/cases/connection_adapters/connection_handler_test.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
module ActiveRecord
77
module ConnectionAdapters
88
class ConnectionHandlerTest < ActiveRecord::TestCase
9+
self.use_transactional_tests = false
10+
911
fixtures :people
1012

1113
def setup
@@ -93,6 +95,8 @@ def test_fixtures_dont_raise_if_theres_no_writing_pool_config
9395
connection_handler = ActiveRecord::Base.connection_handler
9496
ActiveRecord::Base.connection_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
9597

98+
setup_transactional_fixtures
99+
96100
assert_nothing_raised do
97101
ActiveRecord::Base.connects_to(database: { reading: :arunit, writing: :arunit })
98102
end
@@ -101,6 +105,8 @@ def test_fixtures_dont_raise_if_theres_no_writing_pool_config
101105
ro_conn = ActiveRecord::Base.connection_handler.retrieve_connection("ActiveRecord::Base", role: :reading)
102106

103107
assert_equal rw_conn, ro_conn
108+
109+
teardown_transactional_fixtures
104110
ensure
105111
ActiveRecord::Base.connection_handler = connection_handler
106112
end

activerecord/test/cases/connection_adapters/schema_cache_test.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
module ActiveRecord
66
module ConnectionAdapters
77
class SchemaCacheTest < ActiveRecord::TestCase
8+
self.use_transactional_tests = false
9+
810
def setup
911
@connection = ARUnit2Model.connection
1012
@cache = new_bound_reflection

activerecord/test/cases/primary_class_test.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
require "cases/helper"
44

55
class PrimaryClassTest < ActiveRecord::TestCase
6+
self.use_transactional_tests = false
7+
68
def teardown
79
clean_up_connection_handler
810
end

0 commit comments

Comments
 (0)