From b1328f16a0a4e085ffa002a4f55bd7576e709a36 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 09:30:44 +0100 Subject: [PATCH 01/15] Add MySQL database adapter for activerecord-tenanted Clean-room implementation of MySQL support for tenant database lifecycle operations. Extracts a with_server_connection helper to avoid repeating temporary connection setup, uses connection quoting for all SQL values, queries information_schema.schemata for exact existence checks instead of LIKE, and rescues ActiveRecord wrapper exceptions instead of adapter-specific ones for mysql2/trilogy parity. Adds database name length validation (64-char MySQL limit) and per-tenant host routing to BaseConfig. --- lib/active_record/tenanted.rb | 1 + .../tenanted/database_adapter.rb | 2 + .../tenanted/database_adapters/mysql.rb | 114 ++++++++++++++++++ .../database_configurations/base_config.rb | 11 ++ 4 files changed, 128 insertions(+) create mode 100644 lib/active_record/tenanted/database_adapters/mysql.rb diff --git a/lib/active_record/tenanted.rb b/lib/active_record/tenanted.rb index 1b0b62b..b5d3bef 100644 --- a/lib/active_record/tenanted.rb +++ b/lib/active_record/tenanted.rb @@ -6,6 +6,7 @@ loader = Zeitwerk::Loader.for_gem_extension(ActiveRecord) loader.inflector.inflect( "sqlite" => "SQLite", + "mysql" => "MySQL", ) loader.setup diff --git a/lib/active_record/tenanted/database_adapter.rb b/lib/active_record/tenanted/database_adapter.rb index b773941..60a099e 100644 --- a/lib/active_record/tenanted/database_adapter.rb +++ b/lib/active_record/tenanted/database_adapter.rb @@ -25,6 +25,8 @@ def new(db_config) end register "sqlite3", "ActiveRecord::Tenanted::DatabaseAdapters::SQLite" + register "mysql2", "ActiveRecord::Tenanted::DatabaseAdapters::MySQL" + register "trilogy", "ActiveRecord::Tenanted::DatabaseAdapters::MySQL" end end end diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb new file mode 100644 index 0000000..50dcff0 --- /dev/null +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module DatabaseAdapters # :nodoc: + class MySQL + attr_reader :db_config + + def initialize(db_config) + @db_config = db_config + end + + def tenant_databases + database_pattern = db_config.database_for("%") + scanner = Regexp.new(db_config.database_for("(.+)")) + + with_server_connection do |conn| + conn.select_values( + "SHOW DATABASES LIKE #{conn.quote(database_pattern)}" + ).filter_map do |name| + match = name.match(scanner) + if match + match[1] + else + Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from database #{name.inspect}" + nil + end + end + end + rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid + [] + end + + def validate_tenant_name(tenant_name) + if tenant_name.empty? + raise BadTenantNameError, "Tenant name cannot be empty." + end + + if tenant_name.match?(/[\/`]/) || !tenant_name.match?(/\A[\x20-\x7E]+\z/) + raise BadTenantNameError, "Tenant name contains an invalid character: #{tenant_name.inspect}" + end + end + + def create_database + with_server_connection do |conn| + options = {} + options[:charset] = db_config.configuration_hash[:encoding] if db_config.configuration_hash[:encoding] + options[:collation] = db_config.configuration_hash[:collation] if db_config.configuration_hash[:collation] + conn.create_database(database_path, options) + end + end + + def drop_database + with_server_connection do |conn| + conn.drop_database(database_path) + end + end + + def database_exist? + with_server_connection do |conn| + conn.select_values( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = #{conn.quote(database_path)}" + ).any? + end + rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid + false + end + + def database_ready? + database_exist? + end + + def acquire_ready_lock + yield + end + + def ensure_database_directory_exists + true + end + + def database_path + db_config.database + end + + def test_workerize(db, test_worker_id) + test_worker_suffix = "_#{test_worker_id}" + + if db.end_with?(test_worker_suffix) + db + else + db + test_worker_suffix + end + end + + def path_for(database) + database + end + + private + + def with_server_connection + server_config_hash = db_config.configuration_hash.except(:database) + server_db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( + db_config.env_name, "#{db_config.name}_server", server_config_hash + ) + + ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(server_db_config) do |conn| + yield conn + end + end + end + end + end +end diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index 51e37bb..57b7d45 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -29,6 +29,10 @@ def database_for(tenant_name) db = sprintf(database, tenant: tenant_name) + if %w[mysql2 trilogy].include?(adapter) && db.length > 64 + raise BadTenantNameError, "Database name too long (max 64 characters): #{db.inspect}" + end + if test_worker_id db = config_adapter.test_workerize(db, test_worker_id) end @@ -36,6 +40,12 @@ def database_for(tenant_name) db end + def host_for(tenant_name) + return unless host + + sprintf(host, tenant: tenant_name.to_s) + end + def tenants config_adapter.tenant_databases end @@ -45,6 +55,7 @@ def new_tenant_config(tenant_name) config_hash = configuration_hash.dup.tap do |hash| hash[:tenant] = tenant_name hash[:database] = database_for(tenant_name) + hash[:host] = host_for(tenant_name) if configuration_hash.key?(:host) hash[:tenanted_config_name] = name end Tenanted::DatabaseConfigurations::TenantConfig.new(env_name, config_name, config_hash) From ebbaf44fd9a8b30e124c86dd40112c9626fe9274 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 09:37:00 +0100 Subject: [PATCH 02/15] Add shared pool error classes and config predicates Three new error classes (TenantSwitchError, TenantResetError, TenantSwitchInTransactionError) for shared pool runtime failures, and four config methods on BaseConfig (shared_pool?, fallback_database, build_shared_pool_config, validate_shared_pool) to detect, validate, and build the concrete db config for shared pool mode. No runtime behavior changes. Non-shared-pool code paths are unaffected. --- lib/active_record/tenanted.rb | 9 ++++ .../database_configurations/base_config.rb | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/lib/active_record/tenanted.rb b/lib/active_record/tenanted.rb index b5d3bef..7774b27 100644 --- a/lib/active_record/tenanted.rb +++ b/lib/active_record/tenanted.rb @@ -42,6 +42,15 @@ class IntegrationNotConfiguredError < Error; end # Raised when an unsupported database adapter is used. class UnsupportedDatabaseError < Error; end + # Raised when a tenant database switch via USE fails. + class TenantSwitchError < Error; end + + # Raised when resetting a connection to the fallback database fails during checkin. + class TenantResetError < Error; end + + # Raised when a tenant switch is attempted while a database transaction is open. + class TenantSwitchInTransactionError < Error; end + # Return the constantized connection class configured in `config.active_record_tenanted.connection_class`, # or nil if none is configured. def self.connection_class diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index 57b7d45..86e8e80 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -71,6 +71,54 @@ def new_connection def max_connection_pools (configuration_hash[:max_connection_pools] || DEFAULT_MAX_CONNECTION_POOLS).to_i end + + def shared_pool? + configuration_hash[:shared_pool] == true + end + + def fallback_database + configuration_hash[:untenanted_database].presence + end + + def build_shared_pool_config(connection_class_name:) + validate_shared_pool + + hash = configuration_hash.merge( + database: fallback_database, + tenanted_connection_class_name: connection_class_name, + tenanted_config_name: name + ) + + ActiveRecord::DatabaseConfigurations::HashConfig.new(env_name, "#{name}_shared_pool", hash) + end + + def validate_shared_pool + return unless shared_pool? + + unless %w[mysql2 trilogy].include?(adapter) + raise ActiveRecord::Tenanted::TenantConfigurationError, + "Shared pool mode requires the mysql2 or trilogy adapter, " \ + "but #{name.inspect} is configured with #{adapter.inspect}." + end + + if fallback_database.blank? + raise ActiveRecord::Tenanted::TenantConfigurationError, + "Shared pool mode requires an untenanted_database to be configured " \ + "for #{name.inspect}." + end + + if configuration_hash[:host]&.include?("%{tenant}") + raise ActiveRecord::Tenanted::TenantConfigurationError, + "Shared pool mode does not support host templating " \ + "because a single pool implies a single host (config #{name.inspect})." + end + + if configuration_hash[:prepared_statements] == true + raise ActiveRecord::Tenanted::TenantConfigurationError, + "Shared pool mode does not support prepared statements " \ + "for #{name.inspect}. Set prepared_statements to false." + end + end end end end From e50f2fef314ce5a2156da7ee2c2ca93c3b05d618 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 09:48:02 +0100 Subject: [PATCH 03/15] Route tenant pool lookups through physical shard key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today every tenant maps 1:1 to a Rails connection pool. At high tenant counts (5,000+) this causes heavy pool churn, memory pressure, and latency spikes from constant pool recreation. This commit is the architectural pivot for shared pool support. It introduces pool_shard_for, a single decision point that splits logical tenant identity from physical pool identity. In shared pool mode, all tenants map to one synthetic shard key (:__tenanted_shared_pool__) per role instead of one pool per tenant. In non-shared mode, the tenant string is returned directly — zero overhead, zero behavioral change. Every pool flow now routes through this: connection_pool computes the physical shard once and threads it through retrieve_connection_pool and _create_tenanted_pool. destroy_tenant in shared mode drops the database but never removes the shared pool (since other tenants still need it). Without adapter callbacks (next commit), the shared pool connects to the fallback database and stays there. The pool routing layer is testable independently of the switching layer. --- lib/active_record/tenanted/tenant.rb | 84 +++++++++++++++++----------- 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index dc91b97..133516e 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -82,6 +82,10 @@ def to_s end end.new.freeze + # Synthetic shard key used as the physical pool key in shared pool mode. + # All tenants map to one pool per role, keyed by this sentinel. + SHARED_POOL_SHARD = :__tenanted_shared_pool__ + CONNECTION_POOL_CREATION_LOCK = Thread::Mutex.new # :nodoc: class_methods do @@ -160,9 +164,11 @@ def create_tenant(tenant_name, if_not_exists: false, &block) def destroy_tenant(tenant_name) ActiveRecord::Base.logger.info " DESTROY [tenant=#{tenant_name}] Destroying tenant database" - with_tenant(tenant_name, prohibit_shard_swapping: false) do - if retrieve_connection_pool(strict: false) - remove_connection + unless tenanted_root_config.shared_pool? + with_tenant(tenant_name, prohibit_shard_swapping: false) do + if retrieve_connection_pool(strict: false) + remove_connection + end end end @@ -186,55 +192,65 @@ def without_tenant(&block) # :nodoc: end def connection_pool(schema_version_check: true) # :nodoc: - if current_tenant - pool = retrieve_connection_pool(strict: false) + return Tenanted::UntenantedConnectionPool.new(tenanted_root_config, self) unless current_tenant - if pool.nil? - CONNECTION_POOL_CREATION_LOCK.synchronize do - # re-check now that we have the lock - pool = retrieve_connection_pool(strict: false) + shard = pool_shard_for(current_tenant) + pool = retrieve_connection_pool(shard: shard, strict: false) - if pool.nil? - _create_tenanted_pool(schema_version_check: schema_version_check) - pool = retrieve_connection_pool(strict: true) - end + unless pool + CONNECTION_POOL_CREATION_LOCK.synchronize do + pool = retrieve_connection_pool(shard: shard, strict: false) + + unless pool + _create_tenanted_pool(shard, schema_version_check: schema_version_check) + pool = retrieve_connection_pool(shard: shard, strict: true) end end - - pool - else - Tenanted::UntenantedConnectionPool.new(tenanted_root_config, self) end + + pool end def tenanted_root_config # :nodoc: ActiveRecord::Base.configurations.resolve(tenanted_config_name.to_sym) end - def _create_tenanted_pool(schema_version_check: true) # :nodoc: + def _create_tenanted_pool(physical_shard, schema_version_check: true) # :nodoc: # ensure all classes use the same connection pool - return superclass._create_tenanted_pool unless connection_class? + return superclass._create_tenanted_pool(physical_shard, schema_version_check: schema_version_check) unless connection_class? + + if tenanted_root_config.shared_pool? + tenanted_root_config.database_for(current_tenant) + + db_config = tenanted_root_config.build_shared_pool_config(connection_class_name: name) + connection_handler.establish_connection( + db_config, + owner_name: self, + role: current_role, + shard: physical_shard + ) + else + tenant = current_tenant + db_config = tenanted_root_config.new_tenant_config(tenant) - tenant = current_tenant - db_config = tenanted_root_config.new_tenant_config(tenant) + unless db_config.config_adapter.database_exist? + raise TenantDoesNotExistError, "The database for tenant #{tenant.inspect} does not exist." + end - unless db_config.config_adapter.database_exist? - raise TenantDoesNotExistError, "The database for tenant #{tenant.inspect} does not exist." - end - pool = establish_connection(db_config) + pool = establish_connection(db_config) - if schema_version_check - pending_migrations = pool.migration_context.open.pending_migrations - raise ActiveRecord::PendingMigrationError.new(pending_migrations: pending_migrations) if pending_migrations.any? - end + if schema_version_check + pending_migrations = pool.migration_context.open.pending_migrations + raise ActiveRecord::PendingMigrationError.new(pending_migrations: pending_migrations) if pending_migrations.any? + end - pool + pool + end end private - def retrieve_connection_pool(strict:) + def retrieve_connection_pool(shard: pool_shard_for(current_tenant), strict:) role = current_role - shard = current_tenant connection_handler.retrieve_connection_pool(connection_specification_name, role:, shard:, strict:).tap do |pool| if pool tenanted_connection_pools[[ shard, role ]] = pool @@ -243,6 +259,10 @@ def retrieve_connection_pool(strict:) end end + def pool_shard_for(logical_tenant) + tenanted_root_config.shared_pool? ? SHARED_POOL_SHARD : logical_tenant + end + def reap_connection_pools while tenanted_connection_pools.size > tenanted_root_config.max_connection_pools info, _ = *tenanted_connection_pools.pop From a95d06a4a8ccfef6c0b02aa561ce63c1d6b2504b Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 09:59:38 +0100 Subject: [PATCH 04/15] Add SharedPool adapter module with checkout/checkin lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the connection-level tenant switching layer for shared pool mode. This is Layer 1 of the dual-layer safety model — it handles first checkouts and connection teardown, while Layer 2 (tenant context reconciliation, coming in the next commit) will cover sticky leases and nested with_tenant. On checkout, the module resolves the current tenant from the connection class stored in the adapter config, switches the MySQL session to the tenant's database via USE, and wraps the pool's query cache in a NamespaceStore that prefixes cache keys with the current tenant database name. On checkin, it resets the connection back to the fallback database and clears tenant state so idle connections never carry stale tenant associations. Failed USE statements always discard the connection via throw_away! and raise typed errors. Tenant switches inside an open transaction raise TenantSwitchInTransactionError. Non-shared-pool connections see a single boolean check and early return with zero overhead. --- lib/active_record/tenanted/shared_pool.rb | 130 ++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 lib/active_record/tenanted/shared_pool.rb diff --git a/lib/active_record/tenanted/shared_pool.rb b/lib/active_record/tenanted/shared_pool.rb new file mode 100644 index 0000000..f2d7af3 --- /dev/null +++ b/lib/active_record/tenanted/shared_pool.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + # MySQL adapter extension that makes shared pool connections tenant-aware. + # + # This module handles two lifecycle seams: + # + # 1. Checkout: switch the connection to the current tenant's database via + # USE and attach a tenant-namespaced query cache. + # 2. Checkin: reset the connection to the fallback database. + # + # This is Layer 1 of the dual-layer safety model. It covers first checkouts + # and connection teardown. Layer 2 (tenant context reconciliation in Tenant) + # covers sticky leases and nested with_tenant. + # + # Included into Mysql2Adapter and TrilogyAdapter via Railtie load hooks. + # All methods guard on shared_pool? so non-shared-pool connections see a + # single hash lookup and early return. + module SharedPool + extend ActiveSupport::Concern + + included do + attr_accessor :tenant_database + + set_callback :checkout, :after, :apply_current_tenant + set_callback :checkin, :after, :reset_to_fallback + end + + def apply_current_tenant + return unless shared_pool? + + attach_query_cache_namespace + + klass = tenanted_connection_class + tenant = klass.current_tenant + + if tenant.blank? + raise TenantSwitchError, + "Cannot switch tenant database during checkout because no tenant " \ + "context is set (connection class #{klass.name.inspect})." + end + + database = klass.tenanted_root_config.database_for(tenant) + switch_tenant_database(tenant: tenant.to_s, database: database) + end + + def reset_to_fallback + return unless shared_pool? + + db = @config[:untenanted_database] + internal_execute("USE #{quote_table_name(db)}", "TENANT RESET", allow_retry: false) + + self.tenant = nil + self.tenant_database = db + rescue => error + throw_away! + raise TenantResetError, + "Failed to reset connection to fallback database #{db.inspect} " \ + "from tenant #{tenant.inspect}: #{error.class}: #{error.message}" + end + + def switch_tenant_database(tenant:, database:) + return if self.tenant == tenant && tenant_database == database + + if transaction_open? + raise TenantSwitchInTransactionError, + "Cannot switch to tenant #{tenant.inspect} (database #{database.inspect}) " \ + "because a transaction is open." + end + + internal_execute("USE #{quote_table_name(database)}", "TENANT SWITCH", allow_retry: false) + + self.tenant = tenant + self.tenant_database = database + rescue TenantSwitchInTransactionError + raise + rescue => error + throw_away! + raise TenantSwitchError, + "Failed to switch to tenant #{tenant.inspect} " \ + "(database #{database.inspect}): #{error.class}: #{error.message}" + end + + private + def shared_pool? + @config[:shared_pool] == true + end + + def tenanted_connection_class + @config.fetch(:tenanted_connection_class_name).constantize + end + + def attach_query_cache_namespace + self.query_cache = NamespaceStore.new( + pool.query_cache, + -> { tenant_database || @config[:untenanted_database] } + ) + end + + # Thin wrapper that prefixes query cache keys with the current tenant + # database name. Prevents cross-tenant cache hits when two tenants + # execute the same SQL on the same connection within one request. + class NamespaceStore + delegate :enabled, :enabled=, :enabled?, :dirties, :dirties=, :dirties?, + :clear, :size, :empty?, to: :base_store + + attr_reader :base_store + + def initialize(base_store, namespace_proc) + @base_store = base_store + @namespace_proc = namespace_proc + end + + def [](key) + base_store[namespaced(key)] + end + + def compute_if_absent(key, &block) + base_store.compute_if_absent(namespaced(key), &block) + end + + private + def namespaced(key) + [@namespace_proc.call, key] + end + end + end + end +end From e7edddf68be75b02318acd7435c41f9469dc3b43 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 10:05:07 +0100 Subject: [PATCH 05/15] Wire tenant context switching and Railtie adapter hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This completes the dual-layer safety model for shared pool tenant switching. Layer 1 (the SharedPool adapter module's checkout/checkin callbacks) handles first checkouts and connection teardown. But it can't cover sticky leases — when a connection is already leased and with_tenant changes context, the pool's ||= short-circuits checkout entirely, so no callback fires. Layer 2, added here, fills that gap. with_tenant now calls ensure_shared_pool_tenant_switch at two points: inside connected_to before yielding to user code (switches a sticky lease to the new tenant), and in a method-level ensure after connected_to pops the shard stack (reconciles back to the outer tenant on nested exit). A single :after :set_current_tenant callback covers the current_tenant= path for persistent changes. All three call sites use the same method, which reads current_tenant as ground truth and no-ops when the connection is already correct. No captured state, no restore tracking, no callback ordering dependencies. The Railtie initializer includes SharedPool into both MySQL adapter classes via load hooks, activating the checkout/checkin callbacks registered by that module's included block. --- lib/active_record/tenanted/railtie.rb | 10 ++++++++ lib/active_record/tenanted/tenant.rb | 36 +++++++++++++++++++++------ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/lib/active_record/tenanted/railtie.rb b/lib/active_record/tenanted/railtie.rb index 423a351..a1abadc 100644 --- a/lib/active_record/tenanted/railtie.rb +++ b/lib/active_record/tenanted/railtie.rb @@ -137,6 +137,16 @@ class Railtie < ::Rails::Railtie end end + initializer "active_record_tenanted.shared_pool" do + ActiveSupport.on_load(:active_record_mysql2adapter) do + include ActiveRecord::Tenanted::SharedPool + end + + ActiveSupport.on_load(:active_record_trilogyadapter) do + include ActiveRecord::Tenanted::SharedPool + end + end + initializer "active_record_tenanted.action_mailer" do ActiveSupport.on_load(:action_mailer) do prepend ActiveRecord::Tenanted::Mailer diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index 133516e..da37ce4 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -120,17 +120,19 @@ def tenant_exist?(tenant_name) def with_tenant(tenant_name, prohibit_shard_swapping: true, &block) tenant_name = tenant_name.to_s unless tenant_name == UNTENANTED_SENTINEL - if tenant_name == current_tenant - run_callbacks :with_tenant, &block - else - connection_class_for_self.connected_to(shard: tenant_name, role: ActiveRecord.writing_role) do - run_callbacks :with_tenant do - prohibit_shard_swapping(prohibit_shard_swapping) do - log_tenant_tag(tenant_name, &block) - end + return run_callbacks(:with_tenant, &block) if tenant_name == current_tenant + + connection_class_for_self.connected_to(shard: tenant_name, role: ActiveRecord.writing_role) do + ensure_shared_pool_tenant_switch + + run_callbacks :with_tenant do + prohibit_shard_swapping(prohibit_shard_swapping) do + log_tenant_tag(tenant_name, &block) end end end + ensure + ensure_shared_pool_tenant_switch end def create_tenant(tenant_name, if_not_exists: false, &block) @@ -263,6 +265,22 @@ def pool_shard_for(logical_tenant) tenanted_root_config.shared_pool? ? SHARED_POOL_SHARD : logical_tenant end + # Called from with_tenant (before/after) and :after set_current_tenant. + # Reconciles the leased connection to current_tenant in shared pool mode. + def ensure_shared_pool_tenant_switch + return unless tenanted_root_config.shared_pool? + return unless current_tenant + + pool = retrieve_connection_pool(shard: SHARED_POOL_SHARD, strict: false) + return unless pool + + conn = pool.active_connection + return unless conn + + database = tenanted_root_config.database_for(current_tenant) + conn.switch_tenant_database(tenant: current_tenant.to_s, database: database) + end + def reap_connection_pools while tenanted_connection_pools.size > tenanted_root_config.max_connection_pools info, _ = *tenanted_connection_pools.pop @@ -293,6 +311,8 @@ def log_tenant_tag(tenant_name, &block) define_callbacks :with_tenant define_callbacks :set_current_tenant + + set_callback :set_current_tenant, :after, :ensure_shared_pool_tenant_switch end def tenanted? From 9334619aa1d0e8163289cfe6f15248ceed8e62b1 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 10:50:24 +0100 Subject: [PATCH 06/15] Fix shared pool callback crash on temporary connections and tenant creation race condition Two critical bugs in the tenanted gem's shared pool mode are fixed here. The first bug causes SharedPool checkout/checkin callbacks to fire on temporary and task connections that are not true shared-pool runtime connections. The SharedPool module is included into all MySQL2 and Trilogy adapters globally via Railtie load hooks, and its callbacks guard on shared_pool? which only checks @config[:shared_pool] == true. The problem is that shared_pool: true propagates through config inheritance: BaseConfig -> new_tenant_config -> TenantConfig, and from there into with_server_connection which builds a HashConfig via configuration_hash.except(:database). These temporary configs carry shared_pool: true but never receive :tenanted_connection_class_name, which is only set by build_shared_pool_config for actual runtime pool connections. When the checkout callback fires on a temporary connection, shared_pool? returns true and then @config.fetch(:tenanted_connection_class_name) raises KeyError. This affects tenant_exist?, tenants, create_tenant, destroy_tenant, and all DatabaseTasks flows in shared pool mode. The fix tightens the shared_pool? guard to also require the presence of :tenanted_connection_class_name in the config hash, which is the definitive marker of a properly-constructed shared pool connection. The second bug is a race condition in MySQL tenant creation. Two sub-issues: acquire_ready_lock for MySQL was a no-op that just yielded, unlike the SQLite adapter which uses a file-based mutex. This means two concurrent threads or processes can both pass the database_exist? check and attempt to create the same database. Compounding this, the rescue clause in create_tenant unconditionally calls adapter.drop_database on any error, even if the current process did not create the database. So if thread A creates the database, thread B's create_database fails, thread B's rescue drops thread A's database, and then thread A's in-progress migration fails because its database was pulled out from under it. The fix has two parts: acquire_ready_lock now uses MySQL advisory locking via GET_LOCK/RELEASE_LOCK scoped to the tenant database name with a 30-second timeout, providing cross-process coordination on the same MySQL server. And created_db = true is moved to immediately after adapter.create_database succeeds, with the rescue gated on if created_db, so we only clean up databases we actually created. --- GUIDE.md | 90 ++++++++++++++++++- .../tenanted/database_adapters/mysql.rb | 16 +++- lib/active_record/tenanted/shared_pool.rb | 2 +- lib/active_record/tenanted/tenant.rb | 5 +- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 7697615..fcc84ea 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -28,6 +28,7 @@ * [2.5 Configuring the Tenant Resolver](#25-configuring-the-tenant-resolver) * [2.6 Other Tenant Configuration](#26-other-tenant-configuration) * [2.7 Related Rails Configurations](#27-related-rails-configurations) + * [2.8 Configuring Shared Pool Mode (MySQL)](#28-configuring-shared-pool-mode-mysql) - [Documentation "work in progress"](#documentation-work-in-progress) * [Active Record API](#active-record-api) * [Caching](#caching) @@ -66,7 +67,7 @@ The goal is that developers will rarely need to think about managing tenant isol ### 1.2 High-level implementation -Active Record Tenanted extends Active Record to dynamically create a Connection Pool for a tenant on demand. It does this in a thread-safe way by relying heavily on Rails' horizontal sharding features. +Active Record Tenanted extends Active Record to dynamically create a Connection Pool for a tenant on demand. It does this in a thread-safe way by relying heavily on Rails' horizontal sharding features. For MySQL databases, the gem additionally supports a shared pool mode where all tenants share a single connection pool per role, with tenant switching performed via session-level `USE` statements. This drastically reduces memory and connection overhead at high tenant cardinality. It extends Rails' testing frameworks so that tests don't need to explicitly set up a tenant or otherwise be aware of tenanting (unless tenanting behavior is explicitly being tested). @@ -390,6 +391,55 @@ TODO: - `active_record.check_schema_cache_dump_version = false` +### 2.8 Configuring Shared Pool Mode (MySQL) + +By default, Active Record Tenanted creates a separate connection pool for each tenant. This works well for SQLite and low-cardinality MySQL deployments, but at high tenant counts (thousands of tenants) the per-tenant pool model can cause excessive memory usage, file descriptor pressure, and latency spikes from frequent pool recreation. + +For MySQL databases, Active Record Tenanted supports a **shared pool mode** where all tenants share a single connection pool per role. Tenant switching is performed via MySQL's `USE` statement at connection checkout time. + +To enable shared pool mode, add `shared_pool: true` and `untenanted_database` to the tenanted database configuration: + +``` yaml +production: + primary: + adapter: mysql2 # or trilogy + tenanted: true + database: app_%{tenant} + shared_pool: true + untenanted_database: information_schema + prepared_statements: false +``` + +The `untenanted_database` is the database that idle connections are reset to on checkin. It must be accessible by the connection user (e.g. `information_schema`). + +Shared pool mode has the following constraints: + +- **Adapter**: must be `mysql2` or `trilogy`. Shared pool mode uses `USE `, which is MySQL-specific. +- **Prepared statements**: must be `false`. Prepared statement caches are tied to a specific database and cannot be shared across tenants. +- **Host templating**: `%{tenant}` in the `host` config is not supported because a single shared pool implies a single host. +- **Database name length**: the full database name (after tenant interpolation) must not exceed 64 characters (MySQL's limit). + +The shared pool implementation uses a dual-layer safety model: + +1. **Layer 1 (adapter callbacks)**: the `SharedPool` module, included into MySQL adapters via Railtie load hooks, switches the connection to the correct tenant database on checkout and resets it to the fallback database on checkin. +2. **Layer 2 (tenant context reconciliation)**: `with_tenant` and `current_tenant=` reconcile any leased connection to the current tenant, handling sticky leases and nested `with_tenant` calls. + +Failed `USE` statements discard the connection entirely (`throw_away!`) and raise a typed error. Tenant switching inside an open transaction is prohibited and raises `TenantSwitchInTransactionError`. Query cache keys are automatically tenant-namespaced to prevent cross-tenant cache hits. + +All existing subsystems (Active Job, Action Cable, Active Storage, Action Mailer, Console, Testing) work unchanged with shared pool mode because they interact through the tenant context API (`with_tenant`, `current_tenant`), not through pool internals. + +The `host` configuration also supports tenant interpolation for per-tenant host routing (in non-shared-pool mode): + +``` yaml +production: + primary: + adapter: mysql2 + tenanted: true + database: app_%{tenant} + host: "%{tenant}.db.example.com" +``` + + ## Documentation "work in progress" ### Active Record API @@ -400,7 +450,8 @@ Documentation outline: - `.with_tenant` and `.current_tenant=` - and the callbacks for each, `:with_tenant` and `:set_current_tenant` - validation - - invalid characters in a tenant name (which is database-dependent) + - invalid characters in a tenant name (which is database-dependent: path separators for SQLite, backticks/non-printable for MySQL) + - MySQL 64-character database name limit (validated on the full interpolated name) - and how the application may want to do additional validation (e.g. ICANN subdomain restrictions) - `#tenant` is a readonly attribute on all tenanted model instances - `.current_tenant` returns the execution context for the model connection class @@ -438,8 +489,41 @@ TODO: - [x] `#database_path_for(tenant_name)` - [x] `#tenants` returns all the tenants on disk (for iteration) - [x] raise an exception if tenant name contains a path separator + - [x] `#host_for(tenant_name)` for per-tenant host routing + - [x] MySQL database name 64-character limit validation in `#database_for` + - [x] `#shared_pool?` predicate + - [x] `#fallback_database` + - [x] `#build_shared_pool_config` + - [x] `#validate_shared_pool` (adapter, fallback db, host templating, prepared statements) - [ ] bucketed database paths +- implement MySQL database adapter (`AR::Tenanted::DatabaseAdapters::MySQL`) + - [x] create `DatabaseAdapters::MySQL` class following SQLite adapter interface + - [x] register `mysql2` and `trilogy` adapters in `DatabaseAdapter` + - [x] Zeitwerk inflection for `"mysql" => "MySQL"` + - [x] `tenant_databases` via `SHOW DATABASES LIKE` with connection quoting + - [x] `validate_tenant_name` for MySQL identifier constraints + - [x] `create_database` with charset/collation options + - [x] `drop_database`, `database_exist?` via `information_schema.schemata` + - [x] `with_server_connection` helper (adapter-agnostic error handling) + - [x] `test_workerize` with double-suffix guard + +- implement shared pool mode + - [x] `TenantSwitchError`, `TenantResetError`, `TenantSwitchInTransactionError` error classes + - [x] `SHARED_POOL_SHARD` sentinel constant in `Tenant` + - [x] `pool_shard_for` single decision point for physical shard key + - [x] shared pool creation in `_create_tenanted_pool` via `build_shared_pool_config` + - [x] shared pool guard in `destroy_tenant` (drop database, never remove shared pool) + - [x] `SharedPool` adapter module with `:checkout` and `:checkin` callbacks + - [x] `apply_current_tenant` (checkout): switch to tenant DB via `USE` + - [x] `reset_to_fallback` (checkin): reset to fallback DB, discard on failure + - [x] `switch_tenant_database`: no-op guard, transaction guard, `throw_away!` on failure + - [x] `NamespaceStore` for tenant-namespaced query cache keys + - [x] `ensure_shared_pool_tenant_switch` reconciliation method (Layer 2) + - [x] `with_tenant` restructured with ensure-based reconciliation + - [x] `:after :set_current_tenant` callback for shared pool switching + - [x] Railtie load hooks for `mysql2` and `trilogy` adapters to include `SharedPool` + - implement `AR::Tenanted::DatabaseConfigurations::TenantConfig` - [x] make sure the logs include the tenant name (via `#new_connection`) @@ -588,6 +672,7 @@ Documentation outline: - explain why we're not worried about russian doll caching - explain why calling Rails.cache directly requires care that it's either explicitly tenanted or global - explain why we're not worried about sql query caching (it belongs to the connection pool) +- explain how shared pool mode namespaces query cache keys by tenant database (`NamespaceStore`) TODO: @@ -595,6 +680,7 @@ TODO: - [x] make basic fragment caching work - [x] investigate: is collection caching going to be tenanted properly - [x] investigate: make sure the QueryCache executor is clearing query caches for tenanted pool +- [x] tenant-namespaced query cache for shared pool mode (`NamespaceStore`) - [x] do we need to do some exploration on how to make sure all caching is tenanted? - I'm making the call not to pursue this. Rails.cache is a primitive. Just document it. diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index 50dcff0..f73ec62 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -71,7 +71,21 @@ def database_ready? end def acquire_ready_lock - yield + lock_name = "tenanted:#{database_path}" + + with_server_connection do |conn| + result = conn.select_value("SELECT GET_LOCK(#{conn.quote(lock_name)}, 30)") + unless result == 1 + raise ActiveRecord::LockWaitTimeout, + "Could not acquire advisory lock for tenant database #{database_path.inspect}" + end + + begin + yield + ensure + conn.select_value("SELECT RELEASE_LOCK(#{conn.quote(lock_name)})") + end + end end def ensure_database_directory_exists diff --git a/lib/active_record/tenanted/shared_pool.rb b/lib/active_record/tenanted/shared_pool.rb index f2d7af3..41dc6f1 100644 --- a/lib/active_record/tenanted/shared_pool.rb +++ b/lib/active_record/tenanted/shared_pool.rb @@ -84,7 +84,7 @@ def switch_tenant_database(tenant:, database:) private def shared_pool? - @config[:shared_pool] == true + @config[:shared_pool] == true && @config.key?(:tenanted_connection_class_name) end def tenanted_connection_class diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index da37ce4..f5cff17 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -143,16 +143,15 @@ def create_tenant(tenant_name, if_not_exists: false, &block) adapter.acquire_ready_lock do unless adapter.database_exist? adapter.create_database + created_db = true with_tenant(tenant_name) do connection_pool(schema_version_check: false) ActiveRecord::Tenanted::DatabaseTasks.new(base_config).migrate_tenant(tenant_name) end - - created_db = true end rescue - adapter.drop_database + adapter.drop_database if created_db raise end From 963ddd4622ab9a6b88b605b9c6587531e8049534 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 10:55:28 +0100 Subject: [PATCH 07/15] Query cache namespace bypass on pinned cross-thread path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Rails' AbstractAdapter#query_cache (query_cache.rb:217-226) has a special path for pinned connections accessed from a non-owner thread — it returns pool.query_cache directly (a raw per-thread Store from QueryCacheRegistry). This bypasses the NamespaceStore wrapper that SharedPool#attach_query_cache_namespace sets on @query_cache during checkout. Without namespacing, if the cross-thread switches tenants (e.g., a Capybara server thread handling requests for different tenants during a system test), the bare SQL key could match a cached result from a different tenant's query. Fix: Override query_cache in SharedPool to intercept the raw Store coming from the cross-thread path and wrap it in a NamespaceStore. The guard !cache.is_a?(NamespaceStore) ensures the normal path (where @query_cache is already a NamespaceStore) passes through untouched. The wrapper is lightweight (2 instance variables, pure delegation) and the namespace proc reads tenant_database dynamically, keeping keys properly prefixed even as the connection switches tenants. Scope: This primarily affects test environments (transactional fixtures, system tests) where connections are pinned and shared across threads. --- lib/active_record/tenanted/shared_pool.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/active_record/tenanted/shared_pool.rb b/lib/active_record/tenanted/shared_pool.rb index 41dc6f1..3bc476e 100644 --- a/lib/active_record/tenanted/shared_pool.rb +++ b/lib/active_record/tenanted/shared_pool.rb @@ -60,6 +60,21 @@ def reset_to_fallback "from tenant #{tenant.inspect}: #{error.class}: #{error.message}" end + # Override the query_cache getter to ensure tenant namespace isolation + # on Rails' pinned cross-thread path. When a connection is pinned + # (transactional fixtures, system tests) and accessed from a non-owner + # thread, Rails returns pool.query_cache directly, bypassing the + # NamespaceStore wrapper set during checkout. This re-wraps it so + # cache keys remain namespaced by tenant database. + def query_cache + cache = super + if cache && shared_pool? && !cache.is_a?(NamespaceStore) + NamespaceStore.new(cache, -> { tenant_database || @config[:untenanted_database] }) + else + cache + end + end + def switch_tenant_database(tenant:, database:) return if self.tenant == tenant && tenant_database == database From ea4a790e78a742fa7836d61e307314f018d9b3dc Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 10:58:07 +0100 Subject: [PATCH 08/15] Fix MySQL 64-char DB name guard to account for test worker suffix The 64-character database name length guard in BaseConfig#database_for ran before test_workerize appended the _ suffix. A database name at or near 64 characters would pass validation and then exceed the MySQL limit at runtime during parallel test execution. The fix reorders the two operations so the length check runs against the final database name including any test worker suffix. --- .../tenanted/database_configurations/base_config.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index 86e8e80..7c4d439 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -29,14 +29,14 @@ def database_for(tenant_name) db = sprintf(database, tenant: tenant_name) - if %w[mysql2 trilogy].include?(adapter) && db.length > 64 - raise BadTenantNameError, "Database name too long (max 64 characters): #{db.inspect}" - end - if test_worker_id db = config_adapter.test_workerize(db, test_worker_id) end + if %w[mysql2 trilogy].include?(adapter) && db.length > 64 + raise BadTenantNameError, "Database name too long (max 64 characters): #{db.inspect}" + end + db end From 805580c35dbae422fd8ed412189fd2ca416017d3 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 11:05:11 +0100 Subject: [PATCH 09/15] Narrow MySQL discovery rescues and standardize logging across the gem The rescue clauses in tenant_databases and database_exist? caught both NoDatabaseError and StatementInvalid, returning empty array or false. StatementInvalid wraps nearly any MySQL error including permission denied, connection failures, and syntax errors, so a misconfigured user lacking SHOW DATABASES privileges would silently return empty results instead of surfacing the problem. StatementInvalid is removed from both rescue clauses, keeping only NoDatabaseError as the legitimate not-found case. Both rescued paths now log a warning with the error message so they don't go unnoticed. All logging throughout the gem is standardized from Rails.logger to ActiveRecord::Base.logger with nil-safe navigation. Rails.logger is not guaranteed to be available during early boot, in rake tasks, or when using ActiveRecord outside of Rails. ActiveRecord::Base.logger is the correct choice for an ActiveRecord extension and is consistent with how ActiveRecord itself logs internally. Seven occurrences are updated across mysql.rb, sqlite.rb, and tenant.rb. The existing ActiveRecord::Base.logger.info call in destroy_tenant gains a nil guard it was missing. The log_tenant_tag method adds a respond_to?(:tagged) check and falls through to yield when the logger is nil or not a TaggedLogging instance, preventing the block from being silently swallowed. --- lib/active_record/tenanted/database_adapters/mysql.rb | 8 +++++--- lib/active_record/tenanted/database_adapters/sqlite.rb | 2 +- lib/active_record/tenanted/tenant.rb | 9 +++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index f73ec62..a2ebd3e 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -22,12 +22,13 @@ def tenant_databases if match match[1] else - Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from database #{name.inspect}" + ActiveRecord::Base.logger&.warn "ActiveRecord::Tenanted: Cannot parse tenant name from database #{name.inspect}" nil end end end - rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid + rescue ActiveRecord::NoDatabaseError => error + ActiveRecord::Base.logger&.warn "ActiveRecord::Tenanted: tenant_databases returned empty due to NoDatabaseError: #{error.message}" [] end @@ -62,7 +63,8 @@ def database_exist? "SELECT schema_name FROM information_schema.schemata WHERE schema_name = #{conn.quote(database_path)}" ).any? end - rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid + rescue ActiveRecord::NoDatabaseError => error + ActiveRecord::Base.logger&.warn "ActiveRecord::Tenanted: database_exist? returned false due to NoDatabaseError: #{error.message}" false end diff --git a/lib/active_record/tenanted/database_adapters/sqlite.rb b/lib/active_record/tenanted/database_adapters/sqlite.rb index 35745de..1cf4287 100644 --- a/lib/active_record/tenanted/database_adapters/sqlite.rb +++ b/lib/active_record/tenanted/database_adapters/sqlite.rb @@ -28,7 +28,7 @@ def tenant_databases Dir.glob(glob).filter_map do |path| result = path.scan(scanner).flatten.first if result.nil? - Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from filename #{path.inspect}" + ActiveRecord::Base.logger&.warn "ActiveRecord::Tenanted: Cannot parse tenant name from filename #{path.inspect}" end result end diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index f5cff17..18484f4 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -163,7 +163,7 @@ def create_tenant(tenant_name, if_not_exists: false, &block) end def destroy_tenant(tenant_name) - ActiveRecord::Base.logger.info " DESTROY [tenant=#{tenant_name}] Destroying tenant database" + ActiveRecord::Base.logger&.info " DESTROY [tenant=#{tenant_name}] Destroying tenant database" unless tenanted_root_config.shared_pool? with_tenant(tenant_name, prohibit_shard_swapping: false) do @@ -286,13 +286,14 @@ def reap_connection_pools shard, role = *info connection_handler.remove_connection_pool(connection_specification_name, role:, shard:) - Rails.logger.info " REAPED [tenant=#{shard} role=#{role}] Tenanted connection pool reaped to limit total connection pools" + ActiveRecord::Base.logger&.info " REAPED [tenant=#{shard} role=#{role}] Tenanted connection pool reaped to limit total connection pools" end end def log_tenant_tag(tenant_name, &block) - if Rails.application.config.active_record_tenanted.log_tenant_tag - Rails.logger.tagged("tenant=#{tenant_name}", &block) + logger = ActiveRecord::Base.logger + if Rails.application.config.active_record_tenanted.log_tenant_tag && logger.respond_to?(:tagged) + logger.tagged("tenant=#{tenant_name}", &block) else yield end From 2bb73264b4f931e00c236577de74d2b7dedc64f2 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 11:25:26 +0100 Subject: [PATCH 10/15] Fix four bugs in activerecord-tenanted's MySQL adapter and config layer The server connection helper used during tenant creation was replacing ActiveRecord::Base's global connection pool, leaking a database-less config into unrelated queries for the duration of the create+migrate critical section. It now uses an isolated ConnectionHandler that is discarded after each use. Advisory lock release failures in ensure could mask a successful tenant creation and cause the caller to drop a perfectly good database. The release is now rescued and logged, since MySQL automatically frees advisory locks when the connection closes. Both host_for and database_for used sprintf, which treats any literal percent sign as a format directive. They now use gsub to substitute only the %{tenant} placeholder. The shared-pool prepared-statements guard only rejected the boolean true, but Active Record treats most non-"false" values as enabled. It now uses the same type_cast_config_to_boolean that Active Record uses internally. --- .../tenanted/database_adapters/mysql.rb | 31 +++++++++++++++++-- .../database_configurations/base_config.rb | 14 ++++++--- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index a2ebd3e..bbaee96 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -85,7 +85,20 @@ def acquire_ready_lock begin yield ensure - conn.select_value("SELECT RELEASE_LOCK(#{conn.quote(lock_name)})") + begin + conn.select_value("SELECT RELEASE_LOCK(#{conn.quote(lock_name)})") + rescue => error + # MySQL releases advisory locks automatically when the + # connection closes, so a failed RELEASE_LOCK is recoverable. + # Letting it propagate would mask the real operation result and + # could cause create_tenant to drop a successfully created + # database. + ActiveRecord::Base.logger&.warn( + "ActiveRecord::Tenanted: failed to release advisory lock " \ + "#{lock_name.inspect}: #{error.message}; the lock will be " \ + "released when the connection closes" + ) + end end end end @@ -114,15 +127,29 @@ def path_for(database) private + # Establishes an isolated connection to the MySQL server (without a + # specific database selected). We intentionally avoid + # DatabaseTasks.with_temporary_connection here because that method + # replaces ActiveRecord::Base's global connection pool for the + # duration of the block — any Base-backed query running concurrently + # would hit the database-less server config. + # + # Instead we spin up a throwaway ConnectionHandler so the server + # connection never touches the global pool. def with_server_connection server_config_hash = db_config.configuration_hash.except(:database) server_db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( db_config.env_name, "#{db_config.name}_server", server_config_hash ) - ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(server_db_config) do |conn| + handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new + pool = handler.establish_connection(server_db_config) + + pool.with_connection do |conn| yield conn end + ensure + handler&.clear_all_connections!(:all) end end end diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index 7c4d439..9959d9e 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -27,7 +27,7 @@ def database_for(tenant_name) config_adapter.validate_tenant_name(tenant_name) - db = sprintf(database, tenant: tenant_name) + db = database.gsub("%{tenant}", tenant_name) if test_worker_id db = config_adapter.test_workerize(db, test_worker_id) @@ -43,7 +43,7 @@ def database_for(tenant_name) def host_for(tenant_name) return unless host - sprintf(host, tenant: tenant_name.to_s) + host.gsub("%{tenant}", tenant_name.to_s) end def tenants @@ -113,10 +113,14 @@ def validate_shared_pool "because a single pool implies a single host (config #{name.inspect})." end - if configuration_hash[:prepared_statements] == true + ps_value = configuration_hash[:prepared_statements] + ps_cast = ActiveRecord::ConnectionAdapters::AbstractAdapter + .type_cast_config_to_boolean(ps_value) + + if ps_cast != false raise ActiveRecord::Tenanted::TenantConfigurationError, - "Shared pool mode does not support prepared statements " \ - "for #{name.inspect}. Set prepared_statements to false." + "Shared pool mode requires prepared_statements: false " \ + "for #{name.inspect}." end end end From 4e8a25fd4b8dd4eeb6561a1c28f848f0add0da68 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 11:33:11 +0100 Subject: [PATCH 11/15] Fix host template enumeration conflict and add early shared-pool validation Prevent with_server_connection from attempting to connect to a literal %{tenant}.db.example.com hostname when called on a BaseConfig with host templating. This made tenants, with_each_tenant, and migrate-all workflows silently broken for host-templated setups. Now raises TenantConfigurationError with a clear message explaining that host-templated configs cannot enumerate databases from a single server. Also move shared-pool validation to boot time (after_initialize) so misconfigs like wrong adapter, missing untenanted_database, or prepared_statements: true are caught immediately instead of at first tenant connection. The existing runtime check in build_shared_pool_config is kept as defense-in-depth. --- lib/active_record/tenanted/database_adapters/mysql.rb | 8 ++++++++ lib/active_record/tenanted/railtie.rb | 2 ++ 2 files changed, 10 insertions(+) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index bbaee96..d867bb8 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -137,6 +137,14 @@ def path_for(database) # Instead we spin up a throwaway ConnectionHandler so the server # connection never touches the global pool. def with_server_connection + if db_config.configuration_hash[:host]&.include?("%{tenant}") + raise TenantConfigurationError, + "Cannot connect to the MySQL server because the host contains " \ + "an unresolved %{tenant} template. Host-templated configurations " \ + "cannot enumerate tenant databases from a single server. Use a " \ + "non-templated host, or configure shared_pool mode for single-host setups." + end + server_config_hash = db_config.configuration_hash.except(:database) server_db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( db_config.env_name, "#{db_config.name}_server", server_config_hash diff --git a/lib/active_record/tenanted/railtie.rb b/lib/active_record/tenanted/railtie.rb index a1abadc..421e50f 100644 --- a/lib/active_record/tenanted/railtie.rb +++ b/lib/active_record/tenanted/railtie.rb @@ -158,6 +158,8 @@ class Railtie < ::Rails::Railtie end config.after_initialize do + ActiveRecord::Tenanted.base_configs.each(&:validate_shared_pool) + ActiveRecord::QueryLogs.taggings = ActiveRecord::QueryLogs.taggings.merge( tenant: ->(context) { context[:connection].tenant } ) From 2321e03e2f45efa718713c278c8bca4320f7af61 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 11:52:45 +0100 Subject: [PATCH 12/15] Restore fail-fast on mistyped template tokens in database and host templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch from sprintf to gsub in database_for and host_for silently regressed template typo safety. A misspelled token like %{tenant_name} instead of %{tenant} no longer raised a KeyError — it passed through unchanged, quietly routing every tenant to the same database or host. Add post-substitution checks that raise TenantConfigurationError if any %{...} token survives after %{tenant} replacement, catching typos at the point of use rather than letting them cause silent misrouting. --- .../database_configurations/base_config.rb | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index 9959d9e..d3e48e9 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -29,6 +29,12 @@ def database_for(tenant_name) db = database.gsub("%{tenant}", tenant_name) + if db.match?(/%\{[^}]+\}/) + raise ActiveRecord::Tenanted::TenantConfigurationError, + "Database template contains an unrecognized token: #{database.inspect}. " \ + "Only %{tenant} is supported." + end + if test_worker_id db = config_adapter.test_workerize(db, test_worker_id) end @@ -43,7 +49,15 @@ def database_for(tenant_name) def host_for(tenant_name) return unless host - host.gsub("%{tenant}", tenant_name.to_s) + resolved = host.gsub("%{tenant}", tenant_name.to_s) + + if resolved.match?(/%\{[^}]+\}/) + raise ActiveRecord::Tenanted::TenantConfigurationError, + "Host template contains an unrecognized token: #{host.inspect}. " \ + "Only %{tenant} is supported." + end + + resolved end def tenants From b86300c44dab57d68e5848de723c5e0d8bb5ebe0 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 11:55:34 +0100 Subject: [PATCH 13/15] Cap advisory lock names to MySQL's 64-character limit acquire_ready_lock built lock names as "tenanted:#{database_path}", but database names are allowed up to 64 characters, producing lock names up to 73 characters. MySQL's GET_LOCK has a 64-character limit and silently truncates longer names, which could cause unrelated tenants to collide on the same lock. Replace the raw database path with a SHA256 digest, keeping the name deterministic and always within the 64-character limit. --- lib/active_record/tenanted/database_adapters/mysql.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/active_record/tenanted/database_adapters/mysql.rb b/lib/active_record/tenanted/database_adapters/mysql.rb index d867bb8..8e99a7e 100644 --- a/lib/active_record/tenanted/database_adapters/mysql.rb +++ b/lib/active_record/tenanted/database_adapters/mysql.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "digest" + module ActiveRecord module Tenanted module DatabaseAdapters # :nodoc: @@ -73,7 +75,7 @@ def database_ready? end def acquire_ready_lock - lock_name = "tenanted:#{database_path}" + lock_name = "tenanted:#{Digest::SHA256.hexdigest(database_path)}"[0, 64] with_server_connection do |conn| result = conn.select_value("SELECT GET_LOCK(#{conn.quote(lock_name)}, 30)") From 8b387d7ce7a24f25bad89e95c39e14d92c63a04e Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 11:57:45 +0100 Subject: [PATCH 14/15] Allow omitted prepared_statements in shared-pool validation The prepared_statements check in validate_shared_pool rejected configs that simply omitted the setting, even though mysql2 and trilogy both default to false. Since the validation already requires one of those two adapters, an unset value is safe. Skip the check when prepared_statements is nil and only raise when it's explicitly set to a truthy value. --- .../database_configurations/base_config.rb | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/active_record/tenanted/database_configurations/base_config.rb b/lib/active_record/tenanted/database_configurations/base_config.rb index d3e48e9..d6fc94c 100644 --- a/lib/active_record/tenanted/database_configurations/base_config.rb +++ b/lib/active_record/tenanted/database_configurations/base_config.rb @@ -128,13 +128,15 @@ def validate_shared_pool end ps_value = configuration_hash[:prepared_statements] - ps_cast = ActiveRecord::ConnectionAdapters::AbstractAdapter - .type_cast_config_to_boolean(ps_value) - - if ps_cast != false - raise ActiveRecord::Tenanted::TenantConfigurationError, - "Shared pool mode requires prepared_statements: false " \ - "for #{name.inspect}." + unless ps_value.nil? + ps_cast = ActiveRecord::ConnectionAdapters::AbstractAdapter + .type_cast_config_to_boolean(ps_value) + + if ps_cast != false + raise ActiveRecord::Tenanted::TenantConfigurationError, + "Shared pool mode requires prepared_statements: false " \ + "for #{name.inspect}." + end end end end From d7d13d983f2b951012022c61704e4573014d6486 Mon Sep 17 00:00:00 2001 From: Quint Pieters Date: Mon, 23 Feb 2026 15:35:07 +0100 Subject: [PATCH 15/15] Add reset_tenant_on_checkin config option to skip redundant USE statements in shared pool mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every shared-pool query currently issues two USE roundtrips — one on checkin (reset to fallback) and one on checkout (switch to tenant). Since switch_tenant_database already has a fast-path no-op when the connection is already on the right tenant, we can skip the checkin reset and let consecutive same-tenant checkouts resolve with just two string comparisons and zero IO. The checkout callback still validates the tenant on every checkout, so tenant safety is maintained. Defaults to true to preserve existing behavior. --- lib/active_record/tenanted/railtie.rb | 15 +++++++++++++++ lib/active_record/tenanted/shared_pool.rb | 1 + 2 files changed, 16 insertions(+) diff --git a/lib/active_record/tenanted/railtie.rb b/lib/active_record/tenanted/railtie.rb index 421e50f..9b64054 100644 --- a/lib/active_record/tenanted/railtie.rb +++ b/lib/active_record/tenanted/railtie.rb @@ -46,6 +46,21 @@ class Railtie < ::Rails::Railtie # Defaults to false in development and test environments, and true in all other environments. config.active_record_tenanted.log_tenant_tag = !Rails.env.local? + # Set this to false to skip the USE statement that resets connections to the + # fallback database on checkin in shared pool mode. When false, connections + # preserve their tenant database across checkout/checkin cycles, and the + # checkout callback switches only when the next checkout targets a different + # tenant. This eliminates most USE roundtrips in the per-query connection + # lifecycle. + # + # The checkout callback (apply_current_tenant) validates the tenant on every + # checkout regardless of this setting, so tenant safety is maintained. + # + # Only applies when shared_pool: true is set in database.yml. + # + # Defaults to true (always reset on checkin). + config.active_record_tenanted.reset_tenant_on_checkin = true + # Set this to override the default tenant name used in development and test environments. # # This is the default tenant name used by database tasks and in the Rails console. In both diff --git a/lib/active_record/tenanted/shared_pool.rb b/lib/active_record/tenanted/shared_pool.rb index 3bc476e..48c63e3 100644 --- a/lib/active_record/tenanted/shared_pool.rb +++ b/lib/active_record/tenanted/shared_pool.rb @@ -47,6 +47,7 @@ def apply_current_tenant def reset_to_fallback return unless shared_pool? + return unless Rails.application.config.active_record_tenanted.reset_tenant_on_checkin db = @config[:untenanted_database] internal_execute("USE #{quote_table_name(db)}", "TENANT RESET", allow_retry: false)