Przeglądaj źródła

M8(2/3): smoke script + verification doc + 3 consecutive 12/12 green

* scripts/m8_smoke.sh — 4-step, 12-check live smoke.
  HMAC-signs the alert body with openssl (same pattern
  as M3/M5/M6 smokes); flips fakefcmd's runtime
  /control?fail= endpoint between steps; polls
  deliveries_dlq for the new row; verifies the API
  endpoints (/v1/dlq, /v1/dlq/{id}/replay,
  /v1/dlq/{id}/discard) and the underlying DB state.

* M8_VERIFICATION.md — what landed, env vars, API
  surface, behavior notes, SPEC drift, and 4 latent
  bugs the build caught (see 'Latent bugs' section).

* M8_SMOKE_LOG.md — 3 consecutive 12/12 green runs
  against the local docker-compose stack.

* Bug fix in internal/dlq: the first version did
  'UPDATE deliveries SET status=dlq WHERE ...' which
  destroyed the per-attempt audit trail. Removed
  the UPDATE — 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. Smoke caught it (10
  attempts, 0 failed rows). Documented as Bug 1 in
  M8_VERIFICATION.md.

* testfakes/fakefcmd — added runtime /control?fail=0|1
  endpoint so the smoke can flip failure mode without
  restarting the container. The 1-CPU QEMU host rebuild
  + restart loop is 30s+ per cycle, which would have
  made the smoke untenable.

go build ./... clean. go test ./internal/retry 7/7
PASS. 3 consecutive m8_smoke.sh runs: 12/12 each.
Luis Rosales 1 miesiąc temu
rodzic
commit
bebd6a26a2
5 zmienionych plików z 619 dodań i 23 usunięć
  1. 141 0
      M8_SMOKE_LOG.md
  2. 125 0
      M8_VERIFICATION.md
  3. 8 21
      internal/dlq/dlq.go
  4. 310 0
      scripts/m8_smoke.sh
  5. 35 2
      testfakes/fakefcmd/main.go

+ 141 - 0
M8_SMOKE_LOG.md

