ADR-068: Live-Service Degradation Posture Under DB I/O Stall — Server-Yields-First Typed Failure, Route-Class Request Ceilings, a Phase-Aware DB Error Taxonomy, and Saturation Hysteresis
On this page
Status
Accepted (2026-08-23). Design unit of #1404 (epic &83’s last child, outside the &83 program
by the ratified boundary — ADR-067 §Open questions). Drafted FIRST per the
draft-first path; implemented on
feature/1404-degradation-posture. Plan:
DB-Degradation Posture (archived). The design survived one
internal adversarial review (11 findings) and the maintainer’s external stop-ship review
(26 findings); the censuses in the appendices are review outputs, not estimates. Ratified
direction (steer 2026-08-16, mode B): server-yields-first typed failure + honest health.
Merging this design promotes the C21 pool-contention characterization from report-only to
enforcing (contested-surfaces.toml, pool.craig-db.serving-acquire).
Context
The 2026-08-11 U4a battery (forensics on #1403) showed the fleet’s actual posture under
Postgres I/O stall: POST /v1/cases/persons hung the full 30 seconds until the client
timed out; outbox single-row UPDATEs ran 25.6s/17.2s. The arithmetic guarantees the hang:
-
The shared S2S client budget (
craig_common::build_shared_client) is 30s total / 5s connect. The sessionstatement_timeout(set once per connection by the pool’safter_connecthook) defaults to 30s. Equal budgets mean the client always eats the whole stall. -
No inbound request deadline exists anywhere:
apply_global_layershas no timeout layer andaxum::serveruns bare. The only server-side bounds are the fixed 5s pool-acquire cliff (craig_db::SERVING_ACQUIRE_TIMEOUT, #1160) and the session statement timeout. -
Error mapping is dishonest about all of it: SQLSTATE 57014 (statement kill) arrives as
sqlx::Error::Database→ the blanketApiError::Db→ a redacted RFC-9457 500 after up to 30s;sqlx::Error::PoolTimedOutmaps to a typed 503 ONLY on the tx-begin path (map_begin_error); everything else — connection loss, commit-phase failures — is a 500 regardless of retry safety or outcome certainty.
A degraded database must never convert into a caseworker-facing raw stall. The server gives up first, typed; already-durable event-pipeline work catches up; health is honest without inviting restart flapping. That is the ratified posture this ADR realizes.
Decision
D1 — Deadline model: the two-inequality rule, enforced by route-class ceilings
Requiring the sum of all inner worst cases to fit under one ceiling is neither achievable nor necessary — every inner budget (pool acquire, statement timeout, authz/rules eval, S2S hop) is itself a fail-fast path that terminates the request with its own typed error. Two inequalities are required, each with explicit margin, and only these:
-
Specific-beats-generic: each single inner budget + processing margin
<the route’s ceiling — when exactly one component is slow, the caller gets that component’s typed error, never the generic ceiling 504. -
Server-yields-first: ceiling + client connect + margin
<the client’s total budget — the server always answers (typed) before any first-party caller gives up.
The Standard chain:
| Bound | Value | Enforcement |
|---|---|---|
Pool acquire |
5s |
|
In-request S2S hop |
≤ 8s |
|
Per-tx statement budget |
10s |
|
Authz / rules eval budgets |
5s (existing defaults) |
unchanged; settings load refuses any configured value ≥ 19s |
Request ceiling (Standard) |
20s |
the new ceiling middleware (D1a) |
S2S client |
30s total / 5s connect |
|
Checks: 5+ε<20, 8+ε<20, 10+ε<20, 20+5+5<30. Where both sides are consts the order is
pinned by const _: assertions; where one side is a knob it is pinned by settings-load
refusal: db_statement_timeout_ms < 10_000 is refused (SET LOCAL would otherwise silently
raise an operator-tightened bound — the floor makes the joint decision explicit), and
authz_eval_timeout_ms / the rules eval budget are refused at ≥ 19s.
SET LOCAL placement — as-built mechanism: the wrapper cannot inject between the claim and
the domain statements (both live inside the handler closure), so the post-claim budget hook
lives INSIDE claim_first itself — it applies the SET LOCAL as its final act, after the
claim resolves. ADR-062’s claim-first contract is untouched, and the claim INSERT keeps the
session-default (30s) lock-wait allowance its duplicate-arbitration math requires. In
execute_within_tx (unclaimed): immediately after begin. The explicit opt-out for the
censused Batch routes is claim_first_unbudgeted (full ADR-062 claim semantics, only
the SET LOCAL omitted — the route rides the session default under its Batch ceiling).
Census: ncands generate (10k-row INSERT…UNNEST batches), subsidy sweep preview/execute.
A new claim_first_unbudgeted call site requires a justifying comment naming the >10s
statement. No unclaimed _unbudgeted variant exists — the census has no unclaimed batch
funnel user (unused API is not built).
One recorded interaction: the 8s hop budget sits BELOW the 19s eval-budget caps. Under
default configs (5s eval) the callee’s typed 503 always arrives first; a deployment that
raises an eval budget past ~7s trades error specificity for the same bounded outcome — the
caller’s typed hop 503 fires at 8s while the callee finishes uselessly. Bounded and typed
either way; specificity degrades gracefully. The reporting→cases whole-FFY export hop is
NOT an OLTP hop — it targets a Batch-class route and carries BATCH_CALLER_BUDGET (the
caller-budget pairing), not the 8s hop budget.
Session statement_timeout stays 30s by default — it is the worker-plane authority
(ADR-062 §H derives the inbox winner-hold math from it). Standard routes that begin()
directly on the pool (census: financial adjustments-resolution, placement kinship, cases
report-create, among others) ride ceiling-only until migrated to the funnels; the funnels
are the new-code convention.
Depth-2 honesty (per-hop margins instead of propagation). Absolute deadline propagation
is rejected while the synchronous S2S graph has depth ≤ 2: it is header + re-budgeting
machinery on every hop, and it still cannot validate external budgets. Instead every
synchronous in-request S2S call carries a per-call budget ≤ 8s (the financial
CASES_S2S_DEADLINE precedent), asserted < ceiling. The accepted residue: a downstream
service may finish work after its caller has already yielded — inherent to non-propagation;
effects are claims-idempotent (ADR-062). Revisit trigger: the graph deepens past 2.
D1a — The ceiling middleware and route classes
The fleet has a real population of legitimately-long synchronous routes, so the ceiling is
class-based — ONE global middleware selecting the class per request; never an inner
route_layer (an inner layer cannot extend an outer timeout):
| Class | Ceiling | Members | Caller budget pairing |
|---|---|---|---|
Standard |
20s |
everything not listed below |
shared 30s client (inequality 2) |
Extended |
300s |
upload nests (intake signed 30 MiB, cases attachments + contact
attachments + court-order documents, placement home documents, exchange ICPC attachments,
intake attachment proxies) and buffered download/export GETs ( |
the specific first-party call sites get per-request |
Batch |
600s |
the synchronous admin/batch/federal engines: subsidy generate, import finalize, sweep preview + execute, ncands + afcars generate/export, rules promote-preview canary, admin-replay, security archive + purge |
CLI/xtask call sites get
per-request |
A class ceiling without its caller-budget leg is dead policy — the server would keep working for minutes after every first-party caller abandoned at 30s. The two land together.
Mechanics:
-
The table is data on
ServerOptions:(Method, route template) → Class, matched at runtime viaMatchedPath(thehttp_metrics_middlewareprecedent) — never raw path prefixes (dynamic IDs and shared paths across methods make prefixes ambiguous). A syn-based coverage test (thevalidate_authz_coverageidiom) fails on any table entry absent from the service’s route registration, so the table cannot go stale silently. -
Position: innermost of the global layers — inside
http_metrics_middleware(which has no drop guard; an outer timeout would leak the in-flight gauge and lose the 504 from the duration histogram) and insiderequest_id_layer(the problem document carries the correlation id). -
Implementation:
axum::middleware::from_fn+tokio::time::timeout(next.run())— no new dependencies, no tower feature enables, full control of the RFC-9457 body. The bound is cooperative and covers request processing up to the response head: streamed response bodies are not covered (today’s download handlers buffer, hence their Extended class), and a non-yielding CPU section cannot be preempted (none exists on these paths; rules and authz evals carry their own budgets and the ADR-024 wedge deadman). -
Prod values are consts; a test-injectable seam (the
HTTP_DRAIN_DEADLINE/serve_with_listenerprecedent) lets fault legs run at ~1s. -
craig-intake does not use
ApiServer: the middleware ships as a reusablepub fnand intake installs it in its own stack explicitly. As-built: the whole ceiling module (craig_common::ceiling) lives in craig-common, not craig-api — intake is deliberately craig-api-free (no sqlx on the stateless edge), and craig-api re-exports it; only the pool-acquire leg of inequality 1 is asserted in craig-api (craig-common cannot depend on craig-db). The xtask import tool mirrorsBATCH_CALLER_BUDGETwith a named comment (xtask is deliberately craig-common-free; tool-time OUT-class per ADR-067). -
craig-web (BFF) gets NO inbound ceiling: its handlers legitimately fan out multiple sequential 30s upstream calls; its bound is per-upstream-call. Recorded, not deferred.
-
External (non-first-party) API consumers cannot be validated at config load. The contract is documented instead: responses are bounded at 20s/300s/600s by class; set client budgets above your class.
D1b — Cancellation safety (census, not hope)
The ceiling’s cut is mechanically the same event as a client disconnect, which hyper already inflicts on every handler when the peer goes away — the ceiling makes an existing hazard deterministic; it does not create the class. The census:
| Sequence class | Outcome when cut |
|---|---|
Funnel tx routes (all convert-class routes, ADR-062) |
drop = rollback; no partial commit; replay-same-id answers from the claim |
Direct- |
drop = rollback (same mechanics, no claim replay) |
Single-statement autocommit writes |
statement-atomic |
Multi-system non-tx sequences (DB row THEN blob delete: contacts, contact/report attachments, court orders) |
a cut between the two leaves an orphan blob — the standing convergence owner is the orphan-blob janitor issue #1274 (pre-existing exposure via client disconnect; unchanged by this ADR) |
Commit-phase cut |
outcome unknown — the D2 |
Batch routes cut by deploy ( |
abandoned mid-flight — acceptable because the money engines are crash-resumable BY DESIGN (claims, staged finalize, durable sweep records: ADR-055/056/057); their resumability is their own ADRs' contract, restated here per route in the implementation |
One sharp edge stated honestly: sqlx 0.8 returns a dropped pooled connection through an
unbounded async ping (the connection waits for Postgres ReadyForQuery). Under a wire
blackhole a pool slot can therefore wedge past statement_timeout, bounded only by the TCP
stack and the pool’s 30-minute max_lifetime. The acquire cliff sheds load meanwhile
(typed 503s — the designed degraded capacity). TCP keepalives via PgConnectOptions are
enabled if the API offers them (implementation-verified). Health probes avoid this trap
entirely by never using the serving pool (D4).
D2 — Phase-aware DB error taxonomy
One context-free mapping cannot be honest: retry safety depends on the request phase, not
just the sqlx variant. The classifier is a pure function in craig-common (it must not
depend on craig-db — dependency direction), consumed through phase-specific ApiError
constructors; the funnels supply the phase.
| Phase (constructor) | Condition | Status / problem type | Retry signal |
|---|---|---|---|
acquire — |
|
503 |
|
statement — |
SQLSTATE 57014 |
504 |
none on the wire; in-tx the work is rolled back |
statement — |
connection family: |
503 |
none |
commit — |
the connection died during COMMIT (connection family) — the server’s verdict never arrived |
503 |
none — no retry invitation on an unknown outcome. A server-REPORTED commit failure (serialization failure, deferred constraint, statement cancel) means the transaction aborted — outcome KNOWN — and rides the ordinary classifier mapping instead. |
any |
everything else |
500 |
— |
ceiling middleware |
elapsed |
504 |
none |
-
The blanket
From<sqlx::Error>impl becomes manual and applies the conservative statement-phase mapping — the safety net for unswept sites. TheDbfield keeps#[source]. -
Full variant decision table (no variant undecided):
PoolTimedOut/PoolClosed→ acquire-class 503 ·Database(code)per the SQLSTATE rows above, other codes → 500 ·Io/Tls→db-connection-lost503 ·Protocol→ 500 ·RowNotFound,TypeNotFound,ColumnIndexOutOfBounds,ColumnNotFound,ColumnDecode,Decode,Encode,Configuration,Migrate,WorkerCrashed, others → 500 (programming/config errors, not degradation). -
No retry-safety promises ride the wire details: the BFF re-render mints a fresh
client_request_idtoday, and the classifier cannot know route idempotency. Per-class retry semantics are documented HERE; the SDKs (which retry same-id per ADR-062) cite this section.Retry-Afteris variant-specific — the rules eval-timeout design explicitly declined it (no truthful estimate) and that decision stands. -
map_begin_erroris deleted — subsumed byfrom_db_acquire; keeping it would be pre-1.0 compat residue. Funnel arms: begin → acquire-phase; domain statements → statement-phase; commit → commit-phase (a connection loss during COMMIT is exactly the outcome-unknown case, today mapped to a plain 500). -
Coverage is enforced, not promised: a new xtask lint (allowlist + monotonic shrink, the quality-budgets idiom) bans direct
ApiError::Db(/Self::Db(construction and sqlx-typedmap_err(ApiError::internal)outside craig-common. The implementation sweep works the census in Appendix B — Classifier sweep census (U5; the lint keeps it closed); the lint keeps it closed. -
Untouched, verified disjoint: craig-security’s dead-letter
classify_sqlx(MQ plane) and craig-mq’s inbox savepoint 57014 handling — neither converts intoApiError. -
OpenAPI: utoipa cannot see middleware. A shared
Modifyadds the 503/504 problem responses to every operation in every service’sApiDoc; the committed API pages regenerate in the same commits (#1022 gate).
D2a — Edge pass-through
-
BFF (craig-web): upstream 503 → 503, 504 → 504; all other upstream 5xx stay 502 (correct gateway semantics — 502 means "the upstream failed", 503/504 alone carry retry meaning).
UpstreamError::Statuscarries no headers, so the BFF does NOT forwardRetry-After(it renders HTML to humans; recorded). Mutation handlers keep redirect + flash; the flash copy becomes class-appropriate ("busy — try again shortly" vs "timed out"). `get_bytes’ 5xx-as-data contract is unchanged (recorded). -
craig-intake (public portal): the sink stops flattening upstream 503/504 into a redacted 500 — the public edge keeps the typed posture.
-
craig-cases: the rules-upstream guard and the matching-error guard (both have 503-only arms today) extend to 504.
All of this is pre-1.0 breaking and rides the CHANGELOG.
D3 — The honest durability claim
-
Work that was already staged (outbox rows) before or during the stall drains when the DB recovers: the OutboxWorker’s warn-and-retry loop plus lease-healed claims. Delivery is at-least-once with consumer dedup — the outbox’s own contract; this ADR does not upgrade it to exactly-once and no test may claim otherwise.
-
NEW work while the DB is down cannot become durable, and therefore fails typed (503 at the acquire cliff / 504 at the statement kill). There is no queueing fig leaf.
-
Bounds: outbox backlog is bounded by DB disk (the DB-up/broker-down case); the DLX tier (ADR-059) is a consumer-side mechanism, not the pending-outbox path; broker-down ≠ DB-down and keeps its existing posture (ADR-003/ADR-059).
D4 — Health: dedicated-connection probes + saturation hysteresis; readiness unchanged
-
Probes never touch the serving pool. Reachability = a fresh dedicated
PgConnectionper probe, 2s overall bound including connect (the compose healthcheck window is 3s — the probe must answer inside it). Dropping a non-pooled connection is a socket close — none of the pooled return-ping hazard. This also removes today’s amplification: the unbounded/healthzSELECT 1through the pool wedges a connection per hit under a wire stall. -
/readyzsemantics unchanged: reachable ⇒ ready. Pool saturation NEVER gates readiness — flipping readiness on latency invites the restart/eviction flapping the ratified direction forbids (ADR-061 alignment). No contradiction with the 2s bound: pool wait cannot affect a dedicated connection. -
DbSaturationhysteresis, fed by the existing 15s pool-probe loop: a probe is BAD when its acquire wait exceeds 2.5s (half the cliff) or times out; degraded after 2 consecutive BAD (~30s of signal); clears after 4 consecutive OK (~60s). Exposed as the newdb_pool_degradedgauge (0/1) and the/healthzdatabase valueok|degraded|error. -
The probe task decouples from telemetry: today it spawns only when metric registration succeeds and non-otel builds skip it entirely. Functional health cannot depend on observability being compiled in — the probe always runs and feeds health state; metric emission is the optional part. Startup state is
ok; the probe task is supervisor-watched (Observed). -
/livezuntouched. The deployment guide gains thedb_pool_degradedalert row (the worker-staleness convention’s sibling).
D5 — Load-shed / circuit-break: the pool is the admission control, stated precisely
Judgment-protocols comparison (all in-stack options considered):
| Option | Verdict |
|---|---|
Pool acquire cliff + request ceiling (CHOSEN) |
Admission control lives at the
constrained resource: the fixed 5s cliff sheds to a typed 503 exactly when DB capacity is
exhausted, with no proxy metric to mis-tune; the pool also caps the reconnect herd
against a recovering database at |
tower |
A second admission bound ABOVE the pool that must
be sized against |
Circuit-breaker crate ( |
A new dependency and per-route trip state duplicating what the acquire cliff provides at the resource itself; its half-open probing is already served by the pool’s bounded reconnects. Rejected. |
Stated precisely: the pool is a connection-concurrency cap with a bounded (5s)
per-request wait — it is NOT a circuit breaker. It has no open state, no cooldown, no
pending-waiter cap: during a full outage every DB-bound request parks one acquire future
for ≤ 5s and then fails typed, so at arrival rate R roughly R×5 futures are parked (1,000
rps ⇒ 5,000 parked futures — well inside tokio’s envelope; the arithmetic is the recorded
justification for not adding a waiter cap). A hysteresis-driven fast-fail (shed at 0s while
db_pool_degraded is set) is explicitly DEFERRED, with its trigger recorded: operators
needing sub-5s shed under sustained outage. The per-IP rate limiter (429) and the
authz/rules eval budgets keep their existing roles.
Consequences
-
Caseworkers and S2S callers see typed, bounded failures — worst-case time-to-error on a Standard route drops from 30s (client transport error) to ≤ 20s (server-typed 504), and for the common single-slow-component cases to 5s/8s/10s with the specific diagnosis.
-
Pre-1.0 breaking changes (CHANGELOG’d): 57014 becomes 504 (was 500-after-30s);
PoolTimedOutbecomes 503 fleet-wide (was begin-path-only); commit-phase failures become 503db-outcome-unknown(was 500); BFF/intake/cases edges pass 503/504 through;db_statement_timeout_msgains a 10s floor. -
Existing tests pinning 500-on-DB-error or BFF-502 are updated as part of the same change — a legitimate oracle change for a deliberate semantic change, called out per commit (testing-discipline).
-
The C21 pool-contention oracle promotes to load-robust shape assertions (wins < the acquire bound; timeouts ≥ ~the bound — never exact milliseconds, per the harness’s own "load shifts latencies, not counts" note).
-
Deferred, each as a filed issue: backgrounding the Batch engines (202 + poll — removes the 600s class’s reason to exist), streaming object-store downloads (removes most of the Extended-download class), hysteresis fast-fail (trigger recorded above).
Appendix A — In-request synchronous S2S hop census (per-hop budget ≤ 8s)
| Caller → callee | Site | Budget |
|---|---|---|
cases → rules (investigation safety assessment) |
|
8s (new) |
cases → rules (screening policy) |
|
8s (new) |
cases → rules (matching ruleset metadata) |
|
8s (new) |
reporting → cases (detail hydration) |
|
8s (new) |
financial → cases (person DOB, MQ path) |
|
8s (exists) |
exchange → cases (custody) |
|
5s (exists) |
Appendix B — Classifier sweep census (U5; the lint keeps it closed)
| Site | Today |
|---|---|
financial subsidy maps ×7 ( |
direct |
the funnels' own begin/commit arms ( |
commit errors → |
|
→ |
rules store map ( |
→ |
reporting store maps (~30 sites incl. |
→ |
composition |
own |
security archive ( |
→ |
exchange store maps |
→ |
service error enums: |
Db arms flattened |
Alternatives considered
-
Absolute deadline propagation (header + per-statement re-budgeting): rejected at graph depth ≤ 2 — see D1. Revisit trigger recorded.
-
Lowering the session
statement_timeoutdefault so the typed 57014 always beats the ceiling: rejected — the session default is the worker-plane authority and ADR-062’s inbox lock-wait math derives from it; the funnel-scopedSET LOCALdelivers the same ordering on exactly the HTTP tx plane with zero worker blast radius. -
tower timeout/load-shed layers and breaker crates: see D5.
-
One flat ceiling with per-route exemptions: rejected — the review census showed a real population of legitimate 25s+ routes; exemptions-by-default invert into the Extended/ Batch classes with caller-budget pairing instead.