M10_PLAN.md 18 KB

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:

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-upM10(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:

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:

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 labelM10(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:

// 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:

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 profileM10(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 testM10(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?