# M10 — Detailed Implementation Plan **Status:** planning (post-M9) **Target:** M10 exit criteria from SPEC §23 **Goal:** Prove the system handles 5k/s sustained load on docker-compose with strict per-source isolation. --- ## 0. Recap — what M10 must prove | Milestone | Exit criterion | How it's measured | |---|---|---| | **M10** | soak 10 min at 5k/s, p99 ≤ 5s, zero DLQ, runaway-source test passes | `m10_smoke.py` runs the load, queries Prometheus, asserts on deltas | | **M10-bench** | 50k/s via `loadgen` against broker+router with delivery stubbed; p99 router latency ≤ 50ms; no broker backpressure | `m10_bench_smoke.py` against the bench profile | The current loadgen is one-shot. The pacing primitive (`--rate` + `--duration`) already exists in all 4 drivers — what's missing is *coordination*, *assertion*, and *fault injection*. --- ## 1. Workstream overview ``` ┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐ │ W1: loadgen scale-out │ │ W2: assertion harness │ │ W3: bench profile │ │ (5k/s on docker-compose)│ │ (Prometheus + DLQ check)│ │ (50k/s broker+router) │ └──────────────┬───────────┘ └──────────────┬───────────┘ └──────────────┬───────────┘ │ │ │ └───────────────┬───────────────┴───────────────┬───────────────┘ ▼ ▼ ┌──────────────────────────┐ ┌──────────────────────────┐ │ W4: m10_smoke.py │ │ W5: m10_bench_smoke.py │ │ + runaway-source test │ │ (delivery stub mode) │ └──────────────┬───────────┘ └──────────────┬───────────┘ └───────────────┬───────────────┘ ▼ M10_VERIFICATION.md M10_SMOKE_LOG.md 3 consecutive green runs ``` Five workstreams, each with its own commit series. Order matters: W1+W3 are independent and can be parallel; W2 needs W1's metrics; W4 needs W1+W2; W5 needs W3+W2. --- ## 2. Workstream 1 — `loadgen` scale-out (5k/s) ### 2.1 Problem The current `loadgen-http --rate=5000` on one container can drive at most ~1.5–2k/s in practice (Go HTTP client + JSON marshal + HMAC sign per request saturates a single CPU). To hit 5k/s sustained, we need **3 instances** at ~1.7k/s each. ### 2.2 Design **Option A (chosen):** `loadgen-orchestrator` — a thin shell script / Go binary that: 1. Reads `--cluster-target=N` (e.g. 5000) 2. Discovers `loadgen-http-N` instances via `docker compose ps` (profile `loadgen`) 3. Computes per-instance rate = `N / instance_count` 4. Calls `docker compose exec loadgen-http-N /app/loadgen-http --rate=$((N/k)) --duration=...` 5. Tears down on signal **Option B (rejected for v1):** NATS KV coordination (SPEC §13.1) — overkill for 3 instances, adds a dep on NATS, and the SPEC explicitly says "best-effort" coordination is fine. **Why A:** keeps the existing `loadgen-http` binary unchanged; orchestration is a thin layer above it; no new code in the loadgen drivers; works identically on the host and inside CI. ### 2.3 Concrete changes **`docker-compose.yml` — add 3 scaled `loadgen-http` services:** ```yaml loadgen-http-1: <<: *loadgen-base command: - /app/loadgen-http - --target=http://ingestd:8800 - --api-key=acme-001:prom-prod:s3cret-acme - --mode=normal - --rate=1700 - --duration=10m - --metrics=:8891 - --ramp-up=30s profiles: ["loadgen"] loadgen-http-2: # same with --api-key=acme-002:prom-prod:s3cret-acme2 loadgen-http-3: # same with --api-key=acme-003:prom-prod:s3cret-acme3 ``` Use 3 different `acme-00X` API keys so the metrics naturally break down per-source. The 3 sources share a company (`prom-prod` in `acme-001:prom-prod:...`), so per-company rate limit (M5 layer 4) is the only thing that could cause cross-source coupling. We deliberately set per-company limit high enough to not engage. ### 2.4 New flag: `--ramp-up` The current `loadgen-http` jumps from 0 to `--rate` instantly. For soak tests we want a smooth ramp so transient cold-start effects (NATs connection pool, Redis hot cache, JIT) don't poison the first 30s of measurements. **Add to all 4 drivers:** ``` --ramp-up duration "linear ramp from 0 to --rate over this duration (default 0 = instant)" ``` Implementation: replace the fixed `time.NewTicker(time.Second / time.Duration(*rate+1))` with a `pacer` struct that interpolates the rate over the ramp window. No other behavior change. ### 2.5 New flag: `--cluster-id` Add to the metric labels so we can distinguish "loadgen run 2026-06-15" from "loadgen run 2026-06-16": ``` --cluster-id string "tag added as label on all loadgen metrics (default: 'default')" ``` The metrics endpoint becomes: ``` loadgen_alerts_sent_total{profile="normal",instance="loadgen-http-1",cluster_id="m10-2026-06-15",mode="normal"} 12345 ``` ### 2.6 Files to touch | File | Change | |---|---| | `loadgen/cmd/http/main.go` | +`--ramp-up`, +`--cluster-id`, +`pacer` struct | | `loadgen/cmd/mqtt/main.go` | same | | `loadgen/cmd/ws/main.go` | same | | `loadgen/cmd/m5drivers/main.go` (if it exists) | same | | `loadgen/internal/pacer/pacer.go` | **NEW** — shared pacer with linear ramp | | `loadgen/internal/pacer/pacer_test.go` | **NEW** — 4 tests (linear, instant, overshoot, ctx-cancel) | | `docker-compose.yml` | add `loadgen-http-{1,2,3}` | | `Dockerfile` | rebuild needed; no new binary | **Commit series:** `M10(1/5): loadgen pacer + ramp-up` → `M10(1.5/5): 3-instance loadgen profile` --- ## 3. Workstream 2 — Assertion harness ### 3.1 Problem We need to assert: p99 ingest latency ≤ 5s, DLQ row count = 0, runaway source isolation. Today nothing reads Prometheus to make assertions. ### 3.2 Design `scripts/m10_lib.py` — shared library used by both `m10_smoke.py` and `m10_bench_smoke.py`: ```python def assert_ingest_p99_under(threshold_seconds: float, window: str, label_filter: str = "") -> float def assert_dlq_count_equals(expected: int, window: str) -> int def assert_metric_increased(metric: str, min_delta: int, window: str) -> int def per_source_p99(source_id: str) -> float def scrape_prometheus(query: str) -> list[dict] ``` All functions use the same URL-encoding fix as `m9_smoke.py` (urllib.parse.quote with `safe=''`). The library is imported by both smoke scripts. ### 3.3 Latency source Ingest latency is in `ba_ingestd_publish_latency_seconds_bucket` (from M9). This measures `time.Since(start)` from the moment we accept the alert to the moment NATS `Publish` returns. That's the right metric for "ingest latency" — it includes the queue + broker write. For p99, we use Prometheus `histogram_quantile(0.99, ...)` over a sliding 60s window. ### 3.4 DLQ source The `deliveries_dlq` table (M8 hypertable) is the source of truth. Two ways to query: - **SQL:** `SELECT COUNT(*) FROM deliveries_dlq WHERE created_at > now() - interval '10 minutes' AND discarded = false;` (run via `docker exec postgres`) - **Prometheus:** `ba_deliverd_dlq_total` (M9 counter). Less precise (no discarded filter) but no DB hop. We use the Prometheus counter for the smoke (faster) and the SQL count for the post-run forensic log (more precise). ### 3.5 Per-source isolation check For the runaway-source test, the assertion is: - Source X fires at 10× its rate cap, gets rate-limited - Sources Y, Z run normally - `per_source_p99("acme-002")` and `per_source_p99("acme-003")` stay ≤ 5s - `per_source_p99("acme-001")` (the runaway) can be anything The metric we use: filter `ba_ingestd_publish_latency_seconds_bucket` by `source_id` label. But current ingestd doesn't label the latency histogram by source. We need to add that label. **Change to `internal/observability/metrics.go`:** ```go PublishLatency: prometheus.NewHistogramVec(prometheus.HistogramOpts{ Name: "publish_latency_seconds", ... }, []string{"source_id"}), // ← was: []string{} (no labels) ``` Backfill: rebuild + redeploy ingestd. Backwards-compatible (new label dimension, no series removed). **Commit series:** `M10(2/5): per-source latency histogram label` → `M10(2.5/5): m10_lib.py assertion harness` --- ## 4. Workstream 3 — Bench profile (50k/s) ### 4.1 Problem To prove the broker+router ceiling, we need to push 50k/s through the broker without burning FCM credits. We need a no-op `deliverd` that ACKs the broker message but does nothing. ### 4.2 Design **New binary: `cmd/deliverd-bench/main.go`** — ~30 lines: ```go // Pulls from NATS subject ba.*.deliveries, acks immediately, increments a counter. // No HTTP, no retry, no DLQ. Just consume and forget. ``` **Compose profile: `bench`** The bench profile: - Same broker + router + ingestd - `deliverd-bench` instead of `deliverd-fcm` and `deliverd-telegram` - Multiple `loadgen-grpc` instances (future) or `loadgen-http` instances (M10 v1) - `--cluster-target=50000` orchestrator target ### 4.3 Concrete changes **`cmd/deliverd-bench/main.go`** — NEW **`docker-compose.yml`** — add `bench` profile: ```yaml deliverd-bench: build: . command: ["/app/deliverd-bench"] environment: BA_ENV: dev BA_NATS_URL: nats://nats:4222 profiles: ["bench"] depends_on: nats: { condition: service_healthy } ``` **`scripts/m10_bench_smoke.py`** — NEW, asserts: - `ba_routerd_recipient_expansion_seconds_bucket` p99 ≤ 50ms - `deliverd-bench` consumes everything (no NATS subject lag) - Broker's `nats_varz_depth` queue depth stays < 1000 (no backpressure) **`Dockerfile`** — add `deliverd-bench` to the build chain. **Commit series:** `M10(3/5): deliverd-bench no-op + bench profile` → `M10(3.5/5): m10_bench_smoke.py` --- ## 5. Workstream 4 — `m10_smoke.py` + runaway-source test ### 5.1 Structure ``` m10_smoke.py ├── Step 1 — pre-flight │ • All 4 loadgen-http-{1,2,3} containers up │ • Ingestd, routerd, prometheus, deliverds healthy │ • DLQ table is empty (baseline: SELECT count from deliveries_dlq) │ ├── Step 2 — 5k/s soak (10 min) │ • Start loadgen-orchestrator --cluster-target=5000 --duration=10m │ • Sleep 30s for ramp-up │ • Sample Prometheus every 10s: │ - ba_ingestd_alerts_received_total{result="accepted"} rate → should be 5000±5% │ - ba_ingestd_publish_latency_seconds p99 (60s window) → ≤ 5s │ - ba_deliverd_dlq_total delta → must be 0 │ • Hard fail on any breach; collect samples for the log │ ├── Step 3 — runaway-source fault injection (60s) │ • Start 1 loadgen instance pinned to acme-001 at 10× cap (--rate=5000) │ • Background: keep loadgen-http-2/3 running normally at 1700 each │ • Assert: per_source_p99(acme-002) ≤ 5s and per_source_p99(acme-003) ≤ 5s │ • acme-001 (the runaway) p99 is allowed to be anything (it's getting rate-limited) │ ├── Step 4 — DLQ invariant │ • SELECT COUNT(*) FROM deliveries_dlq WHERE created_at > (start of step 2) │ • Must be 0 (or the run is a hard fail) │ ├── Step 5 — teardown │ • docker compose --profile loadgen down │ • Print summary table ``` ### 5.2 Run schedule (matches existing milestone pattern) - Run 1: local docker-compose → write M10_SMOKE_LOG.md - Run 2: local docker-compose → 3 consecutive green required - Run 3: local docker-compose → 3 consecutive green required - Run 4: parres remote (192.168.44.94) → 1 green required ### 5.3 Files | File | Change | |---|---| | `scripts/m10_smoke.py` | **NEW** — 5-step smoke + runaway test | | `scripts/m10_lib.py` | **NEW** — assertion library (shared with bench) | | `scripts/loadgen-orchestrator.sh` | **NEW** — thin shell orchestrator (3 instances) | | `M10_VERIFICATION.md` | NEW — exit criteria mapping + new env vars + new files | | `M10_SMOKE_LOG.md` | NEW — 3-consecutive-green evidence | **Commit series:** `M10(4/5): m10_smoke.py with runaway-source test` → `M10(4.5/5): M10 verification docs + 3 green runs` --- ## 6. Workstream 5 — `m10_bench_smoke.py` (50k/s) ### 6.1 Structure ``` m10_bench_smoke.py ├── Step 1 — bench profile up │ • docker compose --profile bench up -d │ • Verify deliverd-bench is consuming │ ├── Step 2 — 50k/s cluster-wide for 5 min (shorter than 5k/s — broker can take it but we save time) │ • 10 loadgen-http instances at 5000 each → cluster-target=50000 │ • Sample: ba_routerd_recipient_expansion_seconds p99 ≤ 50ms │ • Sample: nats_varz_depth < 1000 (no backpressure) │ ├── Step 3 — broker ceiling check │ • If routerd p99 holds ≤ 50ms and NATS queue depth stays low → bench passes │ ├── Step 4 — teardown ``` ### 6.2 Why a separate smoke 50k/s generates ~3M alerts over 5 min. The `deliveries` table fills fast. We use a separate `bench` profile so: - Bench deliveries are never inserted into `deliveries` (deliverd-bench doesn't touch Postgres) - Bench runs don't pollute the production alerting tables ### 6.3 Files | File | Change | |---|---| | `scripts/m10_bench_smoke.py` | **NEW** — broker+router ceiling test | | `M10_BENCH_VERIFICATION.md` | NEW — bench profile scope + exit criteria | **Commit series:** `M10(5/5): m10_bench_smoke.py + bench verification` --- ## 7. Risk register | Risk | Likelihood | Impact | Mitigation | |---|---|---|---| | Per-source histogram label changes an existing dashboard panel | Med | Low | Update `broad-announce-overview.json` row 2 in W2 | | 5k/s saturates docker-compose NATS single-broker | Med | High | If `nats_varz_depth` > 10k, scale broker horizontally (out of M10 scope) | | Loadgen-orchestrator can't get exactly 5k/s (jitter) | Med | Low | Accept ±5% per SPEC §13.1; assert in smoke | | 10min soak = 3M alerts = 3M `deliveries` rows = OOM in deliverd postgres pool | Med | Med | Add `--pg-pool-max=20` to deliverd env (already M7 default) | | Runaway source causes OOM kill on ingestd | Low | High | M9 layer 7 (quarantine) auto-bans the source after threshold | | `loadgen-grpc` doesn't exist yet (M11) | High | Med | Use 3× `loadgen-http` instances for M10, not `loadgen-grpc`; note in M10_VERIFICATION.md that M11 will rerun with gRPC | | Run takes >30 min (10 min soak + ramp + teardown) | Med | Low | Smoke runner has no hard timeout, but we document the expected wall-clock | --- ## 8. Estimated effort | Workstream | LoC (new) | LoC (changed) | Commits | Wall-clock estimate | |---|---|---|---|---| | W1: loadgen scale-out | ~80 (pacer) | ~30 (drivers) | 2 | 1 session | | W2: assertion harness | ~150 (m10_lib) | ~5 (metric label) | 2 | 1 session | | W3: bench profile | ~30 (deliverd-bench) | ~30 (compose) | 2 | ½ session | | W4: m10_smoke.py + docs | ~250 (smoke) | ~0 | 2 | 1 session | | W5: m10_bench_smoke.py | ~120 (smoke) | ~0 | 1 | ½ session | | **Total** | **~630 LoC** | **~65 LoC** | **9 commits** | **~4 sessions** | The 5k/s smoke itself is 10 min wall-clock. The bench smoke is 5 min. With 3 consecutive green runs + 1 remote, expect a full M10 cycle to be **~1 working day** including verification doc writing. --- ## 9. Definition of done (M10 sign-off) - [ ] All 9 commits pushed to `master` - [ ] `m10_smoke.py` green on local: **3 consecutive runs**, all assertions pass - [ ] `m10_smoke.py` green on `parres` remote (192.168.44.94): 1 run - [ ] `m10_bench_smoke.py` green on local: 1 run - [ ] `M10_VERIFICATION.md` written - [ ] `M10_SMOKE_LOG.md` written (3-run summary + raw captures) - [ ] `M10_BENCH_VERIFICATION.md` written - [ ] `M10_PLAN.md` (this file) moved from `M10_PLAN.md` to `docs/m10_plan.md` (or kept at root per convention) - [ ] SPEC.md bumped: `M10 | shipped YYYY-MM-DD` - [ ] Commit `M10(6/5): SPEC.md bump to 'shipped ...'` (yes, 6/5, on purpose — we always over-ship one doc commit) --- ## 10. What is explicitly OUT of M10 To keep the milestone bounded: - **gRPC ingest** (M11). `loadgen-grpc` doesn't exist yet. We use 3× `loadgen-http` instances; M11 will rerun M10 with `loadgen-grpc` to confirm gRPC doesn't regress the numbers. - **Multi-broker NATS cluster** (M12). M10 proves the single-broker ceiling. If 5k/s breaks NATS, we open M10.5 to fix it (out of scope here). - **Chaos Mesh / toxiproxy** (M13+). The runaway-source test is the only fault injection in M10. Network partitions and broker kills come later. - **Production hardening of `loadgen`** (CLI ergonomics, distribution tarball). M10 ships a working tool; v2 ships the polished one. --- ## 11. Open questions for the user 1. **Sustained rate variance:** SPEC §13.1 says "±5% over/undershoot acceptable for soak". Do you want tighter (±2%) and is the extra orchestration worth it? 2. **Per-company rate limit during soak:** the 3 acme sources all share company `prom-prod`. Per-company cap defaults to what? If it's 1k/s the soak will fail at 5k/s. We need to either bump the cap or split across companies. 3. **Runaway-source target:** SPEC says "10× cap". What's the per-source cap in the current config? If it's 100/s, the runaway fires at 1000/s, which is well below 5k/s. Is that enough to prove isolation or do you want a more aggressive 50× cap? 4. **Bench profile storage:** the bench profile runs don't touch `deliveries` (deliverd-bench is no-op), but they do touch the `alerts` table via ingestd. Do you want a separate `prom_bench` company_id so the bench alerts don't show in the production admin UI?