Skip to content

fix: stop Postgres connection exhaustion (serialized sync views, duplicate Keycloak calls, blind health check) - #135

Merged
saqibmanan merged 4 commits into
devfrom
fix/backend-concurrency-db-exhaustion
Sep 3, 2026
Merged

fix: stop Postgres connection exhaustion (serialized sync views, duplicate Keycloak calls, blind health check)#135
saqibmanan merged 4 commits into
devfrom
fix/backend-concurrency-db-exhaustion

Conversation

@saqibmanan

Copy link
Copy Markdown
Contributor

Fixes #134. Three related changes, smallest blast radius first.

Root cause

Django runs sync views under ASGI via sync_to_async(thread_sensitive=True), which executes them on one shared thread per process. With a single uvicorn worker, exactly one sync request is processed at a time regardless of how many arrive.

Measured on dev — 12 concurrent POSTs to /api/auth/keycloak/login/:

4s, 7s, 10s, 14s, 17s, 20s, 23s, 26s, 29s, 32s, 36s

Near-perfect ~3s increments. They were queued behind each other, not running concurrently.

That queue is what exhausted Postgres. Every in-flight request holds a database connection while it waits its turn — 20 concurrent calls took the connection count from 6 to 26, one per request. Deep enough queues hit max_connections (100), and Postgres began refusing:

FATAL:  sorry, too many clients already

Which is why the same fault produced three different symptoms: requests past nginx's 60s timeout became 504s, refused connections became 500s, and the deploy failed because manage.py could not get a connection either.

/health/ stayed green through all of it.

1. Multiple workers (Dockerfile)

Workers are processes, so N workers give N concurrent sync requests. The work is I/O-bound on Keycloak, so this helps well past the box's 2 CPUs.

--limit-concurrency is the backstop: in-flight requests cap at workers x limit (60), under max_connections with headroom. Excess gets a fast 503 rather than queueing until the database runs out. Shedding load is recoverable; exhausting connections takes the deploy pipeline with it. Both env-tunable.

2. Three Keycloak round-trips per login → one

KeycloakLoginView introspected the token twice (once inside validate_token, again for roles/orgs), and validate_token also called userinfo. On this deployment the client lacks the scope for userinfo, so it returns 403 every time and falls through to a branch that rebuilds the same fields from the introspection it already had.

validate_token now accepts an introspection the caller already holds, and skips userinfo when introspection already carries sub plus an identifier. The userinfo path stays for deployments where introspection is sparse.

Beyond latency: the request holds a connection for its whole duration, so every round-trip removed is connection-hold time removed.

3. Health check that can actually fail

The DB probe ran SELECT 1 on the request's own connection — already established, so it answers however saturated the server is. It could not fail for the condition taking the service down.

Now also opens a fresh connection and closes it. The two failure modes are independent, and only the second catches exhaustion.

Verification

Measurements above are all from dev before the change. Post-deploy verification (concurrency, connection ceiling, health check behaviour) to follow on this PR.

Django runs sync views under ASGI via sync_to_async(thread_sensitive=True),
which executes them on one shared thread per process. With a single
uvicorn worker that means exactly one sync request is processed at a
time, however many arrive.

Measured on dev: 12 concurrent POSTs to /api/auth/keycloak/login/
returned at 4s, 7s, 10s, 14s, 17s, 20s, 23s, 26s, 29s, 32s and 36s -
near-perfect ~3s increments, each queued behind the last.

That queue is what exhausted Postgres. Every in-flight request holds a
connection while it waits its turn: 20 concurrent calls took the
connection count from 6 to 26, one per request. Deep enough queues
reached max_connections (100) and Postgres began refusing with "FATAL:
sorry, too many clients already". Requests that waited past nginx's 60s
proxy timeout became 504s, refused ones became 500s, and the deploy
pipeline failed too because manage.py could not get a connection either.

Workers are processes, so N workers give N concurrent sync requests and
the queue drains N times faster. The work is I/O-bound on Keycloak, so
this helps well past the box's 2 CPUs.

--limit-concurrency is the backstop: in-flight requests are capped at
workers x limit (60), which stays under max_connections with headroom
for other clients. Excess requests get a fast 503 rather than queueing
until the database runs out of slots. Shedding load is recoverable;
exhausting connections takes the deploy pipeline down with it.

Both are env-tunable so a box can be sized without a code change.
KeycloakLoginView introspected the token twice per request - once inside
validate_token and again for roles and organizations - and validate_token
then called userinfo as well.

On this deployment the client lacks the scope for userinfo, so that call
returns 403 every time and falls through to a branch that rebuilds the
same fields from the introspection response it already had. A guaranteed
failing network call on every login, roughly a third of the request's
latency.

validate_token now accepts an introspection the caller already has, and
skips userinfo when introspection already carries sub plus an
identifier. The userinfo path remains for deployments where introspection
is sparse, so behaviour is unchanged where it was actually doing work.

This matters beyond latency: the request holds a database connection for
its whole duration, so every round-trip removed is connection-hold time
removed, which is what ran the pool dry.
/health/ returned {"database": "healthy"} in 0.44s while Postgres was
refusing new connections with "FATAL: sorry, too many clients already",
the login endpoint was failing, and the deploy pipeline could not run
manage.py.

The check ran SELECT 1 on the request's own connection. That connection
is already established, so it keeps answering however saturated the
server is. The endpoint could not fail for the condition that was
actually taking the service down - so a green health check was worse
than no health check, and anything gating a deploy or paging on it stayed
green throughout.

Now also opens a fresh connection and closes it immediately. The two
failure modes are independent, and it is the second that catches
exhaustion.
Measured before setting a number rather than reaching for (2 x CPU) + 1.
The container uses 740MB resident with one worker and the host has about
2.4GB available, so four workers risked an OOM - a worse outage than the
connection exhaustion this is fixing.

Two workers land near 1.3GB and match the 2 CPUs. In-flight requests
then cap at 30, comfortably under max_connections (100) with room for
other clients.

Two workers alone would only double throughput, but removing two of the
three Keycloak round-trips per login cuts per-request time as well, and
the two compound.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Postgres connection exhaustion on dev: authenticated requests fail with 'too many clients already' (500) or time out (504)

1 participant