fix: stop Postgres connection exhaustion (serialized sync views, duplicate Keycloak calls, blind health check) - #135
Merged
Conversation
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.
This was referenced Sep 3, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/: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: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.pycould 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-concurrencyis the backstop: in-flight requests cap atworkers x limit(60), undermax_connectionswith 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
KeycloakLoginViewintrospected the token twice (once insidevalidate_token, again for roles/orgs), andvalidate_tokenalso calleduserinfo. On this deployment the client lacks the scope foruserinfo, so it returns 403 every time and falls through to a branch that rebuilds the same fields from the introspection it already had.validate_tokennow accepts an introspection the caller already holds, and skipsuserinfowhen introspection already carriessubplus an identifier. Theuserinfopath 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 1on 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.