Raxx · internal docs

internal · gated

RCA — console.raxx.app /billing H12 (Postgres connection-pool exhaustion)

Incident ID: 2026-07-24-console-billing-h12-db-pool-exhaustion Date: 2026-07-24 Severity: SEV-2 (user-facing 503 on /billing; short-lived, contained by manual mitigation) Duration: immediate mitigation (WEB_CONCURRENCY 4→2) applied out-of-band by operator; durable code fix landed same day Blast radius: console.raxx.app (Heroku app raxx-console-prod), specifically the /billing blueprint and any other route served by a worker that was mid-request when its DB connection request could not be satisfied Author: feature-developer

Summary

raxx-console-prod runs Gunicorn with WEB_CONCURRENCY=4 worker processes. console/app/__init__.py set no SQLALCHEMY_ENGINE_OPTIONS, so SQLAlchemy's un-tuned defaults applied: pool_size=5 + max_overflow=10, i.e. up to 15 possible connections per worker. Each Gunicorn worker owns its own SQLAlchemy engine/pool (they do not share a pool across processes), so the dyno as a whole could demand up to 4 workers x 15 = 60 connections against heroku-postgresql:essential-0, which hard-caps total connections at 20 -- 3x the plan's ceiling.

A polling/traffic burst pushed enough concurrent DB-bound requests that the pool(s) could not satisfy new connection checkouts. The affected worker process(es) blocked past Gunicorn's 30s WORKER TIMEOUT and were SIGKILL'd mid-request, producing a Heroku router H12 (request timeout) for at least one in-flight /billing request.

Immediate mitigation (operator, out-of-band): WEB_CONCURRENCY dropped 4->2 on raxx-console-prod, roughly halving the worst-case connection demand (4 workers x 15 = 60 -> 2 workers x 15 = 30). This reduces exposure but does not eliminate it -- 30 still exceeds the 20-connection cap, and any future bump back to WEB_CONCURRENCY=4 (or higher, e.g. a Heroku dyno-type change) would silently reopen the same failure mode. It is a mitigation, not a fix.

Durable fix (this PR): console/app/__init__.py now sets explicit, bounded SQLALCHEMY_ENGINE_OPTIONS -- pool_size=2 + max_overflow=3 (5 connections/worker), pool_pre_ping=True (drop dead connections instead of erroring on use), and pool_recycle=300 (recycle idle connections before a Postgres-side idle timeout can silently kill them). At WEB_CONCURRENCY=4 (the pre-mitigation setting), worst case is 4 x 5 = 20 -- exactly at the essential-0 cap, never over it, regardless of how WEB_CONCURRENCY is set in the future. The pool_size/max_overflow values are only applied to the postgres backend (env-tunable via DB_POOL_SIZE / DB_MAX_OVERFLOW) -- sqlite (local/dev/CI) is unaffected, since sqlite's default pool class for :memory: URLs does not accept those kwargs.

This is the same failure class as a prior runbook reference to docs/incidents/2026-05-07-console-prod-worker-thrash.md -- that RCA document was referenced by docs/ops/runbooks/console-prod-h12-alerts.md (as the incident that motivated H12/WORKER TIMEOUT Slack alerting, #1345) but was never actually written; no such file exists in docs/incidents/. Given the identical symptom signature (H12 + WORKER TIMEOUT on raxx-console-prod), it is likely the 2026-05-07 event was the same unbounded-pool failure mode recurring, caught that time by the #1345 alerting before this permanent fix was made. The dead reference has been corrected in the runbook update accompanying this PR.

Timeline (all times UTC)

Impact

What went well

What didn't go well

Root cause analysis

Detection

Resolution

Action items

# Action Owner Due Issue
1 Bound SQLALCHEMY_ENGINE_OPTIONS (pool_size/max_overflow/pool_pre_ping/pool_recycle), env-tunable feature-developer 2026-07-24 done, this PR
2 Fix dead RCA reference + add root-cause section to docs/ops/runbooks/console-prod-h12-alerts.md feature-developer 2026-07-24 done, this PR
3 Add a Postgres connection-count alert (leading indicator, independent of the H12/WORKER TIMEOUT lagging symptoms) to the monitoring stack -- page before the pool is actually exhausted, not after requests start failing sre-agent TBD to file
4 Re-evaluate whether WEB_CONCURRENCY can be safely raised back toward 4 now that the pool is bounded (worst case 20/20 at WEB_CONCURRENCY=4); if headroom for psql/migrations/monitoring is wanted, consider essential-1 (up to 40 vs 20 connections) or keep WEB_CONCURRENCY=2 for extra margin operator TBD to file
5 Audit other Console/Raptor Flask apps for the same unbounded-pool pattern (any Flask-SQLAlchemy app fronted by multiple Gunicorn workers against a connection-capped Postgres plan) sre-agent TBD to file

References