Skip to content

Commit fd17cfd

Browse files
committed
Patch ActiveRecord::Base.clear_query_caches_for_current_thread to restore underlying database
Otherwise it's possible for a Mysql2Adapter instance's database to be changed out from under it during an operation (e.g. insert, update, delete, truncate, etc). CONTEXT OF THE ISSUE This issue presented itself with a combination of using active_record_shards and active_record_host_pool. It was representing the sharded and unsharded database connections within the same connection pool. This problem did not manifest itself until Rails 6 when cache clearing for cache dirtying operations—e.g. insert, update, truncate, delete, etc—was updated to loop over the app's connection handlers, connection pools to search for currently active connections. This happens mid-operation (inside of ActiveRecord::ConnectionAdapters::Mysql2Adapter instance) just before the operation is carried out. The result was that referencing the connection was changing the currently selected database and it was not being restored. This led to issues where the operation would try to be carried out on a different database that did not have the same tables/columns. It is important to note that this issue existed in active_record_host_pool for years, but no code paths exercised it. The assumption that connections would not be re-referenced from within an operation on a connection was safe for years until ActiveRecord 6. WHY THIS FIX The active_record_host_pool library is optimized to reduce the number of database connections by pooling them by host/port/socket/user/slave. This means that different databases on the same host will use the same connection pool. This requires that this library provide a mechanism for selecting the correct database when executing queries. This fix maintains that optimization and fixes the currently known problematic code path introduced in ActiveRecord 6. BENEFITS OF THIS CHANGE Preserves host pool optimization. CONSEQUENCES OF THIS CHANGE This does not fix the issue, it just fixes the one spot where we know ActiveRecord 6.0 can expose it. REFERENCES The problematic line that causes the database to switch is: https://github.com/zendesk/active_record_host_pool/blob/981fdeb8dfa52f390bd91471adc19049283c6fb8/lib/active_record_host_pool/connection_proxy.rb#L16 The change in Rails 6 that exercised a code path to expose this surface the issue: https://github.com/rails/rails/pull/35089/files#diff-193e6e4a86bf5d360a5001bce59334cbR180-R186 For an alternate solution see: #60
1 parent 76c244d commit fd17cfd

6 files changed

Lines changed: 94 additions & 2 deletions

File tree

lib/active_record_host_pool.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require 'active_record/base'
55
require 'active_record/connection_adapters/abstract_adapter'
66

7+
require 'active_record_host_pool/connection_handling'
78
require 'active_record_host_pool/connection_proxy'
89
require 'active_record_host_pool/pool_proxy'
910
require 'active_record_host_pool/connection_adapter_mixin'
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveRecordHostPool
4+
# ActiveRecord 6.0 introduces multiple database support. This introduces a
5+
# change where dirtying operations on a Mysql2Adapter instance calls out to
6+
# ActiveRecord::Base.clear_query_caches_for_current_thread. This will
7+
# for any active connections whose query caches need to be cleared. This
8+
# involves iterating over every connection handler's connection pool
9+
# looking for active active connections.
10+
#
11+
# This exposes an issue with active active_record_host_pool since by
12+
# referencing a connection it causes the underlying Mysql2Adapter instance
13+
# to potentially change its _host_pool_current_database. This means
14+
# mid-way into carrying out an operation like inserting a record the
15+
# act of clearing the query cache may select a new database and leave it
16+
# changed. This can result in the insert SQL then being sent to a new
17+
# database.
18+
#
19+
# This module wraps ActiveRecord::Base.clear_query_caches_for_current_thread
20+
# in order to restore the current connection's database to what it was
21+
# before the caches were attempted to be cleared.
22+
#
23+
# Before ActiveRecord 6 this potential issue existed in the
24+
# active_record_host_pool library but no code paths exercised it. This
25+
# module does not resolve the general issue and the underlying issue still
26+
# exists, but currently clearing the cache is the only known code path that
27+
# exercises this problem so that is all that is patched.
28+
module ResetActiveDatabaseAfterClearingCache
29+
def clear_query_caches_for_current_thread
30+
host_pool_current_database_was = connection.unproxied._host_pool_current_database
31+
super
32+
ensure
33+
# restore in case clearing the cache changed the database
34+
connection.unproxied._host_pool_current_database = host_pool_current_database_was
35+
end
36+
end
37+
end
38+
39+
ActiveRecord::Base.singleton_class.prepend ActiveRecordHostPool::ResetActiveDatabaseAfterClearingCache

