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
superreads 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
-
.apply! ⇒ Object
Idempotent — prepend on an already-prepended module is a no-op.
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 |