# M11 Verification

> Filled in after the 2026-06-16 parres run.
> **Updated 2026-06-16 14:48 EDT — M11 dev-playground gate shipped CONDITIONALLY. Receive rate is green; publish path is broken. See "Post-ship finding" below.**

## Test environment

- **Local**: `docker compose --profile loadgen-grpc` (not run for this gate)
- **Remote**: `parres` (192.168.44.94, 4 cores, shared with 7 user sessions + ollama + prometheus + clickhouse + grafana always-on)
- **Smoke run log**: `/root/smoke-logs/m11-6k-20260616-100031.log`
- **Re-verification log**: `/root/smoke-logs/m11-2min-final-20260616-124829.log`

## Exit criteria

The M11 spec target (per `M11_PLAN.md` §7.3 and SPEC §19) is **10k/s sustained 10 min, p99 ≤ 50ms, DLQ=0** on a prod-shape cluster. On the parres dev playground the per-instance ceiling is lower than the spec target, so we ship a **two-tier gate**:

| Gate | Target rate | p99 | DLQ | Hardware |
|---|---|---|---|---|
| **M11 dev-playground gate** (this run) | ≥ 6,000 alerts/s | ≤ 50 ms | = 0 | parres (4 cores, shared) |
| **M11 prod gate** (M12) | ≥ 10,000 alerts/s | ≤ 50 ms | = 0 | prod-shape cluster (≥ 8 cores, dedicated) |

Horizontal-scaling story: M10-bench (`M10_BENCH_VERIFICATION.md`) already proves the **broker + router ceiling** at 50k/s with delivery stubbed, so the path from per-instance → aggregate is broker fan-out, not new architecture. N × ingestd on prod-shape cores = N × per-instance ceiling.

## Soak samples

`python3 scripts/m11_smoke.py` — step 2 (10-min soak, 2-instance gRPC loadgen cluster targeting 6k/s, ramp 30s):

| Time | Rate/s | p99 (ms) | DLQ | Streams |
|---|---|---|---|---|
| 0m  | 9376 | 24.8 | 0 | 32 |
| 1m  | 9481 | nan  | 0 | 32 |
| 1m  | 9550 | nan  | 0 | 32 |
| 2m  | 9562 | nan  | 0 | 32 |
| 2m  | 9527 | nan  | 0 | 32 |
| 3m  | 9574 | 24.8 | 0 | 32 |
| 3m  | 9475 | 24.8 | 0 | 32 |
| 4m  | 9578 | nan  | 0 | 32 |
| 4m  | 9569 | nan  | 0 | 32 |
| 5m  | 9480 | nan  | 0 | 32 |
| 5m  | 9606 | nan  | 0 | 32 |
| 6m  | 9683 | nan  | 0 | 32 |
| 6m  | 9501 | 24.8 | 0 | 32 |
| 7m  | 9483 | nan  | 0 | 32 |
| 7m  | 9678 | 24.8 | 0 | 32 |
| 8m  | 9543 | nan  | 0 | 32 |
| 8m  | 9494 | nan  | 0 | 32 |
| 9m  | 9646 | nan  | 0 | 32 |
| 9m  | 9563 | nan  | 0 | 32 |
| 10m | 9473 | nan  | 0 | 32 |

**Summary (RECEIVE RATE ONLY — see Post-ship finding for publish-path status):** 20/20 samples green, min rate 9376/s (156% of 6k target), max 9683/s, p99 ≤ 25ms throughout, DLQ = 0, 32 gRPC streams sustained. The cluster is configured at 8k/s per loadgen in `docker-compose.yml`; the per-instance ceiling on parres (4 cores) caps aggregate at ~9.5k/s, which is well above the dev-playground gate. The "nan" p99s are sparse histogram windows at 60s scrape (p99 query needs more samples to converge); the windows with data all read the same 24.8ms bucket.

**CRITICAL CAVEAT (see Post-ship finding):** The rate metric used is `ba_ingestd_alerts_received_total{service="ingestd"}` — this counts alerts **received via gRPC**, not alerts **successfully published to NATS**. The 10-min soak is therefore a green signal on the transport receive path, not on end-to-end delivery.

## Backpressure test result

> Step 4 — multi-stream backpressure test (16 streams × 600/s = 9.6k/s aggregate, 20s)

> ❌ **DEFERRED.** The 16-stream loadgen (instance `loadgen-grpc-bp`) did not honor `--duration=20s` and failed to exit within the smoke's 60s wait window. This is the same `--duration` regression from commit `48ecaba` ("fix: loadgen producer must respect --duration under backpressure") surfacing under the new cluster contention. The smoke process exited with code 1; the backpressure loadgen was cleaned up manually. Fix is in the loadgen, not the server; transport-level backpressure is otherwise exercised by the 256 in-flight cap and per-source rate limiter (`BA_INGESTD_RATE_LIMIT_PER_SOURCE=20000`), both of which held during the soak.

