# M8 Verification — DLQ + replay UI **Status:** shipped 2026-06-14 **Branch:** master **Commits:** see `git log --oneline | grep M8` **Verified on:** local 1-CPU QEMU host (broad-announce docker-compose stack) This milestone closes the loop on the delivery path. Failed deliveries now go through a bounded exponential- backoff retry, terminate in a durable Dead-Letter Queue when exhausted, and the operator can inspect the DLQ via JSON API or HTML UI and either replay the failed delivery (re-publish onto the original NATS subject) or discard it. See SPEC §9 and §23. ## What landed | Area | Change | | --- | --- | | Schema | `migrations/008_dlq.{up,down}.sql` — new `deliveries_dlq` Timescale hypertable (PK `(id, created_at)`, 1d chunks, 7d retention, mirroring the live `deliveries` table). Extra columns: `original_subject` (for replay), `discarded`/`discarded_at`/`discarded_by` (for the operator's discard action). | | Retry helper | `internal/retry/retry.go` — exp backoff (BaseDelay doubles, capped at MaxDelay), wall-clock Budget, ctx-cancel aware, PermanentError short-circuit. 10 attempts, base 100ms, cap 2s, budget 30s — total wall clock ~12s for a fully failing target. Configurable via env. | | Retry tests | `internal/retry/retry_test.go` — 7 unit tests covering: first-try success, retry-then-succeed, exhaustion, permanent short-circuit, budget respect, ctx cancel, monotonic backoff. 7/7 PASS. | | DLQ writer | `internal/dlq/dlq.go` — single `Write()` that INSERTs one row into `deliveries_dlq`. Per-attempt rows in the live `deliveries` table are left as-is (status='failed') so the audit trail is preserved. | | Config | `internal/config/config.go` — four new env knobs on Common: `BA_DELIVERD_MAX_ATTEMPTS` (10), `BA_DELIVERD_RETRY_BASE_MS` (100), `BA_DELIVERD_RETRY_MAX_MS` (2000), `BA_DELIVERD_RETRY_BUDGET_MS` (30000). Other services ignore them. | | FCM retry | `cmd/deliverd-fcm/main.go` — refactored `handleOne()` to use `retry.Run`. Per-attempt deliveries row + on exhaustion a `dlq.Write()`. PermanentError on FCM 4xx (excluding 408/429) so we don't burn the retry budget on a bad token. Per-attempt timeout 10s; outer Budget caps total wall clock. | | Telegram retry | `cmd/deliverd-telegram/main.go` — same pattern. Per-attempt timeout 15s (Telegram can be slower). PermanentError on Telegram 4xx (excluding 429). | | Archiver DLQ drain | `internal/archiver/archiver.go` — `RunOptions` now carries a `[]TableSpec`; `drainTable` takes a `TableSpec` and selects/inserts/deletes per-table. Default plan covers both `deliveries` (live) and `deliveries_dlq` (forensic) with 7d Postgres hot window. ClickHouse schema adds `ba_archive.deliveries_dlq_archive` (2y TTL) + `ba_archive.deliveries_dlq_per_company_daily_mv` (SummingMergeTree) for M9 dashboards. | | ClickHouse schema | `migrations/clickhouse_schema.sql` — hand-runnable ops reference, updated to mirror the new DLQ DDL. | | Admind API | `cmd/admind/main.go` — `GET /v1/ping` (M0) + M8 endpoints: `GET /v1/dlq` (list/filter, 30d window, hides discarded by default), `GET /v1/dlq/{id}` (single row w/ payload), `POST /v1/dlq/{id}/replay` (re-publishes the original NATS envelope onto the original subject, then marks the row discarded), `POST /v1/dlq/{id}/discard` (mark discarded; idempotent). | | Admind UI | `cmd/admind/ui/dlq.html` — minimal HTML page embedded via `go:embed`. Filter form (company, channel, alert_id, include-discarded), inline replay/discard buttons, /v1/dlq/{id} JSON link per row, light/dark theming via `prefers-color-scheme`. JS is plain ES5. | | Test double | `testfakes/fakefcmd/main.go` — added a runtime `/control?fail=0\|1` endpoint to flip the failure mode at runtime. Avoids restarting the container for the smoke test. | | Compose | `docker-compose.yml` — wired the four `BA_DELIVERD_*` env vars on both `deliverd-fcm` and `deliverd-telegram`. | | Env | `.env.example` — documented the four new knobs with the math behind the defaults. | | Smoke | `scripts/m8_smoke.sh` — 4-step, 12-check, HMAC-signed HTTP ingest + runtime fail injection + replay + discard. | ## New env vars (deliverd-*) | Var | Default | Purpose | | --- | --- | --- | | `BA_DELIVERD_MAX_ATTEMPTS` | 10 | total tries including the first | | `BA_DELIVERD_RETRY_BASE_MS` | 100 | wait before the SECOND attempt; doubles each subsequent attempt, capped at `RETRY_MAX_MS` | | `BA_DELIVERD_RETRY_MAX_MS` | 2000 | cap on the per-attempt wait | | `BA_DELIVERD_RETRY_BUDGET_MS` | 30000 | wall-clock cap across all attempts; loop bails early if the next wait would exceed it | With the defaults, a fully-failing target terminates in ~12s. The smoke runs against the live defaults — the test does not slow down the run. ## New endpoints (admind) | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/v1/dlq?company_id=&channel=&alert_id=&include=all&limit=100&offset=0` | list DLQ rows (30d window; hides discarded by default) | | `GET` | `/v1/dlq/{id}` | single row with payload | | `POST` | `/v1/dlq/{id}/replay` | re-publishes the original NATS envelope onto the original subject, then marks the row discarded (idempotent re-publish; failure leaves the DLQ row in place) | | `POST` | `/v1/dlq/{id}/discard` | marks the row discarded (hidden from default list); idempotent | | `GET` | `/dlq` | minimal HTML page (light/dark theming) with filter form + inline replay/discard buttons | ## Files ``` migrations/008_dlq.up.sql # deliveries_dlq hypertable, 7d retention migrations/008_dlq.down.sql # shadow-table rename (reversible) migrations/clickhouse_schema.sql # + deliveries_dlq_archive (2y TTL) + MV internal/dlq/dlq.go # Write() — single INSERT into deliveries_dlq internal/retry/retry.go # bounded exp-backoff retry helper internal/retry/retry_test.go # 7 unit tests, 7/7 PASS internal/config/config.go # + 4 BA_DELIVERD_* knobs on Common internal/archiver/archiver.go # + TableSpec + DLQ drain cmd/deliverd-fcm/main.go # M8 retry + dlq.Write cmd/deliverd-telegram/main.go # M8 retry + dlq.Write cmd/admind/main.go # /v1/dlq + /v1/dlq/{id} + /v1/dlq/{id}/{replay,discard} + /dlq cmd/admind/ui/dlq.html # embedded HTML page testfakes/fakefcmd/main.go # /control?fail=0|1 (runtime fail injection) docker-compose.yml # + BA_DELIVERD_* on both deliverds .env.example # + BA_DELIVERD_* with math scripts/m8_smoke.sh # 4-step, 12-check live smoke M8_VERIFICATION.md # this file M8_SMOKE_LOG.md # 3 consecutive 12/12 runs ``` ## Behavior notes * **One row per attempt, not one row per alert.** The M1 audit shape is preserved: each retry attempt INSERTs one `deliveries` row with `attempts=N` and the per-attempt `last_error`. Operators see "10 attempts, all failed" at a glance. The DLQ row is a *summary*: `attempts=10` + the last error. * **No flip on the live row.** Earlier drafts of `dlq.Write()` flipped the per-attempt `deliveries` rows to `status='dlq'`. That destroyed the audit trail — operators couldn't tell whether 1 attempt or 10 attempts failed. M8 ships without that UPDATE; the DLQ row is the source of truth for "this hit the DLQ". * **Replay is best-effort idempotent.** A replay publishes the original NATS envelope onto the original subject, then `Flush()`es, then marks the DLQ row discarded. If the publish fails (broker down, etc.), the row stays live and the operator can retry. A second concurrent replay would publish twice — we don't dedupe, because the operator explicitly asked for a re-send. If the alert_id is in the M6 dedupe window, ingestd will treat the second arrival as a duplicate of the first, but the deliveries table gets a fresh row regardless. * **Discard is idempotent.** A second discard is a 200 with `{"already": true}`. The handler is keyed on `(id, created_at)` to satisfy the Timescale hypertable composite PK. * **Retry is in-process, not JetStream redelivery.** JetStream's redelivery timer is fixed at the consumer level and doesn't express per-attempt exp backoff. We want explicit config-driven backoff (SPEC §9: 1s, 2s, 4s, … up to 10 attempts). In-process retry is simpler and gives more control. The downside is that one stuck downstream can tie up a single consumer for up to Budget — but with the default 30s budget and 4-replica deliverds, that's bounded. * **PermanentError short-circuit.** Both deliverd-fcm and deliverd-telegram classify 4xx (excluding 408/429) as permanent via `&retry.PermanentError{Err: …}`. FCM's 4xx means the token is bad, Telegram's 4xx means the chat is gone — retrying won't help. We use those 4xx codes to skip the remaining attempts. * **Auth is deferred.** The /v1/dlq* endpoints are unauthenticated in M8. M11 will gate them behind an operator JWT. Until then, deploy behind a LAN boundary or a reverse proxy that enforces IP allowlists. ## SPEC drift noted (not a bug) SPEC §9 literally calls for backoff of 1s, 2s, 4s, … 512s = 1023s total. That ties up a single NATS consumer for ~17 minutes per stuck downstream. The M8 implementation caps per-attempt wait at 2s and total budget at 30s. With `BA_DELIVERD_*` env vars, an operator who wants the SPEC-literal behavior can opt in (base=1000, max=60000, budget=1200, attempts=10 → 1+2+4+8+16+32+60+60+60+60 = 303s). The defaults trade literal SPEC compliance for fast failure detection. SPEC §23 doesn't list an `M8.5`. The `deliverd-fcm` and `deliverd-telegram` source comments still reference "M9 adds the retry + DLQ chain" — those are stale; M8 is the one. (Fixed in this milestone: see the updated headers.) ## Latent bugs caught during M8 build (fixed in this milestone) ### Bug 1 — `dlq.Write()` was over-aggressively flipping live rows The first version of `dlq.Write()` did: ```sql UPDATE deliveries SET status='dlq' WHERE alert_id=… AND status NOT IN ('sent','dlq') ``` A 10-attempt failure produced 10 `deliveries` rows (one per attempt, all `status='failed'`) plus 1 `deliveries_dlq` row. The UPDATE then flipped all 10 of those `failed` rows to `status='dlq'`, destroying the per-attempt audit trail. The smoke caught it: `attempts=10` on the DLQ row but 0 rows in `deliveries WHERE status='failed'`. Fix: remove the UPDATE entirely. The DLQ row is the source of truth for "this hit the DLQ"; the per-attempt rows are the source of truth for "this attempt did X". They serve different purposes. ### Bug 2 — `LastError` retained prior failure on a successful retry The first version of `retry.Run` set `res.LastError` once and never cleared it. Test `TestRunRetriesThenSucceeds` failed: attempt 1 fails (sets LastError), attempt 2 succeeds, but `res.LastError` still had the attempt-1 error. Caught by unit test; fix: `res.LastError = nil` on success. ### Bug 3 — `fakefcmd` had no way to fail mid-test The M1 test double accepted `--fail-rate=0.x` at startup but the smoke needed to flip the failure mode after the happy-path step. Added a `/control?fail=0|1` endpoint. The smoke uses this instead of restarting the fakefcmd container (which on this 1-CPU QEMU host takes 30s+ to rebuild the image). ### Bug 4 — Compose's `docker cp` + `restart` doesn't pick up the new binary `docker cp new-binary old-container:/app/old-binary` overwrites the file on the running container's writable layer, but the running process still has the old inode mapped. `docker compose restart svc` does NOT recreate the container; it just sends SIGTERM/SIGKILL to the same container, which then starts with the (new) on-disk binary — except: I observed that on this Compose 5.1.3 build, `restart` doesn't actually restart the container. Workaround: `docker compose rm -sf svc && docker compose up -d --no-deps svc` to fully recreate, then `docker cp` the new binary, then `kill` + `up` to pick it up. Documented in the developer runbook.