Custom Database Adapters and Connection Pools in Rails 7.2
We run a 12-server Puma fleet against a Postgres primary and two read replicas. For a long time our read/write splitting was manual: a sidecar script rewrote database.yml on deploy and restarted the app. The day a deploy cut over to a replica that hadn’t finished lag-catching, and every read-heavy page served 3-minute-old data to logged-in users, we stopped hand-rolling config and started treating connection management as engineering.
Rails 7.2 quietly fixed several things that make this kind of topology sane. The pool config moved into a first-class mapping with size, checkout_timeout, idle_timeout, and recycle, all surfaced on ActiveRecord::DatabaseConfigurations::HashConfig instead of being parsed ad hoc. query_cache: false in database.yml finally works as advertised, and config.active_record.permanent_connection_checkout gives you a lever for the “every request leaks a leased connection” antipattern. None of it is glamorous, but together it means you can express a replica topology in config rather than in middleware hacks.
The first real bug we fixed was a pool-sizing mismatch. Sixteen Puma threads per worker, three workers per box, twelve boxes — that’s 576 threads against a pool: 5 default. The replica read pool was exhausting in minutes and raising ActiveRecord::ConnectionTimeoutError, which we’d wrapped in a retry that made it worse. The math you actually want: pool = max_expected_concurrent_threads_per_process + 20% headroom. For us that’s 16 threads + a couple of Sidekiq jobs in the same process = pool: 22, which held 99.99th-percentile checkout at ~1ms with zero timeouts across a Black Friday peak.
What the recycle knob is really for: Postgres kills idle connections after a default 5-minute idle_in_transaction_session_timeout is long gone — the killer is tcp_keepalives and NAT timeouts on managed instances. A connection sitting idle for 10 minutes is frequently dead on arrival. Setting recycle: 900 (seconds) plus reaping_frequency: 30 costs nothing and eliminates the “first request after lunch timeout” class of bug. We also set checkout_timeout: 5 explicitly and, crucially, we set connect_timeout: 2 in the adapter so a network partition produces a fast error instead of a 30-second TCP hang.
Read routing belongs in the adapter layer, not in controllers. Our wrapper subclasses the Postgres adapter and tags every connection with its role. Reads go to a replica via connected_to(role: :reading), and anything the query linter flags as a write is refused unless it’s inside a preventing_writes: false block — a hard fail in development and a logged warning in production, because silently sending writes to a replica is how you get a “read-only” database error at 3am. The key detail we got wrong initially: connected_to is role-scoped per thread, and puma’s thread pool means you must not cache a connection across requests. Fetch per request, release per request.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# config/initializers/replica_routing.rb
class ReplicaAwareAdapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
def initialize(connection, logger, pool_config, **options)
super
@replica = pool_config.configuration_hash.fetch(:replica, false)
end
def write_query?(sql)
return true if @replica # never let a write sneak through a replica adapter
super
end
def replica?
@replica
end
end
# database.yml: primary uses the stock adapter, replicas use the wrapper
#
# production:
# primary:
# adapter: postgresql
# pool: 22
# checkout_timeout: 5
# connect_timeout: 2
# recycle: 900
# query_cache: false
# replica1:
# <<: *default
# adapter: replica_aware # maps to ReplicaAwareAdapter
# replica: true
# pool: 22
Register the adapter with ActiveRecord::ConnectionAdapters.register(:replica_aware, "replica_aware_adapter", "ReplicaAwareAdapter") and reference it by that name. This is a real pattern: the built-in adapters are just registered classes, and subclassing the Postgres adapter keeps 100% of the SQL generation, schema cache, and prepared-statement machinery while letting you override exactly the routing decision points.
The metrics that caught our worst incident were from the connection pool, not the database. Instrument ActiveRecord::Base.connection_pool.stat — wait_count, wait_time, size, active, idle — into Prometheus every 15 seconds. The day we saw wait_count climbing while active pinned at size, we knew a background job was holding connections across a long transaction (permanent_connection_checkout confirmed it). The fix was with_connection around that job instead of the implicit connection checkout, and our p95 API latency dropped 14% purely because replica pools stopped queueing.
One opinionated warning: do not build a custom network adapter from scratch. The postgresql adapter embeds years of type-casting, caching, and error-mapping behavior; a from-scratch adapter will leak prepared statements or mishandle NOTICE and you’ll debug it for a month. Subclass the real adapter and override the few hooks that matter. That’s what Rails 7.2’s cleaner HashConfig and pool APIs are for — making the 10% of connection behavior you need to customize cheap to reach without rewriting the other 90%.