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-reporting token (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 when runs_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 and detail_counters from the generation row — progress is never decoded on the read path, so a malformed progress blob cannot 500 a poll. Queued/running responses carry Retry-After (one worker tick). A done run carries result_url pointing at the kind’s read endpoint.

  • GET /v1/reporting/runs?kind=&period=&limit= — newest-requested first, limit clamped to the house bounds, unknown kind → 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

  • /readyz renders a non-gating worker check (label report-runs): pending before the first pass (the delayed first tick is normal), ok, degraded on a stalled loop or an accumulating error streak, and disabled under 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 for report-run pass failed (engine errors, capped backoff) and claim 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; /readyz worker check reads disabled), 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_runs rows older than run_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 janitorsuperseded and abandoned generations' OUTPUT rows (the five report tables) and report_run_universe rows are deleted once past run_generation_row_retention_days (default 30). Retention clocks: a superseded generation is measured from its SUCCESSOR’s published_at (exact — promotion supersedes and publishes in one transaction); an abandoned one from its created_at (conservative; it was never visible). report_generations rows 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

RUN_TICK_MS

5000

500..=60000

RUN_FIRST_TICK_DELAY_SECS

60

0..=600

RUN_CLAIM_SECS

300

60..=600 (and >= 3× heartbeat; > 90s full-call deadline + heartbeat)

RUN_HEARTBEAT_SECS

60

5..=200

RUN_MAX_ATTEMPTS

5

1..=20

RUN_MAX_QUEUED

10

1..=100

RUN_CHUNK_SIZE

200

50..=200 (const-pinned ≤ the shared pagination ceiling)

RUN_UPSTREAM_CONCURRENCY

16

1..=64

RUNS_ENABLED

true

the per-control override (503 + parked worker when false)

RUN_REAP_DAYS

7

7..=90

RUN_GENERATION_ROW_RETENTION_DAYS

30

7..=365

NOTE
The devstack (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.
Edit this page · default