Move the database version cache from schema cache to pool config

Ref: https://github.com/rails/rails/pull/49378

As discussed with Matthew Draper, we have a bit of a chicken and egg
problem with the schema cache and the database version.

The database version is stored in the cache to avoid a query,
but the schema cache need to query the schema version in the database
to be revalidated.

So `check_version` depends on `schema_cache`, which depends on
`Migrator.current_version`, which depends on `configure_connection`
which depends on `check_version`.

But ultimately, we think storing the server version in the cache
is incorrect, because upgrading a DB server is orthogonal from
regenerating the schema cache.

So not persisting the version in cache is better. Instead we store
it in the pool config, so that we only check it once per process
and per database.
This commit is contained in:
Jean Boussier 2023-09-28 13:26:36 +02:00
parent 6c9967f85a
commit a1c0173ee3
9 changed files with 39 additions and 79 deletions

@ -22,6 +22,16 @@ def method_missing(*)
end end
NULL_CONFIG = NullConfig.new # :nodoc: NULL_CONFIG = NullConfig.new # :nodoc:
def initialize
super()
@mutex = Mutex.new
@server_version = nil
end
def server_version(connection) # :nodoc:
@server_version || @mutex.synchronize { @server_version ||= connection.get_database_version }
end
def schema_reflection def schema_reflection
SchemaReflection.new(nil) SchemaReflection.new(nil)
end end
@ -111,7 +121,7 @@ class ConnectionPool
attr_accessor :automatic_reconnect, :checkout_timeout attr_accessor :automatic_reconnect, :checkout_timeout
attr_reader :db_config, :size, :reaper, :pool_config, :async_executor, :role, :shard attr_reader :db_config, :size, :reaper, :pool_config, :async_executor, :role, :shard
delegate :schema_reflection, :schema_reflection=, to: :pool_config delegate :schema_reflection, :schema_reflection=, :server_version, to: :pool_config
# Creates a new ConnectionPool object. +pool_config+ is a PoolConfig # Creates a new ConnectionPool object. +pool_config+ is a PoolConfig
# object which describes database connection information (e.g. adapter, # object which describes database connection information (e.g. adapter,

@ -868,7 +868,7 @@ def get_database_version # :nodoc:
end end
def database_version # :nodoc: def database_version # :nodoc:
schema_cache.database_version pool.server_version(self)
end end
def check_version # :nodoc: def check_version # :nodoc:

@ -174,7 +174,7 @@ def configure_connection
end end
def full_version def full_version
schema_cache.database_version.full_version_string database_version.full_version_string
end end
def get_full_version def get_full_version

@ -3,10 +3,10 @@
module ActiveRecord module ActiveRecord
module ConnectionAdapters module ConnectionAdapters
class PoolConfig # :nodoc: class PoolConfig # :nodoc:
include Mutex_m include MonitorMixin
attr_reader :db_config, :role, :shard attr_reader :db_config, :role, :shard
attr_writer :schema_reflection attr_writer :schema_reflection, :server_version
attr_accessor :connection_class attr_accessor :connection_class
def schema_reflection def schema_reflection
@ -28,6 +28,7 @@ def disconnect_all!
def initialize(connection_class, db_config, role, shard) def initialize(connection_class, db_config, role, shard)
super() super()
@server_version = nil
@connection_class = connection_class @connection_class = connection_class
@db_config = db_config @db_config = db_config
@role = role @role = role
@ -36,6 +37,10 @@ def initialize(connection_class, db_config, role, shard)
INSTANCES[self] = self INSTANCES[self] = self
end end
def server_version(connection)
@server_version || synchronize { @server_version ||= connection.get_database_version }
end
def connection_name def connection_name
if connection_class.primary_class? if connection_class.primary_class?
"ActiveRecord::Base" "ActiveRecord::Base"

