Status: planning (post-M11-conditional, blocked on NATS issue) Target: M12 exit criterion from SPEC §19 Goal: Move from single-broker docker-compose to K8s-deployed multi-broker NATS, sustaining 50k/s end-to-end with M11 prod gate (10k/s sustained 10 min, p99 ≤ 50ms, DLQ=0) on a single ingestd instance.
| Milestone | Exit criterion | How it's measured |
|---|---|---|
| M11 prod gate (carried into M12) | internal Go service pushes ≥ 10k alerts/sec on one gRPC stream, p99 server-side Ack ≤ 50ms, DLQ=0, sustained 10 min |
m11_smoke.py on K8s cluster, asserts on receive rate and publish success |
| M12 ceiling (new) | 50k/s end-to-end through multi-broker NATS + multi-ingestd, p99 router latency ≤ 50ms, no broker backpressure | m10_bench_smoke.py (adapted) on K8s, delivery stubbed |
| M12 cluster (new) | NATS cluster survives single-broker loss without message loss or sustained publish failure | chaos test: kill one NATS pod mid-soak, verify p99 stays under 100ms and no DLQ |
Why M12 now (not later): The 2-min re-verification on 2026-06-16 14:48 EDT exposed the single-NATS broker as the system bottleneck. NATS logged [ERR] JetStream resource limits exceeded for server every 10s under sustained ~6k/s load, and the receive rate collapsed to 0/s in 30s. Single-broker NATS on docker-compose cannot deliver v1 capacity (5k/s sustained per the SPEC, design ceiling 50k/s). M10-bench proved the router ceiling at 50k/s with delivery stubbed, but the broker was the implicit weak link — M12 surfaces and fixes it.
┌──────────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
│ W1: K8s manifests │ │ W2: NATS JetStream │ │ W3: Helm chart + │
│ (Deployments, Services, │───▶│ cluster (3+ brokers, │───▶│ argocd / flux / │
│ ConfigMaps, Secrets) │ │ R3, clustering) │ │ plain kubectl │
└──────────────────────────┘ └──────────────┬───────────┘ └──────────────┬───────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ W4: 50k/s ceiling bench │ │ W5: M11 prod gate on K8s│
│ (M10-bench equivalent) │ │ (10k/s × 10 min) │
│ delivery stubbed │ │ full smoke (5 steps) │
└──────────────────────────┘ └──────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ W6: CI/CD + observability for K8s │
│ (GitHub Actions / GitLab CI build+push, │
│ Prometheus Operator, Grafana dashboards) │
└──────────────────────────────────────────────────┘
Six workstreams, each ends with passing evidence before the next starts.
| Risk | Why it's scary | How we defuses it |
|---|---|---|
| Single NATS broker is the bottleneck (post-M11-conditional finding) | M11 10-min soak was green only because the rate metric counts gRPC receive, not publish success. Real-world delivery is broken under sustained load. | W2: NATS cluster (3+ brokers, R=3 JetStream streams) raises the limit ceiling by ~3x. W4: prove the cluster ceiling at 50k/s. |
| K8s deployment is operationally complex | K8s has a learning curve; mistakes (wrong resource limits, missing healthchecks, bad ConfigMap wiring) cause silent failures | W1: manifests mirror docker-compose service-by-service; W3: Helm chart encapsulates complexity; W6: GitOps pipeline means config changes are reviewable in PRs. |
| Stateful workloads in K8s | NATS, Postgres, ClickHouse, Redis all need persistent volumes and ordered shutdown. Compose just restarts; K8s needs StatefulSets + PodDisruptionBudgets. | W1: use StatefulSets for stateful services, Deployments for stateless (ingestd, routerd, deliverd-*). W3: Helm chart includes PVC templates + PDBs. |
| Migrating from docker-compose to K8s in production | Compose works; K8s is new. Risk of breaking the dev loop. | W1 keeps docker-compose as the dev story (unchanged), K8s is the deploy target only. M11+M12 work continues against compose. |
deploy/k8s/
base/
namespace.yaml # broad-announce namespace
configmap.yaml # BA_* env vars
secrets.yaml.example # fcm-credentials, telegram-bot-token, etc. (gitignored real one)
postgres/
statefulset.yaml # 1 replica, 10Gi PVC, healthcheck
service.yaml
pdb.yaml # minAvailable: 1
nats/ # see W2 for cluster specifics
statefulset.yaml
headless-service.yaml
configmap.yaml
pdb.yaml
redis/
statefulset.yaml
service.yaml
clickhouse/
statefulset.yaml
service.yaml
emqx/
statefulset.yaml # 1 replica (single-node OK for v2.0)
service.yaml
ingestd/
deployment.yaml # 2 replicas, max_inflight=256, BA_INGESTD_RATE_LIMIT_PER_SOURCE=20000
service.yaml # ClusterIP, port 8800 + 9090 (gRPC)
hpa.yaml # autoscale on CPU + custom metric (alerts_received rate)
routerd/
deployment.yaml
service.yaml
deliverd-fcm/
deployment.yaml
service.yaml
deliverd-telegram/
deployment.yaml
service.yaml
admind/
deployment.yaml
service.yaml
archiverd/
deployment.yaml
service.yaml
loadgen/ # kept as Deployments, scaled up for the 50k/s bench
deployment.yaml
| compose | K8s |
|---|---|
service: ingestd |
Deployment named ingestd, Service ClusterIP, port 8800+9090 |
environment: block |
ConfigMap mounted as envFrom |
volumes: ["/data"] |
StatefulSet + volumeClaimTemplates with PVCs |
healthcheck: test: ... |
livenessProbe + readinessProbe (exec or httpGet) |
depends_on: [postgres] |
Same effect via init containers (wait for postgres:5432) or K8s-native service discovery with retries in app code |
ports: ["8800:8800"] |
Service (ClusterIP) + optional Ingress for external |
deploy.resources.limits |
resources.limits in container spec |
| profiles (loadgen-grpc) | Deployment in deploy/k8s/overlays/loadgen/ (Kustomize) — off by default in base |
docker-compose.yml have K8s manifests in deploy/k8s/base/kubectl apply -k deploy/k8s/base/ brings the stack upkubectl get pods -n broad-announce shows all services Running with Ready 1/1 (or appropriate for stateful)The 2026-06-16 14:48 EDT finding shows single-broker NATS hits resource limits under sustained ~6k/s. Multi-broker is M12's core fix, not a deployment detail.
nats-0, nats-1, nats-2) on separate nodes (anti-affinity)ALERTS with replicas=3, storage=File, max_age=24halerts.<company_id>.<source_id> (per ARCHITECTURE.md §6)ROUTER for routerd, replicas=3, ack_wait=30s, max_deliver=5DELIVERY_FCM / DELIVERY_TELEGRAM for deliverd-*, replicas=3# deploy/k8s/base/nats/configmap.yaml
cluster:
name: broad-announce-nats
listen: 0.0.0.0:6222
routes:
- nats-broad-announce-nats-0.nats-headless:6222
- nats-broad-announce-nats-1.nats-headless:6222
- nats-broad-announce-nats-2.nats-headless:6222
jetstream:
store_dir: /data/jetstream
max_memory_store: 2Gi
max_file_store: 100Gi
| Stream | Per-broker | Cluster (R=3) |
|---|---|---|
ALERTS throughput |
~20k/s | ~50-60k/s (3 brokers share load) |
ALERTS storage |
100Gi | 300Gi (file-backed) |
ALERTS max memory |
2Gi | 6Gi |
This raises the resource-limit ceiling by ~3x. The 50k/s ceiling bench (W4) verifies it.
nats stream info ALERTS shows replicas=3, leader distributedalerts.acme-001 on any broker lands in the streamdeploy/helm/
broad-announce/
Chart.yaml
values.yaml # defaults: dev-shape cluster, single NATS
values.prod.yaml # prod-shape: 3 NATS, multi-ingestd, higher resource limits
templates/
_helpers.tpl
namespace.yaml
configmap.yaml
secrets.yaml
postgres/
nats/
redis/
clickhouse/
emqx/
ingestd/
routerd/
deliverd-fcm/
deliverd-telegram/
admind/
archiverd/
# values.prod.yaml
global:
imageRegistry: registry.techno-world.net/lrosales
imageTag: "M12-W6"
nats:
cluster:
enabled: true
replicas: 3
storage: 100Gi
jetstream:
maxMemory: 2Gi
maxFile: 100Gi
ingestd:
replicas: 3
resources:
requests: {cpu: "500m", memory: "512Mi"}
limits: {cpu: "2", memory: "1Gi"}
env:
BA_INGESTD_RATE_LIMIT_PER_SOURCE: "20000"
BA_INGESTD_MAX_INFLIGHT: "256"
routerd:
replicas: 2
...
deploy/helm/broad-announce/values.prod.yaml trigger preview environmentshelm install broad-announce deploy/helm/broad-announce/ -f values.prod.yaml -n broad-announce brings up the prod-shape clusterhelm upgrade --reuse-values works idempotentlyhelm template produces valid manifests that pass kubeconform (or kubectl apply --dry-run=server)The M10-bench in M10_BENCH_VERIFICATION.md proved the router ceiling at 50k/s on a single NATS broker with delivery stubbed. W4 re-proves it on the 3-broker cluster with stubbed delivery, and is the gate for M12.
loadgen Deployments (one per company/api-key, same as M11)ingestd ServiceM11 dev-playground gate passed on parres (4 cores, shared). M12 proves the M11 prod gate on the prod-shape cluster (8+ cores, dedicated). This is the second of the two M12 exit criteria.
The 2-min re-verification on 2026-06-16 14:48 EDT showed the M11 smoke's rate metric (ba_ingestd_alerts_received_total) does not assert on publish success. M12 W5 fixes this:
# scripts/m11_smoke.py — add a publish-success assertion
def assert_grpc_publish_ok(target_rate, tolerance, window_seconds=30):
"""Rate of successful NATS publishes, must be ≥ target * (1 - tolerance)."""
q = f'rate(ba_ingestd_nats_publish_total{{service="ingestd",result="ok"}}[{window_seconds}s])'
rate = float(prom_query(q))
if rate < target_rate * (1 - tolerance):
fail_(f"NATS publish rate {rate:.0f}/s is below target {target_rate}/s")
pass_(f"NATS publish rate {rate:.0f}/s ≥ {target_rate}/s")
(Assumes the ba_ingestd_nats_publish_total counter exists in ingestd with a result label. If not, it's added as part of W1 — see W1 §3.4.)
loadgen Deployments targeting ingestd:9090 (gRPC)docker build for each service, push to registry.techno-world.net/lrosales/broad-announce-{service}:<sha>go test ./... for each service; smoke against ephemeral docker-compose (existing M10/M11 harnesses)ServiceMonitor resources for each serviceW1 ─────────────────┐
├──▶ W2 (NATS cluster)
│ │
│ ├──▶ W3 (Helm) ──┐
│ │ │
│ ▼ ▼
│ W4 (50k/s bench) W5 (M11 prod gate)
│ │ │
│ └──────┬───────────┘
│ ▼
│ W6 (CI/CD + observability)
│
(W1 must land first; rest can parallelize with W3 as a fan-out)
Estimated wall time (with one full-time engineer):
(Scope boundaries to prevent creep)
kubectl apply from CI? Argo is the most common; Flux is lighter-weight.nats-io/nats Helm chart, the NATS operator, or custom StatefulSet? Custom gives more control; the operator gives rolling-upgrade + observability for free. Recommendation: NATS operator (jetstream + cluster + chaos-tested).registry.techno-world.net/lrosales/...) ready for CI-built images.kubectl apply -k deploy/k8s/base/ brings them upM12_VERIFICATION.md published with bench evidence + chaos test resultsdeploy/k8s/README.md quickstart written| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| NATS operator / Helm chart is immature | Low | Medium (deployment friction) | Fall back to custom StatefulSet (W1 template) |
| Single-region outage takes down the cluster | Medium | High (revenue) | Multi-AZ nodes; PodDisruptionBudgets; M3 (multi-region) is the long-term fix |
| Helm values drift between dev / staging / prod | Medium | Medium (config bugs) | Single values.yaml with per-env overlays; Kustomize on top if needed |
| NATS cluster resource limits still hit at 50k/s | Low | High (M12 ceiling fails) | W4 finds the wall early; scale brokers to 5 if needed (R=5) |
| StatefulSet PVC migration in production | High | High (data loss) | Use Velero for backup; test restore in staging; never delete PVCs without backup |
| Cost: K8s cluster is more expensive than docker-compose | Certain | Low | Operational benefits outweigh the cost; capacity planning in W3 |
Next step: resolve open questions (§11) and the M11 NATS prerequisite (#1 in §12). W1 cannot start until both are settled.
The local repo on the workspace (/root/.openclaw/workspace/broad-announce/) is 5 commits ahead of origin/master on git3, but origin has 6 commits not in local (the M11 hotfixes shipped from a different machine). The histories diverged at 09f0d54.
To push the M11 work to git3, three approaches:
cd /root/.openclaw/workspace/broad-announce
git fetch origin master # already done
git merge origin/master # creates a merge commit; preserves both histories
# resolve any conflicts (likely in M11 smoke or loadgen code, since both sides edited)
git push origin master # fast-forward or merge push
Pros: safest, no history rewrite, both sets of commits visible. Cons: creates a merge commit; the graph has a "diamond" shape.
cd /root/.openclaw/workspace/broad-announce
git fetch origin master # already done
git rebase origin/master # replays 5 local commits on top of origin's 6
# resolve any conflicts
git push --force-with-lease origin master # rewrites origin's master
Pros: clean linear history. Cons: rewrites the commits the user (or their other machine) already pushed. If those commits are referenced anywhere (PRs, CI runs, tags), those references break.
cd /root/.openclaw/workspace/broad-announce
git fetch origin master
git reset --hard origin/master # discards local 5 commits
Pros: simple, no conflicts. Cons: loses the local M11 work entirely (the conditional ship, the loadgen fix, the M11 smoke threshold). Not recommended unless the user is sure the local work is duplicated on origin.
Recommended: A1 (merge). The conditional-ship commit (d8008bc) is a real change to SPEC.md and M11_VERIFICATION.md that should be on origin. The loadgen fix (39907d1) is also worth shipping. Merging preserves both.