Explorar el Código

M10: detailed implementation plan (5 workstreams, 9 commits, ~4 sessions)

Luis Rosales hace 1 mes
padre
commit
316400dea1
Se han modificado 1 ficheros con 387 adiciones y 0 borrados
  1. 387 0
      M10_PLAN.md

+ 387 - 0
M10_PLAN.md

@@ -0,0 +1,387 @@
+# 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?