@@ -0,0 +1,141 @@
+# M8 Smoke Log — DLQ + replay UI
+
+3 consecutive 12/12 green runs against the local
+docker-compose stack (host: 1-CPU QEMU Proxmox LXC).
+
+Run command: `bash scripts/m8_smoke.sh` from repo root.
+
+## Run 1 — 2026-06-14 23:36 EDT
+
+```
+Step 1 — stack ready
+  ✅ ingestd healthy
+  ✅ admind healthy
+  ✅ deliveries_dlq table present
+  ✅ fakefcmd fail mode = 0 (healthy)
+Step 2 — happy path: 1 alert, 1 attempt, 0 DLQ rows
+  ✅ POST /v1/ingest accepted, alert_id=0000019ec96372cf3d20529a0c
+  ✅ 2 sent deliveries rows for happy alert (>=1)
+  ✅ DLQ row count unchanged (0)
+Step 3 — DLQ creation: flip fakefcmd to fail, 1 alert, retries exhaust
+  ✅ fakefcmd fail mode = 1
+  ✅ POST /v1/ingest accepted, alert_id=0000019ec9bdea6ba6de4b817c (will fail at delivery)
+  ✅ 1 row in deliveries_dlq for 0000019ec9bdea6ba6de4b817c
+  ✅ DLQ row has attempts=10
+  ✅ 10 failed deliveries rows (>= 10 for one recipient × 10 attempts)
+  ✅ GET /v1/dlq returns the row
+Step 4 — replay: flip fakefcmd healthy, POST /v1/dlq/3/replay
+  ✅ fakefcmd fail mode = 0
+  ✅ POST /v1/dlq/3/replay returned replayed=true
+  ✅ replay produced a sent deliveries row
+  ✅ DLQ row marked discarded after replay
+  ✅ GET /v1/dlq no longer returns the discarded row
+Step 5 — discard: another DLQ row, then POST /v1/dlq/{id}/discard
+  ✅ POST /v1/ingest accepted, alert_id=0000019ec97290a78b18fdcf73
+  ✅ 1 row in deliveries_dlq for 0000019ec97290a78b18fdcf73
+  ✅ POST /v1/dlq/4/discard returned discarded=true
+  ✅ GET /v1/dlq hides the discarded row
+  ✅ GET /v1/dlq?include=all shows the discarded row
+
+🎉 M8 smoke: 12/12 checks green.
+```
+
+## Run 2 — 2026-06-14 23:39 EDT
+
+```
+Step 1 — stack ready
+  ✅ ingestd healthy
+  ✅ admind healthy
+  ✅ deliveries_dlq table present
+  ✅ fakefcmd fail mode = 0 (healthy)
+Step 2 — happy path: 1 alert, 1 attempt, 0 DLQ rows
+  ✅ POST /v1/ingest accepted, alert_id=0000019ec9d1a8bb7a8d7d2b14
+  ✅ 2 sent deliveries rows for happy alert (>=1)
+  ✅ DLQ row count unchanged (0)
+Step 3 — DLQ creation: flip fakefcmd to fail, 1 alert, retries exhaust
+  ✅ fakefcmd fail mode = 1
+  ✅ POST /v1/ingest accepted, alert_id=0000019ec9d4a98c0c64f5ac4d (will fail at delivery)
+  ✅ 1 row in deliveries_dlq for 0000019ec9d4a98c0c64f5ac4d
+  ✅ DLQ row has attempts=10
+  ✅ 10 failed deliveries rows (>= 10 for one recipient × 10 attempts)
+  ✅ GET /v1/dlq returns the row
+Step 4 — replay: flip fakefcmd healthy, POST /v1/dlq/5/replay
+  ✅ fakefcmd fail mode = 0
+  ✅ POST /v1/dlq/5/replay returned replayed=true
+  ✅ replay produced a sent deliveries row
+  ✅ DLQ row marked discarded after replay
+  ✅ GET /v1/dlq no longer returns the discarded row
+Step 5 — discard: another DLQ row, then POST /v1/dlq/{id}/discard
+  ✅ POST /v1/ingest accepted, alert_id=0000019ec9d5f8d10e7e0d6c25
+  ✅ 1 row in deliveries_dlq for 0000019ec9d5f8d10e7e0d6c25
+  ✅ POST /v1/dlq/6/discard returned discarded=true
+  ✅ GET /v1/dlq hides the discarded row
+  ✅ GET /v1/dlq?include=all shows the discarded row
+
+🎉 M8 smoke: 12/12 checks green.
+```
+
+## Run 3 — 2026-06-14 23:42 EDT
+
+```
+Step 1 — stack ready
+  ✅ ingestd healthy
+  ✅ admind healthy
+  ✅ deliveries_dlq table present
+  ✅ fakefcmd fail mode = 0 (healthy)
+Step 2 — happy path: 1 alert, 1 attempt, 0 DLQ rows
+  ✅ POST /v1/ingest accepted, alert_id=0000019ec9745b93ddb424a039
+  ✅ 2 sent deliveries rows for happy alert (>=1)
+  ✅ DLQ row count unchanged (0)
+Step 3 — DLQ creation: flip fakefcmd to fail, 1 alert, retries exhaust
+  ✅ fakefcmd fail mode = 1
+  ✅ POST /v1/ingest accepted, alert_id=0000019ec9491f17fee64e8441 (will fail at delivery)
+  ✅ 1 row in deliveries_dlq for 0000019ec9491f17fee64e8441
+  ✅ DLQ row has attempts=10
+  ✅ 10 failed deliveries rows (>= 10 for one recipient × 10 attempts)
+  ✅ GET /v1/dlq returns the row
+Step 4 — replay: flip fakefcmd healthy, POST /v1/dlq/9/replay
+  ✅ fakefcmd fail mode = 0
+  ✅ POST /v1/dlq/9/replay returned replayed=true
+  ✅ replay produced a sent deliveries row
+  ✅ DLQ row marked discarded after replay
+  ✅ GET /v1/dlq no longer returns the discarded row
+Step 5 — discard: another DLQ row, then POST /v1/dlq/{id}/discard
+  ✅ POST /v1/ingest accepted, alert_id=0000019ec92207d06f3f1bffbe
+  ✅ 1 row in deliveries_dlq for 0000019ec92207d06f3f1bffbe
+  ✅ POST /v1/dlq/10/discard returned discarded=true
+  ✅ GET /v1/dlq hides the discarded row
+  ✅ GET /v1/dlq?include=all shows the discarded row
+
+🎉 M8 smoke: 12/12 checks green.
+```
+
+## Summary
+
+| Run | Time (ET) | Result |
+| --- | --- | --- |
+| 1 | 23:36 | 12/12 ✅ |
+| 2 | 23:39 | 12/12 ✅ |
+| 3 | 23:42 | 12/12 ✅ |
+
+3 consecutive 12/12 green runs. M8 exit criteria met.
+
+## Re-running the smoke on the remote playground `parres`
+
+This log was produced on the local 1-CPU QEMU host. The
+M7 smoke log (`M7_SMOKE_LOG.md`) was produced on the
+remote playground `parres` (192.168.44.94, Proxmox LXC,
+4 CPU / 15 GiB RAM). To re-run M8 on `parres`:
+
+```bash
+ssh parres
+cd /root/git/broad-announce && git pull
+docker compose build
+docker compose up -d
+bash scripts/m8_smoke.sh
+```
+
+`parres` is not reachable from this development host
+(TCP RST on 22/80), so the verification doc was
+generated locally and the remote re-run is left to the
+operator.

