Module: Apartment::Patches::ConnectionRegistry

Defined in:
lib/apartment/patches/connection_registry.rb

Overview

Serializes access to ActiveRecord's connection registry so pool-per-tenant can register and discard shards from many threads at once.

THE REGISTRY. ActiveRecord::ConnectionAdapters::PoolManager (the Rails class, not Apartment's same-named one) indexes every pool AR knows about as a plain nested Hash, { role => { shard => pool_config } }, with no synchronization of any kind. Rails can afford that because upstream writes it only at boot: establish_connection runs from initializers and from connects_to, single-threaded, and after boot the structure is read-only.

WHY v4 CANNOT. A tenant pool is established lazily, on the thread that first routes to that tenant, for the life of the process — so every cold tenant switch adds a shard key to that Hash while other threads are reading it. MRI's per-Hash iteration guard turns the collision into a hard failure in the WRITER: RuntimeError: can't add a new key into hash during iteration, surfaced by Apartment as a failed tenant switch. The readers are routine and unavoidable, all via ConnectionHandler#each_connection_pool: ActiveRecord::QueryCache.run on every executor run (the start of every request and job), ConnectionPool::ExecutorHooks.complete on every executor completion, Base.clear_query_caches_for_current_thread after writes, ActiveRecord.all_open_transactions for transaction-callback bookkeeping, and clear_active_connections! / clear_all_connections! / flush_idle_connections!. (AR's own ConnectionPool::Reaper is NOT one of them — it keeps a private WeakRef list and never reads this registry.) Parallel migration is simply the densest producer of cold creates (one thread per tenant, all establishing at once) and therefore the easiest place to see it.

A read can write, too: get_pool_config / pool_configs / each_pool_config reach the outer Hash through [], whose default proc (+Hash.new { |h, k| h = {} }+) INSERTS an empty shard map on a miss. So a lookup for a not-yet-seen role is itself a write, and the guarded set below is every public accessor rather than only the obvious mutators.

WHY NOT JUST LOCK APARTMENT'S OWN CALL SITES. Apartment's cold creates are already serialized against each other — Concurrent::Map's MRI backend holds a write lock across compute_if_absent, and the capacity-bounded path holds PoolManager's own create mutex. Neither excludes AR's readers, which is the side of the race that matters, and neither covers the discard half (+remove_connection_pool+ from Apartment::PoolReaper's timer thread — ours, not AR's — from AbstractAdapter#drop, from Migrator eviction). The registry itself is the only place that sees all of it.

SCOPE. Applied from Apartment.activate!, not at gem load: an app that merely has the gem in its Gemfile should not pay for a lock it does not need. Prepending affects instances already created (the primary pool's manager is built during Rails' database initializer, before activate!), which is why the lock is module-level rather than per-instance state.

NO DEADLOCK, BY CONSTRUCTION. SYNC is a LEAF lock: every guarded body is an in-memory Hash operation that acquires nothing else, performs no IO, and yields to no caller. Keep it that way — it is the entire deadlock-freedom argument, and Apartment's cold-create path already establishes the one lock ordering that exists (Concurrent::Map's write lock, or the capped path's create mutex, is taken FIRST and SYNC underneath it via establish_connection). Nothing acquires SYNC and then reaches for either. Upstream cooperates: remove_pool_config returns the pool_config and disconnect_pool_from_pool_manager calls disconnect! on it only after the guarded call has returned, and establish_connection builds the pool (PoolConfig#pool, under PoolConfig's own monitor) after set_pool_config returns — so no pool IO and no other monitor is ever nested under SYNC.

COST. One uncontended monitor acquire, measured at ~90ns, on registry operations only. get_pool_config is the hot one (AR resolves it per query for default-tenant and pinned traffic), where it is noise against even a cached query. Iteration copies its pool_config list under the lock and yields outside it, so per-request hooks hold the lock for the length of a Hash walk and never for the length of a caller's block.

Defined Under Namespace

Modules: HandlerSync, PoolManagerSync

Constant Summary collapse

SYNC =

One monitor for every registry in the process. Instances are few (one per connection name, so typically one or two) and every guarded operation is an in-memory Hash op with no IO and no yielding, so per-instance locks would buy negligible parallelism in exchange for lazy-init state on objects that already exist by the time the patch is applied.

Monitor, not Mutex, purely defensively: no guarded method re-enters another today (each accessor's super reads the Hash directly, and #each_pool_config yields outside the lock), and establish_connection's several acquisitions are sequential rather than nested — a Mutex would work. Reentrance costs nothing measurable here (~90ns either way) and buys tolerance for an upstream implementation in which one accessor dispatches through another.

Monitor.new
POOL_MANAGER_METHODS =

Every public accessor AR::ConnectionAdapters::PoolManager defines. Both halves of this claim are enforced at activate! time: a method that has gone missing raises, and an accessor upstream has ADDED that we therefore do not guard warns (the patch still works, but there is a hole in it).

%i[
  shard_names
  role_names
  pool_configs
  each_pool_config
  remove_role
  remove_pool_config
  get_pool_config
  set_pool_config
].freeze
HANDLER_METHODS =
%i[set_pool_manager].freeze

Class Method Summary collapse

Class Method Details

.apply!Object

Idempotent — prepend on an already-prepended module is a no-op.



111
112
113
114
115
116
# File 'lib/apartment/patches/connection_registry.rb', line 111

def apply!
  serialize!(ActiveRecord::ConnectionAdapters::PoolManager, POOL_MANAGER_METHODS, PoolManagerSync,
             exhaustive: true)
  serialize!(ActiveRecord::ConnectionAdapters::ConnectionHandler, HANDLER_METHODS, HandlerSync)
  nil
end