ADR-061: Worker Supervision & Readiness Gating
On this page
Status
Accepted on 2026-07-31. Acceptance = the program owner’s external design review approving the 6-round plan at its gate (the review log lives in the plan page); implementation is tracked there — U1–U6 ship after this ADR merges, docs-first.
Context
Fleet-wide (verified, #1186/W3c+W3d), every long-lived background task — the outbox drainer,
MQ subscriber supervisors, the exchange send worker, sweeps, schedulers, the authz cache
tasks — is spawned with its JoinHandle discarded or retained-unobserved. No supervisor
exists; a panicked worker is silently dropped; nothing restarts or even records it.
Meanwhile /readyz was believed to gate on "DB + MQ", but a live check during planning
confirmed the MQ input NEVER reached the health handlers (the Extension(MqHealth) is
layered onto the nested /v1 routes while the health routes sit on the outer router) —
readiness has been DB-only on every backend service. A pod whose event-durability or
money-writing worker has died stays green and in rotation indefinitely.
Constraints that shaped the decision: worker loops are heterogeneous (spawned by service
mains, shared-crate boot helpers, and ApiServer::router itself); several are knob-gated
and legitimately absent; the MQ subscriber supervisors already self-heal connection loss
(#1127) and contain handler panics (#1203), so the remaining kill vector is a panic in the
supervisor machinery itself or an unforeseen loop exit; post-panic shared state cannot be
trusted for in-process restart; and a readiness signal that can be silently forgotten
(an Option field, an unregistered worker) recreates the exact fail-open class being fixed.
Decision
One Supervisor per service process owns THE shutdown token (created at
Supervisor::install(), which also spawns and registers the signal→token bridge — the
bridge selects on signal OR token so a worker-death shutdown completes it). Every
long-lived worker handle is registered under a static name with a criticality tier and
watched by an order-serializing watcher: a biased select! observes shutdown-requested
vs handle-completed in a serialized order, so a clean exit BEFORE shutdown classifies
Dead and a clean exit after observed shutdown classifies Completed (ties resolve to
Completed — the benign direction; a panic is Dead always). Death policy is
fail-fast: a dead Critical worker is recorded (readiness gates immediately), logged
at error!, and cancels the token — a bounded HTTP drain plus a bounded watcher drain
(both inside k8s’s default 30s termination grace; the constants live with the code and the
plan) then a NONZERO exit via check_exit(); the orchestrator restarts the process. There
is no in-process respawn. Observed-tier deaths are recorded and warn!-logged but never
gate or kill.
Readiness becomes fail-closed against omission twice over: AppState carries
MqRequirement::{Required, NotApplicable} (no Option, no Default — forgetting the MQ
input is a compile error), and each service declares an expected-worker manifest; an
expected Critical worker that is neither registered nor explicitly Disabled reads as
Missing and gates readiness. The /readyz 503 carries a discriminating body naming the
failing checks; /healthz gains a structured per-worker block (criticality + state only —
raw panic/error text stays in logs, never on the unauthenticated wire).
Consequences
Consequences describe the POST-IMPLEMENTATION state this decision produces; the plan’s Status table is authoritative for what has shipped at any moment.
-
Pre-1.0 behavior changes:
/readyzgates on the MQ publisher connection (for the first time, despite prior documentation) AND on critical-worker liveness; its 503 body is informative;ApiServer::servetakes the token (bounded drain);AppStatechanges shape. -
The observable on critical death is "503 on open connections, then connection refusal, then process exit" — orchestration reacts to refusal exactly as to 503, so the pod leaves rotation either way; this ADR deliberately does NOT claim a fresh network probe reliably sees the 503 during the drain window.
-
Devstack has no compose restart policy: a fail-fasted service stays down LOUDLY (
cargo xtask dev startis the remedy) — consistent with the fail-loud doctrine. -
Scope limitation (stated): this is HANDLE-liveness, not functional health. A hung worker, a reconnect-forever supervisor, or persistent warn-and-continue failures remain
Running. #1231 (2026-08-04) ships the functional-health complement: theworker_last_success_timestamp_seconds{worker=…}gauge family (craig_common::metrics::worker_heartbeat) — each instrumented loop records its last SUCCESSFUL pass (failure paths stay silent, so staleness alerts honestly; metrics-only, never coupled to readiness per the issue’s AC). Wired at the shared-crate seams that cover the fleet uniformly: the outbox drain (injected as a plainfnhook so craig-mq stays craig-common-free), the threespawn_local_sweepconsumers (request-claims-retention, financialreconcile-sweep, exchangesend-jobs-sweep), the JWKS/OIDC refresh loops (now name-threaded so the gauge label matches the registry name), andauthz-ttl-refresh. Alert guidance: deployment guide § Worker functional-health alerting. -
#1321 (2026-08-05) completes the tail — the consumer-liveness tick + the per-service loops. Subscriber design (the amendment #1231 deferred): a per-message heartbeat would page on every quiet queue, so the honest signal is a consumer-liveness tick — a third
tokio::select!arm on a per-session 30 s interval (LIVENESS_TICK_PERIOD, missed-ticks skipped) insiderun_sessionandrun_dlq_session: it beats exactly while a consume session is LIVE (consumer registered, channel open; the interval’s first tick fires on session entry) and is structurally silent during reconnect backoff. Injected as the same craig-common-freeOption<fn()>hook as the outbox. Adopted by every subscriber worker:events-subscriber×6,audit-subscriber,dlq-subscriber(tick in the consumer arm only — one registry name, one tick source; the #1232 child-token teardown stops the tick when the consumer arm dies, so the replayer/sampler arms cannot mask it),cache-invalidation,composition-invalidation, andauthz-invalidation. The per-service loops adoptrecord_successat their genuine-success points:decision-refresh(probe-clean pass — the gauge #1219 deferred),jws-cleanup,upload-attempt-reconciler×4 (ONE shared label likeoutbox; beats only on a clean three-step pass),send-worker(clean claim/dispatch-spawn — per-job failures are async by design, the exchange isolation exception),detection-scheduler,subsidy-generator+review-sweep(EXECUTED runs only — a peer-held lease is not this replica’s success), andretention-archive. The two sibling gauges stay named exceptions, not aliases:archive_last_success_timestamp(ADR-058; 0-at-boot, unlabeled, no_secondssuffix) andsubsidy_sweep_last_success_timestamp_seconds(engine-scoped — fires for manual runs too, lease-winner only — a different signal from the scheduler-tick heartbeat deliberately kept alongside it). -
OS-thread coverage (#1228, 2026-08-04): the two dedicated
!Send-eval OS threads (ruleszen-eval, the fleet-wideauthz-eval) — outside theJoinHandlemodel this ADR was written against — register throughWorkerHealth::watch_liveness: a sentinel taskselect!`s the registry token against a death-signal future (the eval channel’s receiver dropping; the future holds its own sender clone, so the loop cannot exit by all-senders-dropped while watched — a pre-shutdown resolution is unambiguously thread DEATH, never a clean exit). Critical tier: a dead eval thread fails every authz-gated request / rules evaluation typed — strictly worse than the staleness risk that already made the authz cache legs Critical — and no durable poison input exists to re-trigger a crash post-restart (the exchange isolation exception does not transfer). The sentinel cannot carry the panic payload (`Dead { panicked: false }with the thread’s own stderr panic output as the diagnostic), and the handle-liveness scope limitation applies unchanged: a wedged-but-alive thread readsRunning; the #784/#1126 per-call budgets own the caller experience. -
Own-router services (#1229, 2026-08-04) — the three services outside
ApiServer::routergot their supervision posture decided and recorded:-
craig-web: SUPERVISED. The supervision module moved from
craig-apitocraig_common::supervisor(everycraig_apipath re-exported unchanged;panic_message_boundedmoved to thecraig-mq-errorleaf with acraig_mqre-export) so the DB/MQ-less BFF can install aSupervisorwithout dragging sqlx/lapin into its graph. Its three refresh loops —oidc-discovery-refresh,jwks-refresh-id,jwks-refresh-access(closing the #1226/#1227 deferrals) — register Observed under the registry’s own token: each has a self-heal path (#981 kid-miss refresh; discovery refresh-on-first-use), so death degrades/healthzwithout gating./healthznow serves the fleet wire shape (worker block via the sharedWorkerCheckEntry;database/rabbitmqhonestlyn/a);/livez+/readyzstay process-up probes (all-Observed ⇒ nothing gates). Bycatch fix: craig-web’s graceful shutdown previously listened for SIGINT only (an inlinectrl_c()closure) — container stops (SIGTERM) killed it without draining; shutdown now derives from the supervisor token, whose signal bridge handles both. -
craig-intake: N/A, recorded. The ADR-017 stateless edge spawns zero long-lived workers (its only production spawn is the TLS graceful-shutdown bridge — present whenever in-process TLS is configured, ADR-046 — which dies with the process; every other spawn is test-scoped). Probe-only health is the complete truth.
-
craig-intake-keyring: N/A, recorded. Zero long-lived workers (store writes are per-request
spawn_blocking; no background flush/compaction). Probe-only health is the complete truth.
-
-
Recorded residuals: a watcher’s own panic is the one unsupervisable task class (its classification core is pure and unit-tested; the record is a
parking_lotwrite); the wrong-token class was reduced, not eliminated at this ADR’s landing — eliminated for router-internal workers by #1233 (2026-08-03):ServerOptions.shutdown(the second channel, whoseDefaultwas a detached never-cancelled token) is DELETED; router-internal workers now derive shutdown fromWorkerHealth::token()— the registry they register with — so token and watcher share one authority by construction (pinned bypruner_exits_when_the_registry_token_cancels; main-spawned workers still hand-thread the supervisor token, where a foreign token remains representable — the exact-set pins and the supervision-chain fault tests own that surface); the tie-window (a clean death racing an unrelated signal within scheduler latency) loses only a diagnostic, never availability. -
Exchange isolation exception: the send worker’s per-job dispatch panics are contained (
craig_mq::contain) and logged with the job id but deliberately do NOT propagate to the Critical parent — a poison job that panics deterministically must not crash-loop the whole service; the #1185 claim lease recovers the job, and the in-flightJoinSetis drained bounded on shutdown. Counter metric is #1230. -
Interior mutability: the registry is a project-authored
Arc<parking_lot::RwLock<BTreeMap<..>>>— sanctioned here per the conventions rule requiring an ADR for project-authored interior mutability (short, non-async critical sections; no lock held across.await). -
Implementation plan: Worker Supervision & Readiness (mechanics, tier table, test matrix, review log). Program: child epic &76; units #1220/#1221/#1222/#1223/#1186/#1224/#1225.
-
ADR-003's "readyz reflects the publisher path only" statement is superseded in part by this ADR (its Amendment #1186 points here): the publisher path genuinely gates readiness, and a subscriber SUPERVISOR’s death gates it too — while the #1127 self-heal semantics inside a live supervisor are unchanged.
Open questions
-
Whether
Missingshould distinguish "expected, boot still in progress" if a future service ever registers workers after serve binds (today all 8 register strictly before bind; revisit only if that ordering changes). -
Publisher-CHANNEL health vs the parent connection — RESOLVED by #1235 (2026-08-03):
MqHealthcarries the #1128 confirm-selected publisher channel and readiness gates on BOTH (disconnectedvspublisher channel closedas structured healthz categories). lapin channels close permanently, so the readiness flip’s restart is the supervised recovery path; callers composing their own channels viaconnect_mqkeep connection-only gating until they attach one (recorded carve-out).
Alternatives considered
-
Shared
JoinSetsupervisor — rejected: handles come from heterogeneous spawn seams (mains, boot helpers, the router); a JoinSet erases per-worker identity unless every seam is rebuilt around task IDs, and it cannot expressDisabled/Missing. -
Typed worker-exit protocol (every loop returns an exit reason) — rejected: causality becomes explicit, but it rewrites every worker loop fleet-wide for information the order-serializing watcher already recovers at the supervision layer.
-
In-process respawn with backoff — rejected per the user-decided policy: post-panic shared state is untrusted (money-writing generators, claim leases), the lint wall makes panics rare enough that each one is a real defect, and process restart restores clean state for free via the orchestrator.
-
Mark-not-ready-only (no exit) — rejected: readiness alone never restarts a pod; the result is a zombie requiring paging. Fail-fast converts the failure into the orchestrator’s native recovery loop.
-
Two-phase readiness window before cancel (so probes reliably observe the 503) — rejected: adds a delay knob for a purely cosmetic distinction; refusal fails probes identically, and the drain window already surfaces the 503 on open connections.