@ -66,10 +66,6 @@ def indexes(connection, table_name)
cache(connection).indexes(connection, table_name) cache(connection).indexes(connection, table_name)
end end
def database_version(connection)
cache(connection).database_version(connection)
end
def version(connection) def version(connection)
cache(connection).version(connection) cache(connection).version(connection)
end end
@ -196,10 +192,6 @@ def indexes(table_name)
@schema_reflection.indexes(@connection, table_name) @schema_reflection.indexes(@connection, table_name)
end end
def database_version
@schema_reflection.database_version(@connection)
end
def version def version
@schema_reflection.version(@connection) @schema_reflection.version(@connection)
end end
@ -264,7 +256,6 @@ def initialize
@primary_keys = {} @primary_keys = {}
@data_sources = {} @data_sources = {}
@indexes = {} @indexes = {}
@database_version = nil
@version = nil @version = nil
end end
@ -283,7 +274,6 @@ def encode_with(coder) # :nodoc:
coder["data_sources"] = @data_sources.sort.to_h coder["data_sources"] = @data_sources.sort.to_h
coder["indexes"] = @indexes.sort.to_h coder["indexes"] = @indexes.sort.to_h
coder["version"] = @version coder["version"] = @version
coder["database_version"] = @database_version
end end
def init_with(coder) def init_with(coder)
@ -293,7 +283,6 @@ def init_with(coder)
@data_sources = coder["data_sources"] @data_sources = coder["data_sources"]
@indexes = coder["indexes"] || {} @indexes = coder["indexes"] || {}
@version = coder["version"] @version = coder["version"]
@database_version = coder["database_version"]
unless coder["deduplicated"] unless coder["deduplicated"]
derive_columns_hash_and_deduplicate_values derive_columns_hash_and_deduplicate_values
@ -370,10 +359,6 @@ def indexes(connection, table_name)
end end
end end
def database_version(connection) # :nodoc:
@database_version ||= connection.get_database_version
end
def version(connection) def version(connection)
@version ||= connection.schema_version @version ||= connection.schema_version
end end
@ -401,7 +386,6 @@ def add_all(connection) # :nodoc:
end end
version(connection) version(connection)
database_version(connection)
end end
def dump_to(filename) def dump_to(filename)
@ -415,11 +399,11 @@ def dump_to(filename)
end end
def marshal_dump # :nodoc: def marshal_dump # :nodoc:
[@version, @columns, {}, @primary_keys, @data_sources, @indexes, @database_version] [@version, @columns, {}, @primary_keys, @data_sources, @indexes]
end end
def marshal_load(array) # :nodoc: def marshal_load(array) # :nodoc:
@version, @columns, _columns_hash, @primary_keys, @data_sources, @indexes, @database_version = array @version, @columns, _columns_hash, @primary_keys, @data_sources, @indexes, _database_version = array
@indexes ||= {} @indexes ||= {}
derive_columns_hash_and_deduplicate_values derive_columns_hash_and_deduplicate_values

@ -202,7 +202,7 @@ def reconnect
end end
def full_version def full_version
schema_cache.database_version.full_version_string database_version.full_version_string
end end
def get_full_version def get_full_version

@ -162,10 +162,13 @@ class Railtie < Rails::Railtie # :nodoc:
warn "Failed to validate the schema cache because of #{error.class}: #{error.message}" warn "Failed to validate the schema cache because of #{error.class}: #{error.message}"
nil nil
end end
next if current_version.nil?
if cache.schema_version != current_version if current_version.nil?
connection_pool.schema_reflection.clear!
next
elsif cache.schema_version != current_version
warn "Ignoring #{filename} because it has expired. The current schema version is #{current_version}, but the one in the schema cache file is #{cache.schema_version}." warn "Ignoring #{filename} because it has expired. The current schema version is #{current_version}, but the one in the schema cache file is #{cache.schema_version}."
connection_pool.schema_reflection.clear!
next next
end end
end end

@ -38,12 +38,10 @@ def assert_match_quoted_microsecond_datetime(match)
assert_match match, @connection.quoted_date(Time.now.change(sec: 55, usec: 123456)) assert_match match, @connection.quoted_date(Time.now.change(sec: 55, usec: 123456))
end end
def stub_version(full_version_string) def stub_version(full_version_string, &block)
@connection.stub(:get_full_version, full_version_string) do @connection.pool.pool_config.server_version = nil
@connection.schema_cache.clear! @connection.stub(:get_full_version, full_version_string, &block)
yield
end
ensure ensure
@connection.schema_cache.clear! @connection.pool.pool_config.server_version = nil
end end
end end

