Deriver Polling Resilience: Remove Connection-Acquisition Retry + Add Poll Jitter
Status: Draft | Owner: vineeth | Last updated: 2026-06-02
Scope: repos/honcho — deriver (src/deriver/queue_manager.py) + DB session layer (src/db.py, src/config.py)
1. Problem
On 2026-06-02 the managed (Groudon) shared Postgres suffered a ~4.5-hour connection-saturation outage (Supavisor client connections 300 → ~9,000, EMAXCONN; pooler avalanche onset ~02:10 ET). The pg_cron connection-state samplers (public.mon_*, see repo-root lock_monitoring.sql) captured the onset and conclusively identified the mechanism. It is not lock contention:
- Synchronized poll herd. Server-side
activebackends sat at ~15–25, then slammed to 251 of ~368 (the pool ceiling) in a single second (01:59:26), repeating in bursts. Every burst was dominated by the deriver queue-claim query (SELECT … work_unit_key FROM <schema>.queue WHERE NOT processed …) firing across many distinct tenant schemas at the same instant. - Not locks.
advisorywaits were ~0 for the entire onset;pg_lockspeaked at 2,272 vs a 32,000 ceiling (max_locks_per_transaction=64 × ~500). Both lock hypotheses are ruled out for this event. - Sustained, not transient. The collapse held for ~4 hours and only cleared when overnight traffic fell / after a full DB restart — it did not self-recover.
There are ~11,543 tenant instances (367 on v3.0.8, 11,176 on v3.0.7), each running its own deriver. At steady state, ~11.5k derivers polling ~every 30s averages ~65 concurrent claim queries — well within the 368-backend pool. The failure is variance, not throughput: the same work bunched into one second instead of spread across thirty.
2. Root Cause — two cooperating flaws
Trigger — no jitter in the poll schedule. _advance_poll_interval (src/deriver/queue_manager.py:~395) advances deterministically (min(interval × POLLING_BACKOFF_MULTIPLIER, POLLING_SLEEP_MAX_INTERVAL_SECONDS)), and the base-interval sleeps are a fixed POLLING_SLEEP_INTERVAL_SECONDS. When a cohort of derivers starts together — a rollout batch, or every instance after the DB restart used to recover the prior event — their poll loops phase-align and stay aligned, producing the synchronized herd. The recovery action (full DB restart) re-synchronized the entire fleet and seeded the next collapse.
Sustain — connection-acquisition retry on the deriver path. Each poll acquires a connection via HonchoAsyncSession → acquire_connection_with_retry (src/db.py:110), which retries on OperationalError (Supavisor EMAXCONN) with jittered exponential backoff up to CONNECTION_RETRY_MAX_DELAY_SECONDS = 10s (src/config.py:639) before giving up. The poll loop’s own “back off a saturated DB” handler (the except → _advance_poll_interval, queue_manager.py:~452) only runs after that 10s. So during saturation each poll cycle:
- fires, can’t get a slot,
- holds a pending client connection and re-knocks for up to 10s,
- only then lets the poll loop back off.
With thousands of derivers doing this, the retry manufactures the multi-thousand client-connection wall and prevents the pool from draining: as fast as a backend frees, a waiting retrier grabs it. Retrying is correct for a transient blip; it is exactly wrong for sustained saturation, where the system must shed load, not persist demand. This is what converted a ~30-second herd into a 4-hour outage.
Clean separation of the two graphs: the active-backend spike = the unjittered poll herd; the client-connection wall + non-drainage = the connection retry.
3. Goals / Non-Goals
Goals
- G1: Desynchronize the deriver fleet so a mass restart (rollout or DB restart) cannot phase-align polls into a herd.
- G2: Make connection acquisition fail fast everywhere — a single attempt, no server-side retry — so a saturated pooler is never hammered by its own clients holding connections open to re-knock.
- G3: On the API path, surface acquisition failure as a fast error (
503recommended) and let retry happen in the client/SDK layer, where it can back off without pinning a server-side connection. On the deriver path, the existing jittered poll backoff absorbs it. - G4: No schema migration — ship as a migration-less patch that the Groudon auto-upgrader rolls automatically.
Non-Goals
- NG1: Queue partial-index optimization (cuts the 170ms claim cost) — separate ticket; complementary.
- NG2: A full API-path circuit breaker / load-shedding layer (concurrency caps, half-open probing) — separate ticket. This spec does the minimal correct thing (fail fast + a clean
503); a richer breaker is an enhancement on top. - NG3:
pg_stat_statements.max/ schema-qualified-SQL normalization — separate. - NG4: The advisory-lock removal (
message_seq_counter, DEV-1852) — confirmed not on this incident path; unchanged here.
4. Change 1 — Jitter the deriver polling
Two layers; the startup jitter is the high-value one for the herd.
4a. Startup jitter (most important). Before the first poll, sleep a uniform-random delay in [0, POLLING_SLEEP_MAX_INTERVAL_SECONDS]. A mass restart then spreads first-polls across the whole window instead of all firing at t=0.
# queue_manager.py, before entering polling_loop()
import random
await asyncio.sleep(random.uniform(0.0, settings.DERIVER.POLLING_SLEEP_MAX_INTERVAL_SECONDS))4b. Per-cycle jitter. Jitter every sleep so loops that drift toward alignment are continuously re-scattered. Keep the underlying backoff schedule deterministic (so the 1→2→4→…→30 progression is unchanged); jitter only the returned sleep.
def _advance_poll_interval(self) -> float:
interval = self._current_poll_interval
if settings.DERIVER.POLLING_BACKOFF_ENABLED:
self._current_poll_interval = min(
self._current_poll_interval * settings.DERIVER.POLLING_BACKOFF_MULTIPLIER,
settings.DERIVER.POLLING_SLEEP_MAX_INTERVAL_SECONDS,
)
return self._jitter(interval)
def _jitter(self, seconds: float) -> float:
j = settings.DERIVER.POLLING_JITTER_RATIO # e.g. 0.5 → sleep in [0.5x, 1.5x]
return seconds * random.uniform(1.0 - j, 1.0 + j)Apply _jitter(...) to the fixed base-interval sleeps as well (the semaphore.locked() branch and the empty-queue branch in polling_loop, queue_manager.py:~422/~445), so the “fast pickup” 1s path doesn’t re-synchronize the fleet either.
New config (src/config.py, DeriverSettings):
POLLING_JITTER_RATIO: Annotated[float, Field(default=0.5, ge=0.0, le=1.0)] = 0.5
# Startup jitter reuses POLLING_SLEEP_MAX_INTERVAL_SECONDS as its upper bound.5. Change 2 — Remove the connection-acquisition retry entirely (fail fast everywhere)
Rip out the retry so every connection checkout is a single attempt. This is one global change — not a per-process .env toggle (which is operationally painful to apply across ~11.5k deriver machines and easy to get wrong). The deriver and the API both fail fast; they differ only in how the failure is surfaced (the deriver loop backs off; the API returns a fast error).
src/db.py — delete the retry:
- Reduce
_ensure_acquiredto a singleawait self.connection()(keep the existing Sentry span + metric for visibility), set_honcho_acquired, and let anyOperationalErrorpropagate.acquire_connection_with_retrycollapses to that single checkout (or is inlined and removed). - Delete the
tenacityimports (AsyncRetrying,retry_if_exception_type,stop_after_delay,wait_exponential_jitter,src/db.py:16),RETRYABLE_DB_CONNECTION_ERRORS(:68), and the retry loop (:132–162). Keep a simplified_record_acquisition_outcome("ok"|"failed")if you want the metric. - Bounded connect timeout so the single attempt fails fast instead of hanging when the pooler is saturated:
# src/db.py connect_args = {"prepare_threshold": None, "connect_timeout": 2} # psycopg seconds
src/config.py — remove the now-dead knobs: CONNECTION_RETRY_ENABLED, CONNECTION_RETRY_MAX_DELAY_SECONDS, CONNECTION_RETRY_BACKOFF_INITIAL_SECONDS, CONNECTION_RETRY_BACKOFF_MAX_SECONDS (:638–646), and the _validate_retry_budget_vs_pool_timeout validator. (POOL_TIMEOUT stays but only applies to a real QueuePool; the managed deployment runs NullPool.)
API path — fast failure, correct status. With the retry gone, an OperationalError propagates to the existing global handler (src/main.py:234), which already returns 500 fast — so the “send a 500 quickly rather than hammer the DB” behavior is the default the moment the retry is removed; no handler change is strictly required. Recommended refinement: add a dedicated handler mapping pooler saturation to 503 Service Unavailable with Retry-After, so SDK clients treat it as transient and back off client-side (where retry belongs) rather than as a hard 500:
from sqlalchemy.exc import OperationalError
@app.exception_handler(OperationalError)
async def db_unavailable_handler(_request: Request, exc: OperationalError):
return JSONResponse(status_code=503, headers={"Retry-After": "1"},
content={"detail": "database temporarily unavailable"})Deriver path — unchanged, just faster. The poll loop’s existing except (queue_manager.py:~452) catches the now-immediate OperationalError and backs off via the jittered _advance_poll_interval from Change 1. No deriver-specific code or env needed.
6. Why failing fast is safe — both paths
- Deriver: the queue is the durability boundary. A poll that can’t acquire a connection claims nothing; a task that fails mid-flight leaves its
QueueItemunprocessed. The work stays enqueued and is retried on a later (jittered) cycle — no data loss, at most a brief delay. - API: during sustained saturation the retry does not succeed — it only defers the same error by up to 10s while holding a client connection and feeding the collapse. A fast
503/500returns the connection immediately so the pool can drain, and lets the caller’s SDK retry with its own backoff (client-side retry doesn’t pin a server connection). A fast error during a genuine outage is strictly better than a slow one that extends the outage. Retrying is the right response to a transient blip and the wrong response to sustained saturation — and the server can’t distinguish them at acquisition time, so it shouldn’t try. Server-side retry is also redundant with the Honcho SDKs’ own client-side retry/backoff.
7. Rollout
- Migration-less, code/config only → qualifies for the Groudon auto-upgrader (no
has_migrationgate). Ships as a normal patch. - Operational guardrails for this rollout specifically (the rollout is itself a herd source):
- Stagger upgrade batches (already 20 / 5 min) and rely on the new startup jitter to desync restarted instances.
- Do not recover by full DB restart. A full restart re-synchronizes the entire fleet and re-arms the herd. Prefer draining / pausing new client acceptance, or restart in staggered cohorts. Capture this in the Groudon incident runbook.
- Verify post-deploy via the
mon_*tables: the per-secondactiveseries should no longer show synchronized 200+ spikes; client connections should track average load, not staircase.
8. Testing
- Jitter desync (unit): N loops started simultaneously produce first-poll times spread across
[0, MAX], not clustered;_advance_poll_intervalreturns values within[1−J, 1+J]×schedulewhile the underlying schedule still progresses1→2→…→30. - Fail-fast (integration): with a pooler returning
EMAXCONN, a connection checkout raises within ~connect_timeout, not ~10s. Deriver: the poll loop catches it and backs off. API: the request returns a fast503(or500) — assert sub-second latency, not multi-second. - Single attempt: under a failing pooler, exactly one checkout is attempted per poll/request — no repeated connection attempts (the regression guard against re-introducing retry).
- No work loss: items unclaimed during a simulated saturation window are processed on a later cycle once capacity returns.
- Fleet simulation (optional): many simulated derivers + a capped pool — confirm jitter flattens the concurrent-claim distribution vs. the deterministic baseline.
9. Risks & Alternatives
- Risk: startup jitter delays first work pickup by up to 30s after deploy. Acceptable for a background worker; bound it lower (e.g.
[0, base×k]) if pickup latency matters. - Risk: removing API retry means a transient single-connection blip now surfaces as a client-visible
503/500instead of being silently ridden out. Accepted — the Honcho SDKs retry with backoff client-side, and server-side retry under saturation is net-harmful. The blast radius of a brief blip (a few retryable 503s) is far smaller than a multi-hour collapse. - Alternative — keep a short (e.g. 1s) bounded retry. Rejected: any server-side retry under sustained saturation re-feeds the collapse, and the 10s budget already proved harmful in production. “Single attempt” is the clean, defensible invariant; tuning a budget invites the same failure mode back.
- Alternative — global in-flight acquisition cap / circuit breaker. Stronger systemic guard but cross-cutting; deferred to the NG2 follow-up.
10. Out of Scope (tracked separately)
- Queue partial index on unprocessed representation work units (claim-query cost).
- API-path circuit breaker / load-shedding.
pg_stat_statements.maxincrease + schema-qualified-SQL normalization.message_seq_counteradvisory-lock removal (DEV-1852) — not on this incident path.