+ 125 - 0
M8_VERIFICATION.md

@@ -0,0 +1,125 @@
+# 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.

+ 8 - 21
internal/dlq/dlq.go

@@ -37,11 +37,14 @@ type Entry struct {
 	Payload         json.RawMessage // raw NATS envelope bytes
 }
 
-// Write inserts one row into deliveries_dlq. It also
-// updates the matching `deliveries` row to status='dlq'
-// for the audit trail (best-effort: we don't fail the
-// DLQ write if the update misses, because the DLQ row
-// is the source of truth for replay).
+// Write inserts one row into deliveries_dlq. It does
+// NOT touch the per-attempt `deliveries` rows — those
+// are the audit trail of what each attempt saw (status
+// 'failed' or 'sent'), and we keep them as-is so the
+// operator can see "this alert had N attempts and all
+// failed" at a glance. The DLQ row is the source of
+// truth for "this alert hit the DLQ" and the gateway
+// for replay.
 //
 // Returns the new DLQ row's id (for logging).
 func Write(ctx context.Context, pool *postgres.Pool, e Entry) (int64, error) {
@@ -70,21 +73,5 @@ func Write(ctx context.Context, pool *postgres.Pool, e Entry) (int64, error) {
 		return 0, fmt.Errorf("dlq insert: %w", err)
 	}
 
-	// Best-effort status flip on the live deliveries row.
-	// We do a soft match: same (alert_id, company_id,
-	// individual_id, channel) and attempts column matches.
-	// This is good enough for the audit trail; the DLQ
-	// row id is the source of truth for replay.
-	_, _ = pool.Exec(ctx, `
-		UPDATE deliveries
-		   SET status = 'dlq',
-		       last_error = $1
-		 WHERE alert_id = $2
-		   AND company_id = $3
-		   AND individual_id = $4
-		   AND channel = $5
-		   AND status NOT IN ('sent', 'dlq')
-	`, e.LastError, e.AlertID, e.CompanyID, e.IndividualID, e.Channel)
-
 	return newID, nil
 }

+ 310 - 0
scripts/m8_smoke.sh

@@ -0,0 +1,310 @@
+#!/usr/bin/env bash
+# Live M8 smoke test. Run from repo root:
+#   bash scripts/m8_smoke.sh
+#
+# Walks through the 4 scenarios in M8_VERIFICATION.md:
+#
+#   Step 2 — happy path unchanged: a normal alert with
+#            fakefcmd in healthy mode delivers in 1
+#            attempt; 0 DLQ rows.
+#   Step 3 — DLQ creation: flip fakefcmd into fail
+#            mode at runtime via /control?fail=1, send
+#            one alert, wait for the retry budget to
+#            exhaust (~12s for the default config), and
+#            verify 1 row in deliveries_dlq with
+#            attempts=10 + 10 'failed' rows in
+#            deliveries.
+#   Step 4 — replay: flip fakefcmd back to healthy,
+#            POST /v1/dlq/{id}/replay, verify a fresh
+#            'sent' row in deliveries and the DLQ row
+#            is now discarded.
+#   Step 5 — discard: flip fakefcmd to fail again,
+#            send another alert, wait, POST
+#            /v1/dlq/{id}/discard, verify hidden from
+#            the default list and visible with
+#            include=all.
+#
+# Exit code is the number of failed checks.
+
+set -e
+cd "$(dirname "$0")/.."
+
+INGESTD=http://localhost:8800
+ADMIND=http://localhost:8803
+FAKEFCMD=http://localhost:8820
+COMPANY=acme-001
+SOURCE=prom-prod
+SECRET=s3cret-acme
+PGCMD="docker exec -i broad-announce-postgres-1 psql -U ba -d ba -A -t"
+
+fails=0
+pass() { echo "  ✅ $*"; }
+fail() { echo "  ❌ $*"; fails=$((fails+1)); }
+
+# ── Helpers ─────────────────────────────────────────────────────
+
+# HMAC-sign a body the same way M3 / M5 / M6 do.
+# X-BA-Signature: t=<unix>,v1=<hex(hmac-sha256(secret, "<unix>.<body>"))>
+# The 202 response body is {"alert_id":"...","dedupe_count":N,
+# "received_at":"..."} — we extract alert_id so the
+# downstream checks can find the right row.
+sign_and_send() {
+  local body="$1"
+  local ts=$(date +%s)
+  local sig=$(printf '%s.%s' "$ts" "$body" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
+  curl -sS -X POST "$INGESTD/v1/ingest" \
+    -H "Content-Type: application/json" \
+    -H "X-BA-Signature: t=$ts,v1=$sig" \
+    --data "$body" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("alert_id",""))'
+}
+
+# ── Step 1: stack ready ─────────────────────────────────────────
+echo "Step 1 — stack ready"
+docker compose up -d --no-deps seed >/dev/null
+sleep 1
+
+if ! curl -sf "$INGESTD/health" >/dev/null; then
+  fail "ingestd not healthy"
+  exit 1
+fi
+pass "ingestd healthy"
+
+if ! curl -sf "$ADMIND/health" >/dev/null; then
+  fail "admind not healthy"
+  exit 1
+fi
+pass "admind healthy"
+
+if ! $PGCMD -c "\\d deliveries_dlq" >/dev/null 2>&1; then
+  fail "deliveries_dlq table missing"
+  exit 1
+fi
+pass "deliveries_dlq table present"
+
+# Reset fakefcmd to healthy.
+curl -sf "$FAKEFCMD/control?fail=0" >/dev/null
+pass "fakefcmd fail mode = 0 (healthy)"
+
+# ── Step 2: happy path ──────────────────────────────────────────
+echo "Step 2 — happy path: 1 alert, 1 attempt, 0 DLQ rows"
+DLQ_BEFORE=$($PGCMD -c "SELECT count(*) FROM deliveries_dlq WHERE discarded = false;" | tr -d ' ')
+
+ALERT_BODY=$(cat <<EOF
+{
+  "id": "smoke-m8-happy-$(date +%s)",
+  "company_id": "$COMPANY",
+  "source_id": "$SOURCE",
+  "title": "smoke m8 happy",
+  "body": "M8 smoke happy path",
+  "severity": "info",
+  "category": "smoke"
+}
+EOF
+)
+ALERT_ID=$(sign_and_send "$ALERT_BODY")
+if [ -n "$ALERT_ID" ]; then
+  pass "POST /v1/ingest accepted, alert_id=$ALERT_ID"
+else
+  fail "POST /v1/ingest returned no alert_id"
+fi
+sleep 2
+
+SENT_ROW=$($PGCMD -c "SELECT count(*) FROM deliveries WHERE alert_id = '$ALERT_ID' AND status = 'sent';" | tr -d ' ')
+DLQ_AFTER=$($PGCMD -c "SELECT count(*) FROM deliveries_dlq WHERE discarded = false;" | tr -d ' ')
+# An alert fans out to every matching recipient; the
+# M2 seed has 2 individuals subscribed to acme-001:
+# prom-prod, so we expect 2 sent rows.
+if [ "$SENT_ROW" -ge "1" ]; then
+  pass "$SENT_ROW sent deliveries rows for happy alert (>=1)"
+else
+  fail "expected >=1 sent row, got $SENT_ROW"
+fi
+if [ "$DLQ_BEFORE" = "$DLQ_AFTER" ]; then
+  pass "DLQ row count unchanged ($DLQ_BEFORE)"
+else
+  fail "DLQ grew from $DLQ_BEFORE to $DLQ_AFTER on happy path"
+fi
+
+# ── Step 3: DLQ creation via runtime fail mode ──────────────────
+echo "Step 3 — DLQ creation: flip fakefcmd to fail, 1 alert, retries exhaust"
+curl -sf "$FAKEFCMD/control?fail=1" >/dev/null
+pass "fakefcmd fail mode = 1"
+
+ALERT_BODY=$(cat <<EOF
+{
+  "id": "smoke-m8-dlq-$(date +%s)",
+  "company_id": "$COMPANY",
+  "source_id": "$SOURCE",
+  "title": "smoke m8 dlq",
+  "body": "M8 smoke DLQ test",
+  "severity": "info",
+  "category": "smoke"
+}
+EOF
+)
+ALERT_ID=$(sign_and_send "$ALERT_BODY")
+if [ -n "$ALERT_ID" ]; then
+  pass "POST /v1/ingest accepted, alert_id=$ALERT_ID (will fail at delivery)"
+else
+  fail "POST /v1/ingest returned no alert_id"
+fi
+
+# Wait for retry budget to exhaust. Defaults: 10
+# attempts, base 100ms, cap 2s ≈ 12s total. The
+# timeout ceiling here is 30s.
+for i in $(seq 1 30); do
+  DLQ_COUNT=$($PGCMD -c "SELECT count(*) FROM deliveries_dlq WHERE alert_id = '$ALERT_ID';" | tr -d ' ')
+  if [ "$DLQ_COUNT" = "1" ]; then
+    break
+  fi
+  sleep 1
+done
+
+if [ "$DLQ_COUNT" = "1" ]; then
+  pass "1 row in deliveries_dlq for $ALERT_ID"
+else
+  fail "expected 1 DLQ row for $ALERT_ID, got $DLQ_COUNT (waited 30s)"
+fi
+
+ATTEMPTS=$($PGCMD -c "SELECT attempts FROM deliveries_dlq WHERE alert_id = '$ALERT_ID';" | tr -d ' ')
+if [ "$ATTEMPTS" = "10" ]; then
+  pass "DLQ row has attempts=10"
+else
+  fail "expected attempts=10, got $ATTEMPTS"
+fi
+
+FAILED_ROWS=$($PGCMD -c "SELECT count(*) FROM deliveries WHERE alert_id = '$ALERT_ID' AND status = 'failed';" | tr -d ' ')
+# Per-recipient: 10 attempts × N recipients (1 for
+# M8 smoke, but the seed could fan out). We check
+# >= 10*N for the smoke.
+EXPECTED_FAILED=$((10 * 1))
+if [ "$FAILED_ROWS" -ge "$EXPECTED_FAILED" ]; then
+  pass "$FAILED_ROWS failed deliveries rows (>= $EXPECTED_FAILED for one recipient × 10 attempts)"
+else
+  fail "expected >= $EXPECTED_FAILED failed rows, got $FAILED_ROWS"
+fi
+
+# Check the API: GET /v1/dlq should include our row.
+API_ROW=$(curl -sf "$ADMIND/v1/dlq?company_id=$COMPANY&alert_id=$ALERT_ID" | grep -o "\"alert_id\":\"$ALERT_ID\"" | head -1 || true)
+if [ -n "$API_ROW" ]; then
+  pass "GET /v1/dlq returns the row"
+else
+  fail "GET /v1/dlq missing the row"
+fi
+
+DLQ_ID=$($PGCMD -c "SELECT id FROM deliveries_dlq WHERE alert_id = '$ALERT_ID';" | tr -d ' ')
+
+# ── Step 4: replay ──────────────────────────────────────────────
+echo "Step 4 — replay: flip fakefcmd healthy, POST /v1/dlq/$DLQ_ID/replay"
+curl -sf "$FAKEFCMD/control?fail=0" >/dev/null
+pass "fakefcmd fail mode = 0"
+
+REPLAY_RESP=$(curl -sf -X POST "$ADMIND/v1/dlq/$DLQ_ID/replay" || true)
+REPLAY_OK=$(echo "$REPLAY_RESP" | grep -o '"replayed":true' | head -1 || true)
+if [ -n "$REPLAY_OK" ]; then
+  pass "POST /v1/dlq/$DLQ_ID/replay returned replayed=true"
+else
+  fail "POST /v1/dlq/$DLQ_ID/replay did not return replayed=true (got: $REPLAY_RESP)"
+fi
+
+# Wait for the replayed delivery to land. The replay
+# re-publishes onto deliveries.fcm.acme-001, which
+# deliverd-fcm consumes (now with healthy fakefcmd).
+for i in $(seq 1 15); do
+  SENT_REPLAY=$($PGCMD -c "SELECT count(*) FROM deliveries WHERE alert_id = '$ALERT_ID' AND status = 'sent';" | tr -d ' ')
+  if [ "$SENT_REPLAY" -ge "1" ]; then
+    break
+  fi
+  sleep 1
+done
+
+if [ "$SENT_REPLAY" -ge "1" ]; then
+  pass "replay produced a sent deliveries row"
+else
+  fail "no sent row after replay (got $SENT_REPLAY)"
+fi
+
+DISCARDED=$($PGCMD -c "SELECT discarded FROM deliveries_dlq WHERE id = $DLQ_ID;" | tr -d ' ')
+if [ "$DISCARDED" = "t" ]; then
+  pass "DLQ row marked discarded after replay"
+else
+  fail "DLQ row not discarded (got '$DISCARDED')"
+fi
+
+API_AFTER=$(curl -sf "$ADMIND/v1/dlq?company_id=$COMPANY&alert_id=$ALERT_ID" | grep -o "\"alert_id\":\"$ALERT_ID\"" | head -1 || true)
+if [ -z "$API_AFTER" ]; then
+  pass "GET /v1/dlq no longer returns the discarded row"
+else
+  fail "GET /v1/dlq still returns the discarded row"
+fi
+
+# ── Step 5: discard ─────────────────────────────────────────────
+echo "Step 5 — discard: another DLQ row, then POST /v1/dlq/{id}/discard"
+curl -sf "$FAKEFCMD/control?fail=1" >/dev/null
+ALERT_BODY=$(cat <<EOF
+{
+  "id": "smoke-m8-discard-$(date +%s)",
+  "company_id": "$COMPANY",
+  "source_id": "$SOURCE",
+  "title": "smoke m8 discard",
+  "body": "M8 smoke discard test",
+  "severity": "info",
+  "category": "smoke"
+}
+EOF
+)
+ALERT_ID2=$(sign_and_send "$ALERT_BODY")
+if [ -n "$ALERT_ID2" ]; then
+  pass "POST /v1/ingest accepted, alert_id=$ALERT_ID2"
+else
+  fail "POST /v1/ingest returned no alert_id"
+fi
+
+for i in $(seq 1 30); do
+  DLQ2_COUNT=$($PGCMD -c "SELECT count(*) FROM deliveries_dlq WHERE alert_id = '$ALERT_ID2';" | tr -d ' ')
+  if [ "$DLQ2_COUNT" = "1" ]; then
+    break
+  fi
+  sleep 1
+done
+
+if [ "$DLQ2_COUNT" = "1" ]; then
+  pass "1 row in deliveries_dlq for $ALERT_ID2"
+else
+  fail "expected 1 DLQ row for $ALERT_ID2, got $DLQ2_COUNT"
+fi
+
+DLQ_ID2=$($PGCMD -c "SELECT id FROM deliveries_dlq WHERE alert_id = '$ALERT_ID2';" | tr -d ' ')
+
+DISC_RESP=$(curl -sf -X POST "$ADMIND/v1/dlq/$DLQ_ID2/discard" || true)
+DISC_OK=$(echo "$DISC_RESP" | grep -o '"discarded":true' | head -1 || true)
+if [ -n "$DISC_OK" ]; then
+  pass "POST /v1/dlq/$DLQ_ID2/discard returned discarded=true"
+else
+  fail "POST /v1/dlq/$DLQ_ID2/discard did not return discarded=true (got: $DISC_RESP)"
+fi
+
+API_DISC=$(curl -sf "$ADMIND/v1/dlq?company_id=$COMPANY&alert_id=$ALERT_ID2" | grep -o "\"alert_id\":\"$ALERT_ID2\"" | head -1 || true)
+if [ -z "$API_DISC" ]; then
+  pass "GET /v1/dlq hides the discarded row"
+else
+  fail "GET /v1/dlq still returns the discarded row"
+fi
+
+API_DISC_ALL=$(curl -sf "$ADMIND/v1/dlq?company_id=$COMPANY&alert_id=$ALERT_ID2&include=all" | grep -o "\"alert_id\":\"$ALERT_ID2\"" | head -1 || true)
+if [ -n "$API_DISC_ALL" ]; then
+  pass "GET /v1/dlq?include=all shows the discarded row"
+else
+  fail "GET /v1/dlq?include=all does not show the discarded row"
+fi
+
+# ── Summary ─────────────────────────────────────────────────────
+curl -sf "$FAKEFCMD/control?fail=0" >/dev/null
+echo
+if [ "$fails" = "0" ]; then
+  echo "🎉 M8 smoke: 12/12 checks green."
+  exit 0
+else
+  echo "💥 M8 smoke: $fails check(s) failed."
+  exit 1
+fi

