Skip to content

Add MySQL database adapter and shared pool mode via USE switching#283

Draft
QuirkQ wants to merge 15 commits intobasecamp:mainfrom
QuirkQ:feat/mysql-database-adapter-USE
Draft

Add MySQL database adapter and shared pool mode via USE switching#283
QuirkQ wants to merge 15 commits intobasecamp:mainfrom
QuirkQ:feat/mysql-database-adapter-USE

Conversation

@QuirkQ
Copy link

@QuirkQ QuirkQ commented Feb 23, 2026

Summary

This is a draft/proof-of-concept for MySQL support in activerecord-tenanted, including a shared pool mode that replaces the per-tenant connection pool model with a single shared pool per role. Tenant switching happens at the MySQL session level via USE <db> on checkout and checkin.

The motivation is straightforward: at high tenant cardinality (thousands of tenants), the per-tenant pool model causes excessive memory usage, file descriptor pressure, and latency spikes from frequent pool creation and teardown. A single shared pool avoids all of that.

This approach also provides a much cleaner migration path for applications coming from ros-apartment, which uses the same session-level USE database switching model. Apps moving to activerecord-tenanted would get a familiar runtime model without needing to restructure their MySQL databases into separate connection pools per tenant.

No tests are included yet. This is a proof of concept to see if there's community interest. If there is, I'd be happy to add comprehensive tests following the existing scenario-based patterns and discuss splitting this into smaller, focused PRs (for example, the MySQL adapter could land as a standalone PR since it's independently useful, with shared pool mode layered on top).

What's in the diff

The MySQL database adapter

The DatabaseAdapters::MySQL class is heavily based on @andrewmarkle's work in #246. He introduced the adapter pattern and the core implementation. I built on top of his PR with some changes: extracting the repeated server-connection setup into a with_server_connection helper, switching from DatabaseTasks.with_temporary_connection to a throwaway ConnectionHandler (the former replaces ActiveRecord::Base's global connection pool for the duration of the block, which would break concurrent queries), using conn.quote instead of string interpolation for SHOW DATABASES LIKE, adding proper advisory locking via MySQL's GET_LOCK / RELEASE_LOCK, and filling in the test_workerize implementation with a double-suffix guard.

The adapter is registered for both mysql2 and trilogy.

The core idea: splitting logical tenant identity from physical pool identity

The key architectural decision is introducing pool_shard_for as a single decision point. In the existing per-tenant model, each tenant name maps 1:1 to a Rails shard key and therefore to a distinct connection pool. In shared pool mode, all tenants map to one synthetic shard key (:__tenanted_shared_pool__) per role. The logical tenant stays in the connected_to stack exactly as before, but the physical pool lookup uses the sentinel key.

This works because activerecord-tenanted already overrides connection_pool on the Tenant module and controls the entire pool lookup path. Rails' internal pool machinery is never patched. The change is just which shard key gets passed to retrieve_connection_pool and connection_handler.establish_connection.

For pool creation in shared mode, I call connection_handler.establish_connection directly with the sentinel shard rather than going through the class-level establish_connection, because the class-level method hardcodes shard: current_shard (which would be the tenant name string, not the sentinel). The pool connects to the configured untenanted_database (typically information_schema) and tenant switching happens at runtime via adapter callbacks.

Tenant switching: adapter callbacks and context reconciliation

The primary switching mechanism is straightforward. The SharedPool module is included into Mysql2Adapter and TrilogyAdapter via Railtie load hooks. It registers :after checkout and :after checkin callbacks. On checkout, it switches the connection to the current tenant's database via USE and attaches a tenant-namespaced query cache wrapper. On checkin, it resets to the fallback database. This covers the normal request lifecycle where a connection is checked out, used for one tenant, and returned.

On top of that there's a second layer of reconciliation in the Tenant module. with_tenant calls ensure_shared_pool_tenant_switch inside connected_to before the user block, and again in a method-level ensure after connected_to pops the shard stack. There's also an :after :set_current_tenant callback for current_tenant=.

In practice, with_tenant defaults to prohibit_shard_swapping: true, which means Rails will raise if you try to nest with_tenant calls — so the normal flow is one tenant per request and the checkout callback handles it entirely. The reconciliation layer is a safety net for the edge cases where prohibit_shard_swapping: false is passed explicitly (currently only destroy_tenant and without_tenant in the testing helper do this), and for current_tenant= which does a persistent switch without going through the pool checkout path.

Both layers call switch_tenant_database, which no-ops when the connection is already on the right tenant (two string comparisons). No conflict, no double-switching.

Safety guarantees

Failed USE statements discard the connection entirely via throw_away! and raise a typed error (TenantSwitchError or TenantResetError). No reuse of unknown session state. Tenant switching inside an open transaction raises TenantSwitchInTransactionError because switching would corrupt the transaction's session state.

Query cache keys are tenant-namespaced via a NamespaceStore wrapper that prefixes keys with the current tenant database name. This prevents cross-tenant cache hits when two tenants execute the same SQL on the same shared connection. The wrapper uses a proc for the namespace (not a fixed string) because tenant switching can happen after the wrapper is created.

Every shared-pool method is guarded with return unless shared_pool?. Non-shared-pool connections see a single hash lookup and early return. Zero overhead for existing SQLite or per-tenant MySQL deployments.

Other changes worth mentioning

destroy_tenant in shared pool mode drops the database but never removes the shared pool (since it's shared across all tenants). create_tenant bootstraps the shared pool on the first tenant and reuses it for all subsequent tenants. I also fixed a bug in create_tenant where the rescue block would call drop_database even if the database creation itself hadn't succeeded yet.

database_for was switched from sprintf to gsub with unrecognized token detection, and host_for was added for per-tenant host routing in non-shared-pool mode. The MySQL 64-character database name limit is validated on the full interpolated name.

A few Rails.logger calls were changed to ActiveRecord::Base.logger&. for consistency and to avoid nil errors when the logger isn't set up.

Configuration

Shared pool mode is opt-in. Without shared_pool: true, behavior is identical to today's per-tenant pool model.

production:
  primary:
    adapter: mysql2          # or trilogy
    tenanted: true
    database: app_%{tenant}
    shared_pool: true
    untenanted_database: information_schema

Configuration is validated at boot: the adapter must be mysql2 or trilogy, untenanted_database must be present, prepared_statements must not be explicitly set to true (prepared statement caches are tied to a specific database — both mysql2 and trilogy already default to false), and host templating with %{tenant} is rejected because a single pool implies a single host.

Status and next steps

This is a draft. I'm opening it to gauge interest and get early feedback on the approach. If there's appetite for this:

I'd add unit and integration tests following the existing scenario patterns. The MySQL adapter could probably land as its own PR first since it's independently useful for any MySQL-backed tenanted deployment. Shared pool mode would layer on top. Happy to iterate on any of the design decisions above.

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.
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.
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.
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.
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.
…eation 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.
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.
The 64-character database name length guard in BaseConfig#database_for
ran before test_workerize appended the _<worker_id> 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.
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.
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.
…dation

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.
…mplates

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.
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.
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.
…ments in shared pool mode

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant