ADR 0036 — Misfire and Catch-Up Policy; Suspension Windows Are Never Backfilled
Status: Accepted Context date: 2026-08-06 Builds on: ADR 0025 (tenant suspension), ADR 0033 (claim-queue scheduling), ADR 0041 (DR behaviour)
Context
A planner outage, database failover, long deploy, or tenant suspension leaves next_fire_at in the past. What happens next is tenant-visible product behaviour: firing everything that was missed can flood a tenant endpoint with thousands of stale calls; firing nothing silently drops work. The policy has to be explicit, per-schedule, and bounded — and it interacts directly with ADR 0025's suspension machinery.
Decision
1. Per-schedule misfire_policy, applied when next_fire_at is more than one interval in the past
| Policy | Behaviour |
|---|---|
fire_once (default) | Materialise only the most recent missed occurrence, then realign to the next future one. |
skip | Realign to the next future occurrence; nothing fires. |
catch_up | Backfill missed occurrences, hard-capped at 100 runs / 24 h of history, rate-limited into the executor. |
fire_once is the default because it matches DR reality: a standby planner starting cold after a regional failover (ADR 0041) applies the misfire policy to every schedule at once — "run the most recent missed occurrence, once" is the only default that is both safe and useful at fleet scale.
2. Staleness cut-off
Runs whose scheduled_for is older than max_staleness (default 1 h, tenant-configurable up to 24 h) are marked skipped(reason=stale) instead of being delivered. A 03:00 report delivered at 14:00 is usually worse than no delivery plus a visible skip record.
3. A suspension window is never backfilled — regardless of policy
On tenant.suspended (ADR 0025 projection), the planner stops materialising and the executor marks claimed pending runs skipped(reason=tenant_suspended); in-flight deliveries finish; schedules are preserved, not deleted. On tenant.activated, schedules resume with the misfire treated as skip even if the schedule's policy is catch_up. A 3-day suspension of a per-minute schedule would otherwise mean 4,320 webhooks on reactivation. An aggregated schedule.run.skipped is emitted so the gap is auditable in run history.
Alternatives Considered
catch_upas the default. Rejected: the default must be safe under fleet-wide events (deploys, failovers); a default that can flood every tenant endpoint simultaneously fails that test. Tenants who genuinely need backfill (e.g. sequential data pipelines) opt in and get the cap.skipas the default. Rejected: for the common "nightly job" case, silently losing the one missed occurrence after a 10-minute outage is exactly the silent-loss failure ADR 0034 rules out.fire_oncerecovers the most recent intent without flooding.- Uncapped
catch_up. Rejected: an unbounded backfill after a long outage is a self-inflicted thundering herd; 100 runs / 24 h covers realistic pipelines and turns pathological cases into visible skips. - Backfilling suspension windows for
catch_upschedules. Rejected: suspension is an administrative action (ADR 0025); replaying the punished period's traffic on reactivation surprises the tenant, hammers their endpoint, and re-litigates the suspension. The gap is recorded, not replayed. - Deleting or pausing-forever schedules on suspension. Rejected: suspension is reversible by design (ADR 0025 preserves tenant data); schedules resume like everything else.
Consequences
- Positive: Every recovery path — crash, failover, deploy, suspension — has a bounded, pre-decided outcome per schedule, visible in run history as real rows (
skippedwith a reason), never as silence. - Positive: The DR story (ADR 0041) needs no special casing: cold-start planning is the misfire path.
- Negative / accepted:
fire_oncemeans a schedule that missed N occurrences runs once, not N times — tenants with strict per-occurrence pipelines must opt intocatch_upand design for its cap. - Negative / accepted: The suspension rule overrides tenant configuration (
catch_upis ignored for the window). Deliberate: platform safety over per-schedule preference, and auditable via the aggregated skip event. - Interaction: Skip reasons (
overlap,tenant_suspended,quota_exhausted,stale) are part of the event contract (ADR 0038) and the run-history API — they are the tenant's window into every occurrence that did not fire.