m10_smoke.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. #!/usr/bin/env python3
  2. """
  3. m10_smoke.py — M10 soak test (5k/s, 10 min, zero DLQ, runaway-source isolation)
  4. Usage:
  5. # Local docker-compose:
  6. docker compose --profile loadgen-m10 up -d
  7. python3 scripts/m10_smoke.py
  8. # Remote parres:
  9. ssh root@192.168.44.94 "cd /root/broad-announce && docker compose --profile loadgen-m10 up -d && python3 scripts/m10_smoke.py"
  10. Exit code 0 = all green. Exit code 1 = assertion failed.
  11. Step 1 — pre-flight
  12. Step 2 — 5k/s soak (10 min, ramp 30s)
  13. Step 3 — runaway-source fault injection (60s)
  14. Step 4 — DLQ invariant
  15. Step 5 — teardown (profile down)
  16. """
  17. import subprocess
  18. import sys
  19. import time
  20. import urllib.request
  21. import urllib.parse
  22. import json
  23. import base64
  24. sys.path.insert(0, __file__.rsplit("/", 1)[0])
  25. import m10_lib as lib
  26. PROM = "http://localhost:9090"
  27. GRAFANA = "http://localhost:3001"
  28. SOAK_DURATION_MIN = 10 # minutes
  29. SOAK_RAMP_SEC = 30 # ramp-up seconds
  30. RUNAWAY_DURATION_SEC = 60 # runaway fault injection duration
  31. CLUSTER_TARGET = 5000 # alerts/sec cluster-wide target
  32. P99_THRESHOLD = 5.0 # seconds — p99 must be under this
  33. DLQ_EXPECTED = 0 # zero DLQ is the invariant
  34. RATE_TOLERANCE = 0.05 # ±5%
  35. def curl_json(url: str) -> dict | None:
  36. try:
  37. with urllib.request.urlopen(url, timeout=10) as r:
  38. return json.loads(r.read())
  39. except Exception:
  40. return None
  41. def pass_(msg: str):
  42. print(f" ✅ {msg}")
  43. def warn_(msg: str):
  44. print(f" ⚠️ {msg}")
  45. def fail_(msg: str):
  46. print(f" ❌ {msg}")
  47. sys.exit(1)
  48. def step1_preflight() -> dict:
  49. """Verify all services are up and DLQ baseline is clean."""
  50. print("Step 1 — pre-flight")
  51. targets = lib.get_prometheus_targets()
  52. required = ["ingestd", "routerd", "deliverd-fcm", "deliverd-telegram",
  53. "admind", "archiverd", "prometheus",
  54. "loadgen-http-1", "loadgen-http-2", "loadgen-http-3"]
  55. all_up = True
  56. for svc in required:
  57. status = targets.get(svc, "0")
  58. if status == "1":
  59. pass_(f"{svc} is up")
  60. else:
  61. warn_(f"{svc} is {'not scraped' if status == '0' else status}")
  62. all_up = False
  63. # Check DLQ baseline
  64. dlq_now = lib.assert_dlq_count_equals(0, window_seconds=60)
  65. pass_(f"DLQ baseline clean: {dlq_now} rows")
  66. # Check circuit breaker is closed
  67. cb_state = lib.get_circuit_breaker_state("nats")
  68. cb_names = {0: "CLOSED", 1: "HALF-OPEN", 2: "OPEN"}
  69. if cb_state == 0:
  70. pass_(f"circuit breaker CLOSED (nats)")
  71. elif cb_state < 0:
  72. warn_(f"circuit breaker gauge not initialized yet (expected on cold start)")
  73. else:
  74. warn_(f"circuit breaker is {cb_names.get(cb_state, cb_state)} — may recover under load")
  75. if not all_up:
  76. fail_("not all services are up — fix before running M10 smoke")
  77. return targets
  78. def step2_start_loadgen() -> subprocess.Popen:
  79. """Start the 3-instance loadgen cluster."""
  80. print("\nStep 2 — starting 3-instance loadgen cluster (5k/s)")
  81. proc = subprocess.Popen(
  82. ["docker", "compose", "--profile", "loadgen-m10", "up", "-d"],
  83. stdout=subprocess.DEVNULL,
  84. stderr=subprocess.DEVNULL,
  85. )
  86. code = proc.wait()
  87. if code != 0:
  88. fail_(f"docker compose --profile loadgen-m10 up -d failed (exit {code})")
  89. pass_("3 loadgen-http instances started")
  90. # Wait for ramp-up
  91. print(f" waiting {SOAK_RAMP_SEC}s for ramp-up to complete...", flush=True)
  92. time.sleep(SOAK_RAMP_SEC)
  93. pass_(f"ramp-up complete — now targeting {CLUSTER_TARGET}/s")
  94. return proc
  95. def step2_monitor_soak() -> dict:
  96. """
  97. Monitor the soak: sample p99 + rate + DLQ every 30s.
  98. Fails fast on any breach.
  99. Returns dict of samples for the log.
  100. """
  101. print(f"\n Monitoring soak for {SOAK_DURATION_MIN} minutes...")
  102. samples = []
  103. start = time.time()
  104. deadline = start + SOAK_DURATION_MIN * 60
  105. sample_interval = 30 # seconds between samples
  106. while time.time() < deadline:
  107. time.sleep(sample_interval)
  108. elapsed = int(time.time() - start) // 60
  109. try:
  110. rate = lib.assert_rate_near(CLUSTER_TARGET, tolerance=RATE_TOLERANCE, window_seconds=30)
  111. p99 = lib.assert_ingest_p99_under(P99_THRESHOLD, window_seconds=60)
  112. dlq = lib.assert_dlq_count_equals(DLQ_EXPECTED, window_seconds=60)
  113. print(f" [{elapsed}m] rate={rate:.0f}/s p99={p99:.3f}s dlq={dlq}")
  114. samples.append({"elapsed_min": elapsed, "rate": rate, "p99": p99, "dlq": dlq})
  115. except AssertionError as e:
  116. fail_(f"soak breach at {elapsed}m: {e}")
  117. return samples
  118. def step3_runaway_test() -> None:
  119. """
  120. Runaway-source fault injection.
  121. Scenario:
  122. - loadgen-http-1 (acme-001/prom-prod) is already running at 1700/s.
  123. - loadgen-http-4 starts, targeting the SAME company+source at 1000/s.
  124. Combined: ~2700/s for acme-001/prom-prod. Per-source cap is 100/s,
  125. so ingestd rate-limits ~2600/s back with HTTP 429.
  126. - acme-002 (loadgen-http-2) and acme-003 (loadgen-http-3) continue
  127. unaffected at ~1700/s each.
  128. Pass condition: p99 for acme-002 and acme-003 stays ≤ P99_THRESHOLD (5s)
  129. during the 60-second runaway window.
  130. """
  131. print(f"\nStep 3 — runaway-source fault injection ({RUNAWAY_DURATION_SEC}s)")
  132. # Sources that must remain healthy (acme-002 and acme-003 send via different
  133. # source_ids: prom-prod is hard-coded in loadgen, but loadgen-http-2 and
  134. # loadgen-http-3 each send as their own company, so the per-source metric
  135. # query uses company_id as the label on ba_ingestd_publish_latency_seconds).
  136. # NOTE: the source_id label on the histogram is the SourceID field from the
  137. # alert payload (always "prom-prod" in the current loadgen). The
  138. # company_id is in the metric labels as "company_id".
  139. # We check the aggregate p99 for all non-acme-001 companies.
  140. healthy_companies = ["acme-002", "acme-003"]
  141. # Start the rogue loadgen (loadgen-http-4 is in the loadgen-m10 profile).
  142. print(" starting loadgen-http-4 (rogue, 10× per-source cap)...")
  143. proc = subprocess.Popen(
  144. ["docker", "compose", "up", "-d", "loadgen-http-4"],
  145. stdout=subprocess.DEVNULL,
  146. stderr=subprocess.DEVNULL,
  147. cwd="/root/broad-announce",
  148. )
  149. code = proc.wait()
  150. if code != 0:
  151. fail_("docker compose up -d loadgen-http-4 failed")
  152. pass_("loadgen-http-4 started")
  153. # Wait for ramp-up to complete (loadgen-http-4 uses 10s ramp-up).
  154. print(" waiting 15s for rogue ramp-up...", flush=True)
  155. time.sleep(15)
  156. # Sample per-source p99 every 10s for RUNAWAY_DURATION_SEC.
  157. print(f" sampling p99 every 10s for {RUNAWAY_DURATION_SEC}s...")
  158. samples = []
  159. start = time.time()
  160. deadline = start + RUNAWAY_DURATION_SEC
  161. while time.time() < deadline:
  162. time.sleep(10)
  163. elapsed = int(time.time() - start)
  164. # Check each healthy company: p99 must stay under threshold.
  165. # We query the per-source latency histogram using the company_id label.
  166. # Each company sends at ~1700/s; the rogue does not affect these.
  167. all_ok = True
  168. for company in healthy_companies:
  169. try:
  170. p99 = _per_company_p99(company, window_seconds=30)
  171. print(f" [{elapsed}s] {company} p99={p99:.3f}s")
  172. samples.append({"company": company, "elapsed": elapsed, "p99": p99})
  173. if p99 > P99_THRESHOLD:
  174. all_ok = False
  175. except AssertionError:
  176. all_ok = False
  177. if not all_ok:
  178. # Print what we saw before failing
  179. for s in samples:
  180. marker = "❌" if s["p99"] > P99_THRESHOLD else "✅"
  181. print(f" {marker} {s['company']} p99={s['p99']:.3f}s at {s['elapsed']}s")
  182. fail_(
  183. f"runaway-source p99 breach: one or more healthy companies exceeded "
  184. f"{P99_THRESHOLD}s p99 threshold during rogue injection"
  185. )
  186. pass_(
  187. f"all {len(healthy_companies)} healthy companies kept p99 ≤ {P99_THRESHOLD}s "
  188. f"throughout {RUNAWAY_DURATION_SEC}s rogue injection"
  189. )
  190. # Stop the rogue.
  191. print(" stopping loadgen-http-4 (rogue)...")
  192. r = subprocess.run(
  193. ["docker", "compose", "stop", "loadgen-http-4"],
  194. capture_output=True,
  195. )
  196. if r.returncode == 0:
  197. pass_("loadgen-http-4 stopped")
  198. else:
  199. warn_(f"failed to stop loadgen-http-4: {r.stderr.decode().strip()}")
  200. def _per_company_p99(company_id: str, window_seconds: int = 60) -> float:
  201. """
  202. Return p99 publish latency for a specific company_id.
  203. Queries ba_ingestd_publish_latency_seconds_bucket with company_id label.
  204. Returns 0.0 if no data.
  205. """
  206. query = (
  207. f'histogram_quantile(0.99, '
  208. f'rate(ba_ingestd_publish_latency_seconds_bucket{{company_id="{company_id}"}}[{window_seconds}s]))'
  209. )
  210. results = lib.scrape(query)
  211. if not results:
  212. # No data yet — treat as 0 (pre-warm). Will be caught if still 0 at end.
  213. return 0.0
  214. return float(results[0]["value"][1])
  215. def step4_dlq_invariant(samples: list[dict]) -> int:
  216. """Assert zero DLQ rows for the entire soak window."""
  217. print("\nStep 4 — DLQ invariant check")
  218. # The soak samples already checked DLQ delta, but do a final absolute check.
  219. # Query the full soak window.
  220. soak_seconds = SOAK_DURATION_MIN * 60
  221. dlq = lib.assert_dlq_count_equals(DLQ_EXPECTED, window_seconds=soak_seconds)
  222. pass_(f"DLQ count over {SOAK_DURATION_MIN}min soak: {dlq} (expected 0)")
  223. return dlq
  224. def step5_teardown(proc) -> None:
  225. """Bring down the loadgen cluster."""
  226. print("\nStep 5 — teardown")
  227. r = subprocess.run(
  228. ["docker", "compose", "--profile", "loadgen-m10", "down", "-v"],
  229. capture_output=True,
  230. )
  231. if r.returncode == 0:
  232. pass_("loadgen cluster torn down")
  233. else:
  234. warn_(f"teardown returned {r.returncode}: {r.stderr.decode().strip()}")
  235. def print_summary(samples: list[dict], dlq_final: int) -> None:
  236. """Print a summary table of the soak run."""
  237. print("\n=== M10 Soak Summary ===")
  238. print(f"Duration: {SOAK_DURATION_MIN} min")
  239. print(f"Target: {CLUSTER_TARGET}/s (±{RATE_TOLERANCE*100:.0f}%)")
  240. print(f"p99 threshold: {P99_THRESHOLD}s")
  241. print()
  242. if samples:
  243. print(f"{'Time':>6} {'Rate/s':>8} {'p99(s)':>7} {'DLQ':>4}")
  244. print("-" * 35)
  245. for s in samples:
  246. print(f"{s['elapsed_min']:>5}m {s['rate']:>8.0f} {s['p99']:>7.3f} {s['dlq']:>4}")
  247. print()
  248. print(f"Final DLQ count: {dlq_final} (expected 0)")
  249. print()
  250. print("🎉 M10 smoke: all checks complete.")
  251. def main():
  252. print(f"M10 Soak Test — target {CLUSTER_TARGET}/s for {SOAK_DURATION_MIN} min")
  253. print(f"p99 threshold: {P99_THRESHOLD}s | DLQ expected: {DLQ_EXPECTED}")
  254. print()
  255. try:
  256. step1_preflight()
  257. proc = step2_start_loadgen()
  258. samples = step2_monitor_soak()
  259. step3_runaway_test()
  260. dlq_final = step4_dlq_invariant(samples)
  261. step5_teardown(proc)
  262. print_summary(samples, dlq_final)
  263. except AssertionError as e:
  264. print(f"\n💥 M10 smoke FAILED: {e}")
  265. sys.exit(1)
  266. except KeyboardInterrupt:
  267. print("\nInterrupted.")
  268. sys.exit(1)
  269. if __name__ == "__main__":
  270. main()