m10_bench_smoke.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. #!/usr/bin/env python3
  2. """
  3. m10_bench_smoke.py — M10-bench broker+router ceiling test (10k/s on bench profile)
  4. Usage:
  5. docker compose --profile bench up -d
  6. python3 scripts/m10_bench_smoke.py
  7. Exit code 0 = all green. Exit code 1 = assertion failed.
  8. Step 1 — pre-flight (bench profile up, deliverd-bench consuming)
  9. Step 2 — 10k/s soak (5 min, ramp 15s)
  10. Step 3 — broker ceiling check (p99 ≤ 50ms, NATS queue depth < 1000)
  11. Step 4 — teardown
  12. NOTE: The bench profile has 2 loadgen-bench instances × 5000/s = 10k/s.
  13. M10_PLAN.md described 10 instances × 5000/s = 50k/s; only 2 exist in
  14. docker-compose.yml. This smoke targets 10k/s and adapts thresholds accordingly.
  15. """
  16. import subprocess
  17. import sys
  18. import time
  19. import urllib.request
  20. import urllib.parse
  21. import json
  22. sys.path.insert(0, __file__.rsplit("/", 1)[0])
  23. import m10_lib as lib
  24. PROM = "http://localhost:9090"
  25. NATS_MONITOR = "http://localhost:8222"
  26. BENCH_DURATION_MIN = 5 # minutes
  27. BENCH_RAMP_SEC = 15 # ramp-up seconds
  28. CLUSTER_TARGET = 10000 # alerts/sec cluster-wide target (2 × 5000)
  29. P99_ROUTER_THRESHOLD = 0.050 # seconds — router p99 must be under this
  30. NATS_QUEUE_DEPTH_MAX = 1000 # NATS queue depth must stay below this
  31. RATE_TOLERANCE = 0.10 # ±10% (loadgen ramp variance is higher at 10k/s)
  32. def pass_(msg: str):
  33. print(f" ✅ {msg}")
  34. def warn_(msg: str):
  35. print(f" ⚠️ {msg}")
  36. def fail_(msg: str):
  37. print(f" ❌ {msg}")
  38. sys.exit(1)
  39. # ---------------------------------------------------------------------------
  40. # NATS monitoring (port 8222)
  41. # ---------------------------------------------------------------------------
  42. def nats_varz() -> dict:
  43. """Fetch NATS /varz JSON. Returns {} on error."""
  44. try:
  45. with urllib.request.urlopen(f"{NATS_MONITOR}/varz", timeout=10) as r:
  46. return json.loads(r.read())
  47. except Exception:
  48. return {}
  49. def nats_queue_depth() -> int:
  50. """Return current NATS JetStream total message count across all streams.
  51. Uses GET /jsz (JetStream info endpoint, no auth required on default NATS).
  52. Returns total messages stored in JetStream. High values indicate the
  53. consumer is not keeping up with the producer.
  54. """
  55. try:
  56. with urllib.request.urlopen(f"{NATS_MONITOR}/jsz", timeout=10) as r:
  57. d = json.loads(r.read())
  58. return int(d.get("messages", 0))
  59. except Exception:
  60. return -1
  61. # ---------------------------------------------------------------------------
  62. # Prometheus-backed assertions
  63. # ---------------------------------------------------------------------------
  64. def assert_router_p99_under(threshold: float, window_seconds: int = 60) -> float:
  65. """
  66. Assert p99 router recipient expansion latency is under threshold.
  67. Metric: ba_recipient_expansion_seconds_bucket
  68. Returns the actual p99, or raises AssertionError.
  69. """
  70. query = (
  71. f'histogram_quantile(0.99, '
  72. f'rate(ba_recipient_expansion_seconds_bucket[{window_seconds}s]))'
  73. )
  74. results = lib.scrape(query)
  75. if not results:
  76. raise AssertionError(
  77. f"router p99 query returned no data. Is routerd receiving alerts? "
  78. f"(query: {query})"
  79. )
  80. p99 = float(results[0]["value"][1])
  81. if p99 > threshold:
  82. raise AssertionError(
  83. f"router p99 {p99*1000:.1f}ms exceeds threshold {threshold*1000:.1f}ms "
  84. f"(window={window_seconds}s)"
  85. )
  86. return p99
  87. def assert_bench_rate_near(target: float, tolerance: float = 0.10,
  88. window_seconds: int = 30) -> float:
  89. """
  90. Assert the bench cluster-wide accept rate is within tolerance of target.
  91. tolerance=0.10 means ±10%.
  92. Returns the actual rate.
  93. """
  94. query = (
  95. f'rate(ba_ingestd_alerts_received_total{{result="accepted"}}[{window_seconds}s])'
  96. )
  97. results = lib.scrape(query)
  98. if not results:
  99. raise AssertionError(
  100. f"rate query returned no data. Is ingestd receiving alerts? (query: {query})"
  101. )
  102. actual = sum(float(r["value"][1]) for r in results)
  103. lower = target * (1 - tolerance)
  104. upper = target * (1 + tolerance)
  105. if not (lower <= actual <= upper):
  106. raise AssertionError(
  107. f"bench rate {actual:.1f}/s is outside ±{tolerance*100:.0f}% "
  108. f"tolerance of target {target}/s (range: {lower:.1f}–{upper:.1f})"
  109. )
  110. return actual
  111. # ---------------------------------------------------------------------------
  112. # Steps
  113. # ---------------------------------------------------------------------------
  114. def step1_preflight() -> None:
  115. """Verify all bench services are up."""
  116. print("Step 1 — pre-flight (bench profile)")
  117. targets = lib.get_prometheus_targets()
  118. required = ["ingestd", "routerd", "prometheus",
  119. "loadgen-bench-1", "loadgen-bench-2"]
  120. all_up = True
  121. for svc in required:
  122. status = targets.get(svc, "0")
  123. if status == "1":
  124. pass_(f"{svc} is up")
  125. else:
  126. warn_(f"{svc} is {'not scraped' if status == '0' else status}")
  127. all_up = False
  128. # Verify deliverd-bench is consuming (check NATS consumer lag)
  129. # A lagging consumer shows up as non-zero "pending" on the deliverd-bench
  130. # subject. We check that the subject has active consumers.
  131. nats_info = nats_varz()
  132. js = nats_info.get("jetstream", {})
  133. if js:
  134. pass_("NATS JetStream is available")
  135. else:
  136. warn_("NATS JetStream stats unavailable — cannot verify consumer lag")
  137. if not all_up:
  138. fail_("not all bench services are up — fix before running bench smoke")
  139. def step2_start_loadgen() -> subprocess.Popen:
  140. """Start the bench loadgen cluster."""
  141. print("\nStep 2 — starting 2-instance bench loadgen cluster (10k/s)")
  142. proc = subprocess.Popen(
  143. ["docker", "compose", "--profile", "bench", "up", "-d"],
  144. stdout=subprocess.DEVNULL,
  145. stderr=subprocess.DEVNULL,
  146. )
  147. code = proc.wait()
  148. if code != 0:
  149. fail_(f"docker compose --profile bench up -d failed (exit {code})")
  150. pass_("2 loadgen-bench instances started")
  151. print(f" waiting {BENCH_RAMP_SEC}s for ramp-up to complete...", flush=True)
  152. time.sleep(BENCH_RAMP_SEC)
  153. pass_(f"ramp-up complete — now targeting {CLUSTER_TARGET}/s")
  154. return proc
  155. def step2_monitor_soak() -> list[dict]:
  156. """
  157. Monitor the bench soak: sample rate + router p99 + NATS queue depth every 30s.
  158. Fails fast on any breach.
  159. Returns list of samples for the log.
  160. """
  161. print(f"\n Monitoring bench soak for {BENCH_DURATION_MIN} minutes...")
  162. samples = []
  163. start = time.time()
  164. deadline = start + BENCH_DURATION_MIN * 60
  165. sample_interval = 30 # seconds
  166. while time.time() < deadline:
  167. time.sleep(sample_interval)
  168. elapsed_min = int(time.time() - start) // 60
  169. elapsed_sec = int(time.time() - start)
  170. try:
  171. rate = assert_bench_rate_near(CLUSTER_TARGET, tolerance=RATE_TOLERANCE,
  172. window_seconds=30)
  173. p99 = assert_router_p99_under(P99_ROUTER_THRESHOLD, window_seconds=60)
  174. qd = nats_queue_depth()
  175. qd_ok = qd < NATS_QUEUE_DEPTH_MAX
  176. print(f" [{elapsed_min}m] rate={rate:.0f}/s router_p99={p99*1000:.1f}ms "
  177. f"nats_qd={qd} {'✅' if qd_ok else '❌'}")
  178. samples.append({
  179. "elapsed_min": elapsed_min,
  180. "elapsed_sec": elapsed_sec,
  181. "rate": rate,
  182. "router_p99_ms": p99 * 1000,
  183. "nats_qd": qd,
  184. })
  185. if not qd_ok:
  186. fail_(f"NATS queue depth {qd} exceeds max {NATS_QUEUE_DEPTH_MAX}")
  187. except AssertionError as e:
  188. fail_(f"bench soak breach at {elapsed_min}m: {e}")
  189. return samples
  190. def step3_broker_ceiling_check(samples: list[dict]) -> None:
  191. """Final broker ceiling assertions over the full soak window."""
  192. print("\nStep 3 — broker ceiling check")
  193. # All samples already passed the router p99 check; do a final aggregate
  194. # assertion over the full window to confirm the ceiling held.
  195. p99_final = assert_router_p99_under(P99_ROUTER_THRESHOLD,
  196. window_seconds=BENCH_DURATION_MIN * 60)
  197. pass_(f"router p99 over full {BENCH_DURATION_MIN}min window: "
  198. f"{p99_final*1000:.1f}ms (threshold: {P99_ROUTER_THRESHOLD*1000:.1f}ms)")
  199. qd = nats_queue_depth()
  200. if qd < NATS_QUEUE_DEPTH_MAX:
  201. pass_(f"NATS queue depth: {qd} (max allowed: {NATS_QUEUE_DEPTH_MAX})")
  202. else:
  203. fail_(f"NATS queue depth {qd} exceeds max {NATS_QUEUE_DEPTH_MAX}")
  204. def step4_teardown(proc) -> None:
  205. """Bring down the bench cluster."""
  206. print("\nStep 4 — teardown")
  207. r = subprocess.run(
  208. ["docker", "compose", "--profile", "bench", "down", "-v"],
  209. capture_output=True,
  210. )
  211. if r.returncode == 0:
  212. pass_("bench cluster torn down")
  213. else:
  214. warn_(f"teardown returned {r.returncode}: {r.stderr.decode().strip()}")
  215. def print_summary(samples: list[dict]) -> None:
  216. """Print a summary table of the bench run."""
  217. print("\n=== M10-Bench Soak Summary ===")
  218. print(f"Duration: {BENCH_DURATION_MIN} min")
  219. print(f"Target rate: {CLUSTER_TARGET}/s (±{RATE_TOLERANCE*100:.0f}%)")
  220. print(f"Router p99 threshold: {P99_ROUTER_THRESHOLD*1000:.0f}ms")
  221. print(f"NATS qd max: {NATS_QUEUE_DEPTH_MAX}")
  222. print()
  223. if samples:
  224. print(f"{'Time':>6} {'Rate/s':>8} {'rt_p99(ms)':>11} {'nats_qd':>8}")
  225. print("-" * 42)
  226. for s in samples:
  227. print(f"{s['elapsed_min']:>5}m {s['rate']:>8.0f} "
  228. f"{s['router_p99_ms']:>11.1f} {s['nats_qd']:>8}")
  229. print()
  230. print("🎉 M10-bench smoke: all checks complete.")
  231. def main():
  232. print(f"M10-Bench — target {CLUSTER_TARGET}/s for {BENCH_DURATION_MIN} min")
  233. print(f"Router p99 threshold: {P99_ROUTER_THRESHOLD*1000:.0f}ms | "
  234. f"NATS qd max: {NATS_QUEUE_DEPTH_MAX}")
  235. print()
  236. try:
  237. step1_preflight()
  238. proc = step2_start_loadgen()
  239. samples = step2_monitor_soak()
  240. step3_broker_ceiling_check(samples)
  241. step4_teardown(proc)
  242. print_summary(samples)
  243. except AssertionError as e:
  244. print(f"\n💥 M10-bench smoke FAILED: {e}")
  245. sys.exit(1)
  246. except KeyboardInterrupt:
  247. print("\nInterrupted.")
  248. sys.exit(1)
  249. if __name__ == "__main__":
  250. main()