@ -6,9 +6,8 @@ module ActiveRecord
module ConnectionAdapters module ConnectionAdapters
class SchemaCacheTest < ActiveRecord::TestCase class SchemaCacheTest < ActiveRecord::TestCase
def setup def setup
@connection = ARUnit2Model.connection @connection = ARUnit2Model.connection
@cache = new_bound_reflection @cache = new_bound_reflection
@database_version = @connection.get_database_version
@check_schema_cache_dump_version_was = SchemaReflection.check_schema_cache_dump_version @check_schema_cache_dump_version_was = SchemaReflection.check_schema_cache_dump_version
end end
@ -67,7 +66,6 @@ def test_yaml_dump_and_load
assert cache.data_source_exists?("courses") assert cache.data_source_exists?("courses")
assert_equal "id", cache.primary_keys("courses") assert_equal "id", cache.primary_keys("courses")
assert_equal 1, cache.indexes("courses").size assert_equal 1, cache.indexes("courses").size
assert_equal @database_version.to_s, cache.database_version.to_s
end end
ensure ensure
tempfile.unlink tempfile.unlink
@ -104,7 +102,6 @@ def test_yaml_dump_and_load_with_gzip
assert cache.data_source_exists?(@connection, "courses") assert cache.data_source_exists?(@connection, "courses")
assert_equal "id", cache.primary_keys(@connection, "courses") assert_equal "id", cache.primary_keys(@connection, "courses")
assert_equal 1, cache.indexes(@connection, "courses").size assert_equal 1, cache.indexes(@connection, "courses").size
assert_equal @database_version.to_s, cache.database_version(@connection).to_s
end end
# Load the cache the usual way. # Load the cache the usual way.
@ -116,7 +113,6 @@ def test_yaml_dump_and_load_with_gzip
assert cache.data_source_exists?("courses") assert cache.data_source_exists?("courses")
assert_equal "id", cache.primary_keys("courses") assert_equal "id", cache.primary_keys("courses")
assert_equal 1, cache.indexes("courses").size assert_equal 1, cache.indexes("courses").size
assert_equal @database_version.to_s, cache.database_version.to_s
end end
ensure ensure
tempfile.unlink tempfile.unlink
@ -141,18 +137,6 @@ def test_yaml_loads_5_1_dump_without_indexes_still_queries_for_indexes
end end
end end
def test_yaml_loads_5_1_dump_without_database_version_still_queries_for_database_version
cache = load_bound_reflection(schema_dump_path)
# We can't verify queries get executed because the database version gets
# cached in both MySQL and PostgreSQL outside of the schema cache.
assert_not_nil reflection = @cache.instance_variable_get(:@schema_reflection)
assert_nil reflection.instance_variable_get(:@cache)
assert_equal @database_version.to_s, cache.database_version.to_s
end
def test_primary_key_for_existent_table def test_primary_key_for_existent_table
assert_equal "id", @cache.primary_keys("courses") assert_equal "id", @cache.primary_keys("courses")
end end
@ -189,18 +173,6 @@ def test_indexes_for_non_existent_table
assert_equal [], @cache.indexes("omgponies") assert_equal [], @cache.indexes("omgponies")
end end
def test_caches_database_version
@cache.database_version # cache database_version
assert_no_queries do
assert_equal @database_version.to_s, @cache.database_version.to_s
if current_adapter?(:Mysql2Adapter, :TrilogyAdapter)
assert_not_nil @cache.database_version.full_version_string
end
end
end
def test_clearing def test_clearing
@cache.columns("courses") @cache.columns("courses")
@cache.columns_hash("courses") @cache.columns_hash("courses")
@ -211,9 +183,6 @@ def test_clearing
@cache.clear! @cache.clear!
assert_equal 0, @cache.size assert_equal 0, @cache.size
reflection = @cache.instance_variable_get(:@schema_reflection)
schema_cache = reflection.instance_variable_get(:@cache)
assert_nil schema_cache.instance_variable_get(:@database_version)
end end
def test_marshal_dump_and_load def test_marshal_dump_and_load
@ -223,10 +192,6 @@ def test_marshal_dump_and_load
# Populate it. # Populate it.
cache.add("courses") cache.add("courses")
# We're going to manually dump, so we also need to force
# database_version to be stored.
cache.database_version
# Create a new cache by marshal dumping / loading. # Create a new cache by marshal dumping / loading.
cache = Marshal.load(Marshal.dump(cache.instance_variable_get(:@schema_reflection).instance_variable_get(:@cache))) cache = Marshal.load(Marshal.dump(cache.instance_variable_get(:@schema_reflection).instance_variable_get(:@cache)))
@ -236,7 +201,6 @@ def test_marshal_dump_and_load
assert cache.data_source_exists?(@connection, "courses") assert cache.data_source_exists?(@connection, "courses")
assert_equal "id", cache.primary_keys(@connection, "courses") assert_equal "id", cache.primary_keys(@connection, "courses")
assert_equal 1, cache.indexes(@connection, "courses").size assert_equal 1, cache.indexes(@connection, "courses").size
assert_equal @database_version.to_s, cache.database_version(@connection).to_s
end end
end end
@ -257,7 +221,6 @@ def test_marshal_dump_and_load_via_disk
assert cache.data_source_exists?("courses") assert cache.data_source_exists?("courses")
assert_equal "id", cache.primary_keys("courses") assert_equal "id", cache.primary_keys("courses")
assert_equal 1, cache.indexes("courses").size assert_equal 1, cache.indexes("courses").size
assert_equal @database_version.to_s, cache.database_version.to_s
end end
ensure ensure
tempfile.unlink tempfile.unlink
@ -316,7 +279,6 @@ def test_marshal_dump_and_load_with_gzip
assert cache.data_source_exists?(@connection, "courses") assert cache.data_source_exists?(@connection, "courses")
assert_equal "id", cache.primary_keys(@connection, "courses") assert_equal "id", cache.primary_keys(@connection, "courses")
assert_equal 1, cache.indexes(@connection, "courses").size assert_equal 1, cache.indexes(@connection, "courses").size
assert_equal @database_version.to_s, cache.database_version(@connection).to_s
end end
# Load a new cache. # Load a new cache.
@ -328,7 +290,6 @@ def test_marshal_dump_and_load_with_gzip
assert cache.data_source_exists?("courses") assert cache.data_source_exists?("courses")
assert_equal "id", cache.primary_keys("courses") assert_equal "id", cache.primary_keys("courses")
assert_equal 1, cache.indexes("courses").size assert_equal 1, cache.indexes("courses").size
assert_equal @database_version.to_s, cache.database_version.to_s
end end
ensure ensure
tempfile.unlink tempfile.unlink
@ -389,20 +350,20 @@ def test_when_lazily_load_schema_cache_is_set_cache_is_lazily_populated_when_est
ActiveRecord::Base.establish_connection(new_config) ActiveRecord::Base.establish_connection(new_config)
# cache starts empty # cache starts empty
assert_equal 0, ActiveRecord::Base.connection.pool.schema_reflection.instance_variable_get(:@cache).size assert_nil ActiveRecord::Base.connection.pool.schema_reflection.instance_variable_get(:@cache)
# now we access the cache, causing it to load # now we access the cache, causing it to load
assert ActiveRecord::Base.connection.schema_cache.version assert_not_nil ActiveRecord::Base.connection.schema_cache.version
assert File.exist?(tempfile) assert File.exist?(tempfile)
assert ActiveRecord::Base.connection.pool.schema_reflection.instance_variable_get(:@cache) assert_not_nil ActiveRecord::Base.connection.pool.schema_reflection.instance_variable_get(:@cache)
# assert cache is still empty on new connection (precondition for the # assert cache is still empty on new connection (precondition for the
# following to show it is loading because of the config change) # following to show it is loading because of the config change)
ActiveRecord::Base.establish_connection(new_config) ActiveRecord::Base.establish_connection(new_config)
assert File.exist?(tempfile) assert File.exist?(tempfile)
assert_equal 0, ActiveRecord::Base.connection.pool.schema_reflection.instance_variable_get(:@cache).size assert_nil ActiveRecord::Base.connection.pool.schema_reflection.instance_variable_get(:@cache)
# cache is loaded upon connection when lazily loading is on # cache is loaded upon connection when lazily loading is on
old_config = ActiveRecord.lazily_load_schema_cache old_config = ActiveRecord.lazily_load_schema_cache
@ -410,7 +371,7 @@ def test_when_lazily_load_schema_cache_is_set_cache_is_lazily_populated_when_est
ActiveRecord::Base.establish_connection(new_config) ActiveRecord::Base.establish_connection(new_config)
assert File.exist?(tempfile) assert File.exist?(tempfile)
assert ActiveRecord::Base.connection.pool.schema_reflection.instance_variable_get(:@cache) assert_not_nil ActiveRecord::Base.connection.pool.schema_reflection.instance_variable_get(:@cache)
ensure ensure
ActiveRecord.lazily_load_schema_cache = old_config ActiveRecord.lazily_load_schema_cache = old_config
ActiveRecord::Base.establish_connection(:arunit) ActiveRecord::Base.establish_connection(:arunit)
@ -449,7 +410,6 @@ def test_when_lazily_load_schema_cache_is_set_cache_is_lazily_populated_when_est
assert_equal expected, coder["data_sources"] assert_equal expected, coder["data_sources"]
assert_equal expected, coder["indexes"] assert_equal expected, coder["indexes"]
assert coder.key?("version") assert coder.key?("version")
assert coder.key?("database_version")
end end
private private