Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

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.statwait_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%.

comments powered by Disqus