Report Runs — Operations Runbook
On this page
Operational guide for the canopy-reporting durable run pipeline (#1202/#1203,
plan report-run-generations). The
substrate ships in MR4 with zero report kinds wired: the worker, queue,
poll API, metrics and janitor are all live, but the five federal generate
POSTs still run synchronously until the MR5 (SNAP) and MR6 (TANF+Medicaid)
waves flip them to 202 Accepted.
Model in one paragraph
An enqueue creates a durable report_runs job and a fresh staged
report_generations output generation, atomically. One in-process worker
claims runs (FOR UPDATE SKIP LOCKED, DB-minted claim token), materializes
the universe, processes it under token-fenced checkpoints, and finalizes in
a single transaction that also promotes the generation
(staged→published, prior published→superseded). Readers only ever see
the published generation — a partial, abandoned or errored run is invisible
by construction. Run rows are ephemeral (reaped); generation rows are
permanent provenance.
Enqueue
-
AUTH (#1438, ADR-043 §C): the generate POSTs are USER-ONLY — the caller needs the worker’s exchanged
aud=canopy-reportingtoken (supervisor-or-above); service-class tokens and, under enforcement, direct broad-audience worker bearers are 403. The runs poll/list surfaces below stay dual (service or supervisor). -
The five generate POSTs (
/v1/reporting/snap/fns-388,…/snap/qc-universe,…/tanf/acf-199,…/medicaid/tmsis,…/medicaid/cms-416) become the enqueue surface in MR5/MR6: 202 +ReportRunAccepted{run_id, generation_id, poll_url}
Location; 409 with the in-flight run’s handle when the (kind, period) already has an active run; 503 at the queue cap or whenruns_enabled=false. -
One active run per (kind, period) — enforced by a partial unique index. The 409 body carries the active run’s handle, so "conflict" is always pollable (runs are org-visible for exactly this reason).
-
Period canonicalization is enforced, not documented: monthly kinds pin the month start, CMS-416 pins Jan 1 of the report year, QC keys the snapshot date. The enqueue path normalizes before insert; the schema CHECKs would refuse anything else.
Poll and list
-
GET /v1/reporting/runs/{id}→ReportRunStatus. Counters come from run COLUMNS anddetail_countersfrom the generation row — progress is never decoded on the read path, so a malformed progress blob cannot 500 a poll. Queued/running responses carryRetry-After(one worker tick). Adonerun carriesresult_urlpointing at the kind’s read endpoint. -
GET /v1/reporting/runs?kind=&period=&limit=— newest-requested first, limit clamped to the house bounds, unknownkind→ 422. -
RBAC: supervisor-or-above OR any service-class caller.
-
A 404 on a previously-valid run id usually means the run was REAPED (see below). Its provenance is not lost — the generation row is permanent and carries the copied run summary.
Observe
-
/readyzrenders a non-gatingworkercheck (labelreport-runs):pendingbefore the first pass (the delayed first tick is normal),ok,degradedon a stalled loop or an accumulating error streak, anddisabledunder the operator override. Worker degradation NEVER 503s readiness — pulling a replica cannot revive its own in-process worker. -
Metrics (OTel meter
canopy_reporting):canopy_reporting_run_claims_total,canopy_reporting_runs_serviced_total,canopy_reporting_run_abandons_total,canopy_reporting_run_finalizes_total{state,error_code},canopy_reporting_run_queue_depth,canopy_reporting_run_oldest_queued_age_seconds,canopy_reporting_run_worker_last_success_age_seconds(-1 = never). -
Every worker decision logs with
run_id; look forreport-run pass failed(engine errors, capped backoff) andclaim fenced mid-pass(lost lease — informational, the run continues elsewhere).
Disabled mode (runs_enabled=false)
CANOPY_REPORTING__RUNS_ENABLED=false is the per-control operator
override, accountable and visible:
-
the worker parks at boot (logs
report-run worker DORMANT;/readyzworker check readsdisabled), AND -
enqueue answers 503 — a 202 for work that will never run is a lie, so the queue can never accumulate dead-letter runs while disabled.
Re-enabling requires a restart (boot-time knob). Queued runs enqueued before disabling survive and are serviced after re-enable.
Stuck run / reclaim semantics
A worker holds a run via a leased claim (run_claim_secs, default 300s)
that its supervised pulse re-arms every run_heartbeat_secs (default 60s)
— an upstream call as long as the 90s full-call deadline can never be
reclaimed mid-flight (boot-validated relationship rules, never clamped).
If the worker dies, the claim simply EXPIRES. The next pass reclaims the
run with progress preserved (checkpointed counters, cursor and phase)
and attempts incremented, and re-verifies the generation’s stable-input
pins (build_version, params_hash) — a reclaim under a different binary
or parameter table finalizes error/stale_pins; re-enqueue to get a fresh
generation with fresh pins. There is no operator "unstick" verb to run:
recovery is claim expiry. A run that looks wedged for longer than
run_claim_secs + one tick without state movement indicates the whole
worker loop is down — check the /readyz worker check and the service
logs, not the run row.
Attempts cap
run_max_attempts (default 5) bounds claim attempts. The claim function
refuses over-cap rows by TERMINALIZING them in the same statement: the run
does work at attempts 1..=max, and the (max+1)-th claim attempt
finalizes it instead of working it. Terminal attribution is durable and
honest: upstream_unavailable when the last recorded abandon reason was
transient-class, else crashed (fence loss, worker death, no recorded
reason). The abandon_reason column survives into the status response for
diagnosis.
Reap + janitor
Both run inside the worker on a daily internal cadence (checked every loop iteration):
-
Run reap — terminal (
done/error)report_runsrows older thanrun_reap_days(default 7; a 7-day floor is enforced in SQL) are deleted. Polling a reaped run returns 404; provenance lives on the generation row, which is never deleted. -
Generation-row janitor —
supersededandabandonedgenerations' OUTPUT rows (the five report tables) andreport_run_universerows are deleted once pastrun_generation_row_retention_days(default 30). Retention clocks: a superseded generation is measured from its SUCCESSOR’spublished_at(exact — promotion supersedes and publishes in one transaction); an abandoned one from itscreated_at(conservative; it was never visible).report_generationsrows are PERMANENT — the janitor only removes bulk rows. Batch-bounded (50 generations per pass) and idempotent.
Knobs
All CANOPY_REPORTING__RUN_*; validated at boot against pinned domains and
relationship rules — an out-of-domain value is a startup failure, never a
silent clamp. See configuration-reference.adoc for the layered-config
mechanics.
| Knob | Default | Domain |
|---|---|---|
|
5000 |
500..=60000 |
|
60 |
0..=600 |
|
300 |
60..=600 (and >= 3× heartbeat; > 90s full-call deadline + heartbeat) |
|
60 |
5..=200 |
|
5 |
1..=20 |
|
10 |
1..=100 |
|
200 |
50..=200 (const-pinned ≤ the shared pagination ceiling) |
|
16 |
1..=64 |
|
true |
the per-control override (503 + parked worker when false) |
|
7 |
7..=90 |
|
30 |
7..=365 |
docker-compose.yml) sets
CANOPY_REPORTINGRUN_TICK_MS=500 and
CANOPY_REPORTINGRUN_FIRST_TICK_DELAY_SECS=0 so enqueued report runs
execute promptly in dev; production defaults are the config.rs values
above.