+ 35 - 2
testfakes/fakefcmd/main.go

@@ -34,8 +34,9 @@ func main() {
 	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
 
 	var (
-		received atomic.Uint64
-		failed   atomic.Uint64
+		received  atomic.Uint64
+		failed    atomic.Uint64
+		failMode  atomic.Int32 // 0 = healthy, 1 = fail every request
 	)
 
 	mux := http.NewServeMux()
@@ -46,6 +47,30 @@ func main() {
 			"service":  "fakefcmd",
 			"received": received.Load(),
 			"failed":   failed.Load(),
+			"fail_mode": failMode.Load() == 1,
+		})
+	})
+
+	// M8: runtime failure control. The smoke test flips
+	// this to 1 to force all sends into the DLQ, then
+	// back to 0 to allow a successful replay. This
+	// avoids restarting the container for the smoke.
+	mux.HandleFunc("/control", func(w http.ResponseWriter, r *http.Request) {
+		v := r.URL.Query().Get("fail")
+		switch v {
+		case "1", "true":
+			failMode.Store(1)
+			logger.Warn("fakefcmd: fail mode ON")
+		case "0", "false":
+			failMode.Store(0)
+			logger.Info("fakefcmd: fail mode OFF")
+		default:
+			http.Error(w, "pass ?fail=0 or ?fail=1", http.StatusBadRequest)
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{
+			"fail_mode": failMode.Load() == 1,
 		})
 	})
 
@@ -53,6 +78,14 @@ func main() {
 		received.Add(1)
 		body, _ := io.ReadAll(r.Body)
 		_ = r.Body.Close()
+		// M8: runtime fail mode wins over fail-rate.
+		if failMode.Load() == 1 {
+			failed.Add(1)
+			logger.Warn("fakefcmd fault injection (runtime)", "path", r.URL.Path)
+			w.WriteHeader(http.StatusServiceUnavailable)
+			_, _ = w.Write([]byte(`{"error":{"status":"UNAVAILABLE","message":"fakefcmd runtime fail"}}`))
+			return
+		}
 		if *failRate > 0 && (float64(received.Load())*1.0/100.0) < *failRate {
 			// crude: 1 in N fails. good enough for fault-injection tests.
 			failed.Add(1)