**Update 2026-06-16 14:48 EDT:** The hang is **fixed** in commit `7f7928b` (parres) / `39907d1` (local). Verified in isolation: a 15s backpressure loadgen now exits cleanly in 17s with exit code 0 (sent 142,304 alerts, p99 RTT 10-25ms). However, when run via the smoke script, `docker compose run --rm` for the backpressure loadgen now returns non-zero (loadgen process itself exits 0; the cause is in the smoke's `subprocess.Popen` stderr capture or a docker compose edge case). New exit-code issue deferred for separate investigation.

## Parres run

- **Log:** `/root/smoke-logs/m11-6k-20260616-100031.log`
- **Repo at run:** `09f0d54` + smoke-threshold commit + loadgen fix commits
- **Result:** dev-playground gate **PARTIALLY GREEN** — receive rate 20/20 green; publish path broken (see Post-ship finding).

## Post-ship finding (2026-06-16 14:48 EDT) — NATS JetStream resource limits exceeded

After the 10-min soak was green, the user requested a 2-min @ 6k/s re-verification on a freshly-cleaned parres (all broad-announce containers recreated, ollama preserved). Result:

| Time | Rate/s | p99 (ms) | DLQ | Status |
|---|---|---|---|---|
| 0m | 9558 | nan | 0 | ✅ |
| 1m | 9748 | nan | 0 | ✅ |
| 1m | 9716 | nan | 0 | ✅ |
| 2m | **0**  | —    | — | ❌ rate collapse |

**Root cause (NATS + ingestd logs):**

NATS is logging `[ERR] JetStream resource limits exceeded for server` every 10 seconds throughout the smoke (first occurrence ~11s after `docker compose up`). Once the limit is hit, ingestd's `nats publish` calls fail with `context canceled` and alerts stop being processed. The gRPC receive rate collapses to 0 within ~30s of the limit being hit because the loadgens' per-stream contexts cancel and they stop sending.

**Why the 10-min soak didn't catch this:**

The smoke's rate metric (`ba_ingestd_alerts_received_total{service="ingestd"}`) counts alerts **received via gRPC**, not alerts **successfully published to NATS**. ingestd was acking alerts that NATS was rejecting. The 10-min soak was green on the receive rate; the system was actually broken end-to-end on the publish path the whole time.

**Implication for M11:**

The M11 dev-playground gate is shipped, but the gRPC → NATS → delivery pipeline is broken under sustained load. M11 cannot be declared fully shipped until **both**:

1. NATS JetStream resource limits are raised (config investigation deferred), or the smoke is updated to assert on **publish success** rather than (or in addition to) receive rate, AND
2. The full 5-step smoke (soak + backpressure + DLQ invariant + teardown) runs green end-to-end with the new assertion

## Known issues

1. **NATS JetStream resource limits exceeded** (post-ship finding, 2026-06-16 14:48 EDT) — under sustained load (~6k/s for >1.5 min), NATS hits an internal resource limit (memory? storage? max messages per stream?) and starts rejecting publishes. ingestd logs `"nats publish"` errors with `"context canceled"`. The smoke's receive-rate metric does not catch this; a publish-success assertion (`rate(ba_ingestd_nats_publish_ok_total[30s])` or similar) is required. NATS config investigation deferred.
2. **Loadgen backpressure test exits non-zero** (2026-06-16 14:48 EDT) — the hang is fixed (`7f7928b` / `39907d1`), but `docker compose run --rm` for the backpressure loadgen now returns non-zero. The loadgen process itself exits 0; the cause is in the smoke's `subprocess.Popen` stderr capture or a docker compose edge case. Investigation deferred.
3. **Loadgen `--duration` regression** (pre-ship) — the backpressure loadgen does not exit cleanly after `--duration=N` under cluster contention. **Resolved** in `7f7928b` / `39907d1` (main() now exits on worker completion, not just on SIGTERM).
4. **p99 sparse windows** (pre-ship) — `m11_lib.get_grpc_p99()` reads `ba_ingestd_grpc_ack_latency_seconds_bucket` over a 60s window. At ~9.5k/s with 32 streams, individual bucket counts are high enough for convergence, but the metric does not always have data at the exact 60s scrape instant. Window selection (90s or 120s) would densify coverage; not blocking for the M11 gate since all populated windows read the same value.

## Sign-off

- [⚠️] **M11 dev-playground gate** SHIPPED CONDITIONALLY — receive rate green, publish path broken (NATS resource limit)
- [x] Receive-rate assertion: 20/20 soak samples green at 9376-9683/s, p99 ≤ 25ms, DLQ=0
- [ ] Publish-success assertion: **NOT YET** — needs smoke update + NATS limit fix
- [ ] 3/3 local runs green (deferred — dev-playground gate already tested on parres)
- [ ] M11 prod gate (10k/s, prod-shape cluster) — planned for M12
- [x] Backpressure step hang fixed (`7f7928b` / `39907d1`); new exit-code issue deferred
- [⚠️] SPEC.md M11 row updated to **shipped conditionally 2026-06-16**

**Bottom line:** M11 is not fully shippable. The receive path works. The publish path (gRPC → NATS → delivery) is broken under sustained load due to NATS resource limits. Re-verification needed after NATS config is tuned and the smoke is updated to assert on publish success.

---

## F1 NATS fix verification — 2026-06-16 14:00 EDT

**What was wrong:** the 2-min re-verification (2026-06-16 14:48 EDT) on the
M11 conditional ship showed the rate collapsing from 9.7k/s → 0/s at the 2m
mark. Root cause: NATS JetStream hit its server-level `max_storage` cap
(5.46 GiB default on parres with 6.5 GiB free) because ALERTS stream's
24h `MaxAge` allowed 6.1 GiB of test data to accumulate. The 10-min soak
that "shipped" M11 was a false positive: `ba_ingestd_alerts_received_total`
counts gRPC receive, not NATS publish success.

**The fix (commits f450196, 6c82dcf, 82dbc5a, 09d5584):**

| Change | File | Effect |
|---|---|---|
| ALERTS `MaxAge` 24h → 1h + `MaxBytes`=1 GiB + `DiscardOld` | `internal/broker/broker.go` | ALERTS self-trims; producer never blocks |
| DELIVERIES `MaxBytes`=100 MiB | `internal/broker/broker.go` | Cap on delivery stream |
| DLQ `MaxAge` 7d → 1h + `MaxBytes`=10 MiB | `internal/broker/broker.go` | DLQ is operational, not archival |
| NATS server `max_file_store`=10 GiB via `nats.conf` | `deploy/nats/nats.conf` + `docker-compose.yml` | Explicit server-level cap; 2.10 doesn't expose these as CLI flags |
| Smoke teardown: drop `-v` | `scripts/m11_smoke.py` | Was destroying pgdata/natsdata/chdata every run |

**Verification (smoke run `m11-fix2-20260616-140053`):**

```
[0m] rate=7548/s p99=nanms dlq=0 streams=32
[1m] rate=7698/s p99=nanms dlq=0 streams=32
[1m] rate=7638/s p99=24.9ms dlq=0 streams=32
[2m] rate=7696/s p99=nanms dlq=0 streams=32
... (20/20 samples) ...
[10m] rate=7208/s p99=nanms dlq=0 streams=32

Step 4 — multi-stream backpressure: ✅ no crashes, 0 rate-limited acks
  (per-source cap 20k/s > 9.6k/s aggregate, no backpressure expected)
Step 5 — DLQ invariant: ✅ 0 over 10min
Step 6 — teardown: ✅ named volumes preserved (pgdata/natsdata/chdata intact)

🎉 M11 smoke: all checks complete.
```

**On-disk evidence of the cap working:**

- ALERTS `meta.inf` has `max_age: 3600000000000` (1h) and `max_bytes: 1073741824` (1 GiB)
- During the soak, ALERTS bytes stabilized at **exactly 1,024 MiB** (the cap)
  — old messages trimmed as new ones arrived, no producer stall
- `varz` reports `max_storage: 10 GiB`, current `storage: 0 B` after smoke
- **Zero `[ERR] JetStream resource limits exceeded` log lines** during the entire 12-min smoke

**Status:** M11 dev-playground gate now SHIPPED (properly, not conditionally).
See commit `f450196` for the broker.go change, `82dbc5a` for the
docker-compose + nats.conf, and `09d5584` for the smoke teardown fix.

**What F2 still owes us:** a `ba_ingestd_nats_publish_total{result="ok"}`
counter plus a smoke assertion that publish rate ≥ receive rate, so the
next class of "system looks healthy but publishes are silently failing"
bug gets caught at smoke time. The `M11_NATS_INVESTIGATION.md` doc
proposes this in the medium-term section.

---

## F2 verification — 2026-06-16 16:55 EDT (smoke `m11-f2-*`)

**What F2 adds:** the M11 NATS investigation's medium-term layer — a
publish-success counter on ingestd, a smoke assertion that the publish
path tracks the receive path, and PromQL alerts that would have fired
on the original NATS issue.

**Changes:**

| Change | File |
|---|---|
| `ba_ingestd_nats_publish_total{result=ok|error}` counter | `internal/observability/metrics.go` + `internal/pipeline/pipeline.go` (4 sites: 2 submission errors + observeAsyncAck ok + observeAsyncAck error) |
| `reg.MustRegister` includes the new counter | `internal/observability/metrics.go` (549d1e3 — the original commit forgot this) |
| `assert_nats_publish_rate_near(target, tolerance, window)` | `scripts/m11_lib.py` |
| Per-sample `publish_ok` column in the soak log | `scripts/m11_smoke.py` |
| `publish_ok` column in the final summary table | `scripts/m11_smoke.py` (dc71b38) |
| 4 PromQL alerts (NATS storage + publish path) | `deploy/prometheus/rules/nats.yml` (new) |
| `rule_files` + rule volume mount | `deploy/prometheus/prometheus.yml` + `docker-compose.yml` |

**Smoke run (`m11-f2-*`):**

```
[0m]  rate=7443/s publish_ok=7454/s p99=nanms  dlq=0 streams=32
[1m]  rate=7610/s publish_ok=7614/s p99=nanms  dlq=0 streams=32
[1m]  rate=7133/s publish_ok=7133/s p99=nanms  dlq=0 streams=32
[2m]  rate=7114/s publish_ok=7115/s p99=nanms  dlq=0 streams=32
[2m]  rate=7093/s publish_ok=7094/s p99=nanms  dlq=0 streams=32
[3m]  rate=7130/s publish_ok=7129/s p99=nanms  dlq=0 streams=32
[3m]  rate=7141/s publish_ok=7144/s p99=nanms  dlq=0 streams=32
[4m]  rate=7087/s publish_ok=7087/s p99=nanms  dlq=0 streams=32
[4m]  rate=7134/s publish_ok=7135/s p99=nanms  dlq=0 streams=32
[5m]  rate=7147/s publish_ok=7147/s p99=25.0ms dlq=0 streams=32
[5m]  rate=7132/s publish_ok=7133/s p99=nanms  dlq=0 streams=32
[6m]  rate=7098/s publish_ok=7099/s p99=25.0ms dlq=0 streams=32
[6m]  rate=7081/s publish_ok=7081/s p99=25.0ms dlq=0 streams=32
[7m]  rate=7052/s publish_ok=7053/s p99=25.0ms dlq=0 streams=32
[7m]  rate=7122/s publish_ok=7123/s p99=nanms  dlq=0 streams=32
[8m]  rate=7080/s publish_ok=7081/s p99=nanms  dlq=0 streams=32
[8m]  rate=7110/s publish_ok=7110/s p99=nanms  dlq=0 streams=32
[9m]  rate=7080/s publish_ok=7077/s p99=nanms  dlq=0 streams=32
[9m]  rate=7048/s publish_ok=7048/s p99=nanms  dlq=0 streams=32
[10m] rate=7163/s publish_ok=7162/s p99=25.0ms dlq=0 streams=32

Step 4 — multi-stream backpressure (16 streams × 1k/s):
  ✅ 16-stream backpressure loadgen ran without crashes
  ✅ rate-limited acks observed: 33 (backpressure working)
Step 5 — DLQ invariant: 0 over 10min soak
Step 6 — teardown: named volumes preserved

🎉 M11 smoke: all checks complete.
```

**Every sample shows `rate ≈ publish_ok` within 1/s** — the publish path
tracks the receive path, which is the F2 invariant. The F2 assertion
would have failed the smoke immediately if the publish path diverged
from the receive path by more than 10% of target (600/s threshold),
which is exactly the failure mode the M11 10-min soak missed.

**Prometheus rules now loaded:**

```
nats_publish_path: 2 rules
  IngestdNatsPublishErrorsHigh   (state=inactive)
  IngestdReceivePublishMismatch  (state=inactive)
nats_resource_limits: 2 rules
  NatsJetStreamStorageHigh       (state=inactive)
  NatsJetStreamStorageCritical   (state=inactive)
```

**Status:** F2 is shipped. M11 dev-playground gate is now defended by:
1. The smoke (which would fail the next time a publish path diverges
   silently from the receive path)
2. The alerts (which would page on-call well before the system went red)
3. The F1 cap (which prevents the storage from filling past the limit
   in the first place)

The combined F1 + F2 closes the M11 NATS investigation's full report.
The remaining work is M12 (multi-broker NATS, horizontal scaling,
50k/s ceiling, prod-shape cluster).
