|
@@ -0,0 +1,297 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+"""
|
|
|
|
|
+m10_bench_smoke.py — M10-bench broker+router ceiling test (10k/s on bench profile)
|
|
|
|
|
+
|
|
|
|
|
+Usage:
|
|
|
|
|
+ docker compose --profile bench up -d
|
|
|
|
|
+ python3 scripts/m10_bench_smoke.py
|
|
|
|
|
+
|
|
|
|
|
+Exit code 0 = all green. Exit code 1 = assertion failed.
|
|
|
|
|
+
|
|
|
|
|
+Step 1 — pre-flight (bench profile up, deliverd-bench consuming)
|
|
|
|
|
+Step 2 — 10k/s soak (5 min, ramp 15s)
|
|
|
|
|
+Step 3 — broker ceiling check (p99 ≤ 50ms, NATS queue depth < 1000)
|
|
|
|
|
+Step 4 — teardown
|
|
|
|
|
+
|
|
|
|
|
+NOTE: The bench profile has 2 loadgen-bench instances × 5000/s = 10k/s.
|
|
|
|
|
+M10_PLAN.md described 10 instances × 5000/s = 50k/s; only 2 exist in
|
|
|
|
|
+docker-compose.yml. This smoke targets 10k/s and adapts thresholds accordingly.
|
|
|
|
|
+"""
|
|
|
|
|
+import subprocess
|
|
|
|
|
+import sys
|
|
|
|
|
+import time
|
|
|
|
|
+import urllib.request
|
|
|
|
|
+import urllib.parse
|
|
|
|
|
+import json
|
|
|
|
|
+
|
|
|
|
|
+sys.path.insert(0, __file__.rsplit("/", 1)[0])
|
|
|
|
|
+import m10_lib as lib
|
|
|
|
|
+
|
|
|
|
|
+PROM = "http://localhost:9090"
|
|
|
|
|
+NATS_MONITOR = "http://localhost:8222"
|
|
|
|
|
+BENCH_DURATION_MIN = 5 # minutes
|
|
|
|
|
+BENCH_RAMP_SEC = 15 # ramp-up seconds
|
|
|
|
|
+CLUSTER_TARGET = 10000 # alerts/sec cluster-wide target (2 × 5000)
|
|
|
|
|
+P99_ROUTER_THRESHOLD = 0.050 # seconds — router p99 must be under this
|
|
|
|
|
+NATS_QUEUE_DEPTH_MAX = 1000 # NATS queue depth must stay below this
|
|
|
|
|
+RATE_TOLERANCE = 0.10 # ±10% (loadgen ramp variance is higher at 10k/s)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def pass_(msg: str):
|
|
|
|
|
+ print(f" ✅ {msg}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def warn_(msg: str):
|
|
|
|
|
+ print(f" ⚠️ {msg}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def fail_(msg: str):
|
|
|
|
|
+ print(f" ❌ {msg}")
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# NATS monitoring (port 8222)
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+def nats_varz() -> dict:
|
|
|
|
|
+ """Fetch NATS /varz JSON. Returns {} on error."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ with urllib.request.urlopen(f"{NATS_MONITOR}/varz", timeout=10) as r:
|
|
|
|
|
+ return json.loads(r.read())
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return {}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def nats_queue_depth() -> int:
|
|
|
|
|
+ """Return current NATS JetStream total message count across all streams.
|
|
|
|
|
+
|
|
|
|
|
+ Uses GET /jsz (JetStream info endpoint, no auth required on default NATS).
|
|
|
|
|
+ Returns total messages stored in JetStream. High values indicate the
|
|
|
|
|
+ consumer is not keeping up with the producer.
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ with urllib.request.urlopen(f"{NATS_MONITOR}/jsz", timeout=10) as r:
|
|
|
|
|
+ d = json.loads(r.read())
|
|
|
|
|
+ return int(d.get("messages", 0))
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ return -1
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Prometheus-backed assertions
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+def assert_router_p99_under(threshold: float, window_seconds: int = 60) -> float:
|
|
|
|
|
+ """
|
|
|
|
|
+ Assert p99 router recipient expansion latency is under threshold.
|
|
|
|
|
+ Metric: ba_recipient_expansion_seconds_bucket
|
|
|
|
|
+ Returns the actual p99, or raises AssertionError.
|
|
|
|
|
+ """
|
|
|
|
|
+ query = (
|
|
|
|
|
+ f'histogram_quantile(0.99, '
|
|
|
|
|
+ f'rate(ba_recipient_expansion_seconds_bucket[{window_seconds}s]))'
|
|
|
|
|
+ )
|
|
|
|
|
+ results = lib.scrape(query)
|
|
|
|
|
+ if not results:
|
|
|
|
|
+ raise AssertionError(
|
|
|
|
|
+ f"router p99 query returned no data. Is routerd receiving alerts? "
|
|
|
|
|
+ f"(query: {query})"
|
|
|
|
|
+ )
|
|
|
|
|
+ p99 = float(results[0]["value"][1])
|
|
|
|
|
+ if p99 > threshold:
|
|
|
|
|
+ raise AssertionError(
|
|
|
|
|
+ f"router p99 {p99*1000:.1f}ms exceeds threshold {threshold*1000:.1f}ms "
|
|
|
|
|
+ f"(window={window_seconds}s)"
|
|
|
|
|
+ )
|
|
|
|
|
+ return p99
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def assert_bench_rate_near(target: float, tolerance: float = 0.10,
|
|
|
|
|
+ window_seconds: int = 30) -> float:
|
|
|
|
|
+ """
|
|
|
|
|
+ Assert the bench cluster-wide accept rate is within tolerance of target.
|
|
|
|
|
+ tolerance=0.10 means ±10%.
|
|
|
|
|
+ Returns the actual rate.
|
|
|
|
|
+ """
|
|
|
|
|
+ query = (
|
|
|
|
|
+ f'rate(ba_ingestd_alerts_received_total{{result="accepted"}}[{window_seconds}s])'
|
|
|
|
|
+ )
|
|
|
|
|
+ results = lib.scrape(query)
|
|
|
|
|
+ if not results:
|
|
|
|
|
+ raise AssertionError(
|
|
|
|
|
+ f"rate query returned no data. Is ingestd receiving alerts? (query: {query})"
|
|
|
|
|
+ )
|
|
|
|
|
+ actual = sum(float(r["value"][1]) for r in results)
|
|
|
|
|
+ lower = target * (1 - tolerance)
|
|
|
|
|
+ upper = target * (1 + tolerance)
|
|
|
|
|
+ if not (lower <= actual <= upper):
|
|
|
|
|
+ raise AssertionError(
|
|
|
|
|
+ f"bench rate {actual:.1f}/s is outside ±{tolerance*100:.0f}% "
|
|
|
|
|
+ f"tolerance of target {target}/s (range: {lower:.1f}–{upper:.1f})"
|
|
|
|
|
+ )
|
|
|
|
|
+ return actual
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+# Steps
|
|
|
|
|
+# ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+def step1_preflight() -> None:
|
|
|
|
|
+ """Verify all bench services are up."""
|
|
|
|
|
+ print("Step 1 — pre-flight (bench profile)")
|
|
|
|
|
+ targets = lib.get_prometheus_targets()
|
|
|
|
|
+ required = ["ingestd", "routerd", "prometheus",
|
|
|
|
|
+ "loadgen-bench-1", "loadgen-bench-2"]
|
|
|
|
|
+ all_up = True
|
|
|
|
|
+ for svc in required:
|
|
|
|
|
+ status = targets.get(svc, "0")
|
|
|
|
|
+ if status == "1":
|
|
|
|
|
+ pass_(f"{svc} is up")
|
|
|
|
|
+ else:
|
|
|
|
|
+ warn_(f"{svc} is {'not scraped' if status == '0' else status}")
|
|
|
|
|
+ all_up = False
|
|
|
|
|
+
|
|
|
|
|
+ # Verify deliverd-bench is consuming (check NATS consumer lag)
|
|
|
|
|
+ # A lagging consumer shows up as non-zero "pending" on the deliverd-bench
|
|
|
|
|
+ # subject. We check that the subject has active consumers.
|
|
|
|
|
+ nats_info = nats_varz()
|
|
|
|
|
+ js = nats_info.get("jetstream", {})
|
|
|
|
|
+ if js:
|
|
|
|
|
+ pass_("NATS JetStream is available")
|
|
|
|
|
+ else:
|
|
|
|
|
+ warn_("NATS JetStream stats unavailable — cannot verify consumer lag")
|
|
|
|
|
+
|
|
|
|
|
+ if not all_up:
|
|
|
|
|
+ fail_("not all bench services are up — fix before running bench smoke")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def step2_start_loadgen() -> subprocess.Popen:
|
|
|
|
|
+ """Start the bench loadgen cluster."""
|
|
|
|
|
+ print("\nStep 2 — starting 2-instance bench loadgen cluster (10k/s)")
|
|
|
|
|
+ proc = subprocess.Popen(
|
|
|
|
|
+ ["docker", "compose", "--profile", "bench", "up", "-d"],
|
|
|
|
|
+ stdout=subprocess.DEVNULL,
|
|
|
|
|
+ stderr=subprocess.DEVNULL,
|
|
|
|
|
+ )
|
|
|
|
|
+ code = proc.wait()
|
|
|
|
|
+ if code != 0:
|
|
|
|
|
+ fail_(f"docker compose --profile bench up -d failed (exit {code})")
|
|
|
|
|
+ pass_("2 loadgen-bench instances started")
|
|
|
|
|
+ print(f" waiting {BENCH_RAMP_SEC}s for ramp-up to complete...", flush=True)
|
|
|
|
|
+ time.sleep(BENCH_RAMP_SEC)
|
|
|
|
|
+ pass_(f"ramp-up complete — now targeting {CLUSTER_TARGET}/s")
|
|
|
|
|
+ return proc
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def step2_monitor_soak() -> list[dict]:
|
|
|
|
|
+ """
|
|
|
|
|
+ Monitor the bench soak: sample rate + router p99 + NATS queue depth every 30s.
|
|
|
|
|
+ Fails fast on any breach.
|
|
|
|
|
+ Returns list of samples for the log.
|
|
|
|
|
+ """
|
|
|
|
|
+ print(f"\n Monitoring bench soak for {BENCH_DURATION_MIN} minutes...")
|
|
|
|
|
+ samples = []
|
|
|
|
|
+ start = time.time()
|
|
|
|
|
+ deadline = start + BENCH_DURATION_MIN * 60
|
|
|
|
|
+ sample_interval = 30 # seconds
|
|
|
|
|
+
|
|
|
|
|
+ while time.time() < deadline:
|
|
|
|
|
+ time.sleep(sample_interval)
|
|
|
|
|
+ elapsed_min = int(time.time() - start) // 60
|
|
|
|
|
+ elapsed_sec = int(time.time() - start)
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ rate = assert_bench_rate_near(CLUSTER_TARGET, tolerance=RATE_TOLERANCE,
|
|
|
|
|
+ window_seconds=30)
|
|
|
|
|
+ p99 = assert_router_p99_under(P99_ROUTER_THRESHOLD, window_seconds=60)
|
|
|
|
|
+ qd = nats_queue_depth()
|
|
|
|
|
+ qd_ok = qd < NATS_QUEUE_DEPTH_MAX
|
|
|
|
|
+ print(f" [{elapsed_min}m] rate={rate:.0f}/s router_p99={p99*1000:.1f}ms "
|
|
|
|
|
+ f"nats_qd={qd} {'✅' if qd_ok else '❌'}")
|
|
|
|
|
+ samples.append({
|
|
|
|
|
+ "elapsed_min": elapsed_min,
|
|
|
|
|
+ "elapsed_sec": elapsed_sec,
|
|
|
|
|
+ "rate": rate,
|
|
|
|
|
+ "router_p99_ms": p99 * 1000,
|
|
|
|
|
+ "nats_qd": qd,
|
|
|
|
|
+ })
|
|
|
|
|
+ if not qd_ok:
|
|
|
|
|
+ fail_(f"NATS queue depth {qd} exceeds max {NATS_QUEUE_DEPTH_MAX}")
|
|
|
|
|
+ except AssertionError as e:
|
|
|
|
|
+ fail_(f"bench soak breach at {elapsed_min}m: {e}")
|
|
|
|
|
+
|
|
|
|
|
+ return samples
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def step3_broker_ceiling_check(samples: list[dict]) -> None:
|
|
|
|
|
+ """Final broker ceiling assertions over the full soak window."""
|
|
|
|
|
+ print("\nStep 3 — broker ceiling check")
|
|
|
|
|
+ # All samples already passed the router p99 check; do a final aggregate
|
|
|
|
|
+ # assertion over the full window to confirm the ceiling held.
|
|
|
|
|
+ p99_final = assert_router_p99_under(P99_ROUTER_THRESHOLD,
|
|
|
|
|
+ window_seconds=BENCH_DURATION_MIN * 60)
|
|
|
|
|
+ pass_(f"router p99 over full {BENCH_DURATION_MIN}min window: "
|
|
|
|
|
+ f"{p99_final*1000:.1f}ms (threshold: {P99_ROUTER_THRESHOLD*1000:.1f}ms)")
|
|
|
|
|
+
|
|
|
|
|
+ qd = nats_queue_depth()
|
|
|
|
|
+ if qd < NATS_QUEUE_DEPTH_MAX:
|
|
|
|
|
+ pass_(f"NATS queue depth: {qd} (max allowed: {NATS_QUEUE_DEPTH_MAX})")
|
|
|
|
|
+ else:
|
|
|
|
|
+ fail_(f"NATS queue depth {qd} exceeds max {NATS_QUEUE_DEPTH_MAX}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def step4_teardown(proc) -> None:
|
|
|
|
|
+ """Bring down the bench cluster."""
|
|
|
|
|
+ print("\nStep 4 — teardown")
|
|
|
|
|
+ r = subprocess.run(
|
|
|
|
|
+ ["docker", "compose", "--profile", "bench", "down", "-v"],
|
|
|
|
|
+ capture_output=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ if r.returncode == 0:
|
|
|
|
|
+ pass_("bench cluster torn down")
|
|
|
|
|
+ else:
|
|
|
|
|
+ warn_(f"teardown returned {r.returncode}: {r.stderr.decode().strip()}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def print_summary(samples: list[dict]) -> None:
|
|
|
|
|
+ """Print a summary table of the bench run."""
|
|
|
|
|
+ print("\n=== M10-Bench Soak Summary ===")
|
|
|
|
|
+ print(f"Duration: {BENCH_DURATION_MIN} min")
|
|
|
|
|
+ print(f"Target rate: {CLUSTER_TARGET}/s (±{RATE_TOLERANCE*100:.0f}%)")
|
|
|
|
|
+ print(f"Router p99 threshold: {P99_ROUTER_THRESHOLD*1000:.0f}ms")
|
|
|
|
|
+ print(f"NATS qd max: {NATS_QUEUE_DEPTH_MAX}")
|
|
|
|
|
+ print()
|
|
|
|
|
+ if samples:
|
|
|
|
|
+ print(f"{'Time':>6} {'Rate/s':>8} {'rt_p99(ms)':>11} {'nats_qd':>8}")
|
|
|
|
|
+ print("-" * 42)
|
|
|
|
|
+ for s in samples:
|
|
|
|
|
+ print(f"{s['elapsed_min']:>5}m {s['rate']:>8.0f} "
|
|
|
|
|
+ f"{s['router_p99_ms']:>11.1f} {s['nats_qd']:>8}")
|
|
|
|
|
+ print()
|
|
|
|
|
+ print("🎉 M10-bench smoke: all checks complete.")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main():
|
|
|
|
|
+ print(f"M10-Bench — target {CLUSTER_TARGET}/s for {BENCH_DURATION_MIN} min")
|
|
|
|
|
+ print(f"Router p99 threshold: {P99_ROUTER_THRESHOLD*1000:.0f}ms | "
|
|
|
|
|
+ f"NATS qd max: {NATS_QUEUE_DEPTH_MAX}")
|
|
|
|
|
+ print()
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ step1_preflight()
|
|
|
|
|
+ proc = step2_start_loadgen()
|
|
|
|
|
+ samples = step2_monitor_soak()
|
|
|
|
|
+ step3_broker_ceiling_check(samples)
|
|
|
|
|
+ step4_teardown(proc)
|
|
|
|
|
+ print_summary(samples)
|
|
|
|
|
+ except AssertionError as e:
|
|
|
|
|
+ print(f"\n💥 M10-bench smoke FAILED: {e}")
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+ except KeyboardInterrupt:
|
|
|
|
|
+ print("\nInterrupted.")
|
|
|
|
|
+ sys.exit(1)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ main()
|