test/database.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,12 @@ test_host_1_db_not_there:
6666
password: <%= mysql.password %>
6767
host: <%= mysql.host %>
6868
reconnect: true
69+
70+
test_host_1_db_shard:
71+
adapter: mysql2
72+
encoding: utf8
73+
database: arhp_test_1_shard
74+
username: <%= mysql.user %>
75+
password: <%= mysql.password %>
76+
host: <%= mysql.host %>
77+
reconnect: true

test/helper.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ def arhp_create_models
2525
return if ARHPTestSetup.const_defined?('Test1')
2626

2727
eval <<-RUBY
28+
# The placement of the Test1Shard class is important so that its
29+
# connection will not be the most recent connection established
30+
# for test_host_1.
31+
class Test1Shard < ::ActiveRecord::Base
32+
establish_connection(:test_host_1_db_shard)
33+
end
34+
2835
class Test1 < ActiveRecord::Base
2936
self.table_name = "tests"
3037
establish_connection(:test_host_1_db_1)

test/schema.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
require_relative 'helper'
44
ActiveRecord::Schema.define(version: 1) do
5-
create_table 'tests' do |t|
5+
create_table 'tests', force: true do |t|
66
t.string 'val'
77
end
8+
9+
# Add a table only the shard database will have. Conditional
10+
# exists since Phenix loads the schema file for every database.
11+
if ActiveRecord::Base.connection.current_database == 'arhp_test_1_shard'
12+
create_table 'test1_shards' do |t|
13+
t.string 'name'
14+
end
15+
end
816
end

test/test_arhp.rb

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def test_process_forking_with_connections
1919

2020
# Verify that when we fork, the process doesn't crash
2121
pid = Process.fork do
22-
if ActiveRecord::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR == 2
22+
if ActiveRecord.version >= Gem::Version.new('5.2')
2323
assert_equal(false, ActiveRecord::Base.connected?) # New to Rails 5.2
2424
else
2525
assert_equal(true, ActiveRecord::Base.connected?)
@@ -55,6 +55,22 @@ def test_should_insert_on_correct_database
5555
assert_action_uses_correct_database(:insert, "insert into tests values(NULL, 'foo')")
5656
end
5757

58+
def test_models_with_matching_hosts_and_non_matching_databases_should_share_a_connection
59+
simulate_rails_app_active_record_railties
60+
assert_equal(Test1.connection.raw_connection, Test1Shard.connection.raw_connection)
61+
end
62+
63+
def test_models_with_matching_hosts_and_non_matching_databases_do_not_mix_up_underlying_database
64+
simulate_rails_app_active_record_railties
65+
66+
# ActiveRecord 6.0 introduced a change that surfaced a problematic code
67+
# path in active_record_host_pool: Clearing the cache across connection
68+
# handlers can cause the shared host pool to change the underlying
69+
# database out from under an in-progress operation.
70+
# See lib/active_record_host_pool/connection_handling.rb for more
71+
ActiveRecord::Base.cache { Test1Shard.create! }
72+
end
73+
5874
def test_connection_returns_a_proxy
5975
assert_kind_of ActiveRecordHostPool::ConnectionProxy, Test1.connection
6076
end
@@ -180,4 +196,16 @@ def assert_action_uses_correct_database(action, sql)
180196
assert_equal desired_db, current_database(klass)
181197
end
182198
end
199+
200+
def simulate_rails_app_active_record_railties
201+
if ActiveRecord.version >= Gem::Version.new('6.0')
202+
# Necessary for testing ActiveRecord 6.0 which uses the connection
203+
# handlers when clearing query caches across all handlers when
204+
# an operation that dirties the cache isinvolved (e.g. create/insert,
205+
# update, delete/destroy, truncate, etc.)
206+
ActiveRecord::Base.connection_handlers = {
207+
ActiveRecord::Base.writing_role => ActiveRecord::Base.default_connection_handler
208+
}
209+
end
210+
end
183211
end

0 commit comments

Comments
 (0)