m10_lib.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. """
  2. m10_lib.py — shared assertion library for M10 smoke scripts.
  3. Used by both m10_smoke.py (5k/s soak) and m10_bench_smoke.py (50k/s broker ceiling).
  4. All Prometheus queries use URL-encoding-safe Python urllib to avoid the
  5. bashquoting bugs that plagued m9_smoke.sh.
  6. """
  7. import urllib.request
  8. import urllib.parse
  9. import time
  10. import sys
  11. PROM = "http://localhost:9090"
  12. PROM_TIMEOUT = 10 # seconds
  13. def scrape(query: str) -> list[dict]:
  14. """
  15. Run a Prometheus query and return the result vector.
  16. Returns [] on error or no data.
  17. """
  18. url = f"{PROM}/api/v1/query?query={urllib.parse.quote(query, safe='')}"
  19. try:
  20. with urllib.request.urlopen(url, timeout=PROM_TIMEOUT) as r:
  21. data = r.read()
  22. d = __import__("json").loads(data)
  23. if d.get("status") != "success":
  24. return []
  25. return d.get("data", {}).get("result", [])
  26. except Exception:
  27. return []
  28. def assert_ingest_p99_under(threshold: float, window_seconds: int = 60,
  29. label_filter: str = "") -> float:
  30. """
  31. Assert p99 ingest latency (ba_ingestd_publish_latency_seconds) is under threshold.
  32. Uses histogram_quantile over the given sliding window.
  33. Returns the actual p99 value, or raises AssertionError.
  34. """
  35. filter_expr = f',{{{label_filter}}}' if label_filter else ''
  36. query = (
  37. f'histogram_quantile(0.99, '
  38. f'rate(ba_ingestd_publish_latency_seconds_bucket{{{filter_expr}}}[{window_seconds}s]))'
  39. )
  40. results = scrape(query)
  41. if not results:
  42. raise AssertionError(
  43. f"p99 query returned no data. Is ingestd up and generating load? "
  44. f"(query: {query})"
  45. )
  46. p99 = float(results[0]["value"][1])
  47. if p99 > threshold:
  48. raise AssertionError(
  49. f"p99 ingest latency {p99:.3f}s exceeds threshold {threshold}s "
  50. f"(window={window_seconds}s, filter='{label_filter}')"
  51. )
  52. return p99
  53. def per_source_p99(source_id: str, window_seconds: int = 60) -> float:
  54. """
  55. Return the p99 publish latency for a specific source_id.
  56. Returns 0.0 if no data.
  57. """
  58. query = (
  59. f'histogram_quantile(0.99, '
  60. f'rate(ba_ingestd_publish_latency_seconds_bucket{{source_id="{source_id}"}}[{window_seconds}s]))'
  61. )
  62. results = scrape(query)
  63. if not results:
  64. return 0.0
  65. return float(results[0]["value"][1])
  66. def assert_dlq_count_equals(expected: int, window_seconds: int) -> int:
  67. """
  68. Assert the DLQ row count (delta over window) equals expected.
  69. Uses Prometheus counter delta (ba_deliverd_dlq_total).
  70. Returns the actual count.
  71. """
  72. query = (
  73. f'increase(ba_deliverd_dlq_total[{window_seconds}s])'
  74. )
  75. results = scrape(query)
  76. if not results:
  77. # No DLQ entries at all
  78. actual = 0
  79. else:
  80. actual = int(float(results[0]["value"][1]))
  81. if actual != expected:
  82. raise AssertionError(
  83. f"DLQ count {actual} != expected {expected} "
  84. f"(window={window_seconds}s). Run 'docker exec ... psql ... "
  85. f"SELECT count(*) FROM deliveries_dlq' for precise count."
  86. )
  87. return actual
  88. def assert_rate_near(target: float, tolerance: float = 0.05,
  89. window_seconds: int = 30) -> float:
  90. """
  91. Assert the cluster-wide accept rate is within tolerance of target.
  92. tolerance=0.05 means ±5%.
  93. Returns the actual rate.
  94. """
  95. query = (
  96. f'rate(ba_ingestd_alerts_received_total{{result="accepted"}}[{window_seconds}s])'
  97. )
  98. results = scrape(query)
  99. if not results:
  100. raise AssertionError(
  101. f"rate query returned no data. Is ingestd receiving alerts? (query: {query})"
  102. )
  103. # Sum all series (multiple source_ids)
  104. actual = sum(float(r["value"][1]) for r in results)
  105. lower = target * (1 - tolerance)
  106. upper = target * (1 + tolerance)
  107. if not (lower <= actual <= upper):
  108. raise AssertionError(
  109. f"rate {actual:.1f}/s is outside ±{tolerance*100:.0f}% "
  110. f"tolerance of target {target}/s (range: {lower:.1f}–{upper:.1f})"
  111. )
  112. return actual
  113. def assert_no_rate_limit_storm(window_seconds: int = 30) -> float:
  114. """
  115. Assert rate-limited hits are < 5% of total accepted.
  116. Returns the rate-limited fraction.
  117. """
  118. accepted_q = f'rate(ba_ingestd_alerts_received_total{{result="accepted"}}[{window_seconds}s])'
  119. rl_q = f'rate(ba_ingestd_alerts_received_total{{result="rate_limited"}}[{window_seconds}s])'
  120. accepted_results = scrape(accepted_q)
  121. rl_results = scrape(rl_q)
  122. accepted = sum(float(r["value"][1]) for r in accepted_results) if accepted_results else 0
  123. rl = sum(float(r["value"][1]) for r in rl_results) if rl_results else 0
  124. if accepted == 0:
  125. return 0.0
  126. frac = rl / accepted
  127. if frac > 0.05:
  128. raise AssertionError(
  129. f"rate-limited fraction {frac:.1%} exceeds 5% threshold "
  130. f"(accepted={accepted:.1f}/s, rate_limited={rl:.1f}/s)"
  131. )
  132. return frac
  133. def wait_until(fn, timeout_seconds: float, interval: float = 1.0, name: str = ""):
  134. """
  135. Poll fn() every interval until it returns truthy or timeout.
  136. Returns fn() result on success, raises on timeout.
  137. """
  138. deadline = time.time() + timeout_seconds
  139. last_err = None
  140. while time.time() < deadline:
  141. try:
  142. result = fn()
  143. if result:
  144. return result
  145. except Exception as e:
  146. last_err = e
  147. time.sleep(interval)
  148. raise AssertionError(
  149. f"{name or 'condition'} did not become true within {timeout_seconds}s"
  150. + (f": {last_err}" if last_err else "")
  151. )
  152. def get_prometheus_targets() -> dict[str, str]:
  153. """
  154. Return {job_name: up_value} for all Prometheus scrape targets.
  155. up_value is "1" (up) or "0" (down).
  156. """
  157. results = scrape('up')
  158. return {r["metric"]["job"]: r["value"][1] for r in results}
  159. def get_metric_series_count(metric_name: str) -> int:
  160. """Return number of time series for a metric."""
  161. results = scrape(metric_name)
  162. return len(results)
  163. def get_circuit_breaker_state(component: str = "nats") -> int:
  164. """Return current circuit breaker state: 0=CLOSED, 1=HALF-OPEN, 2=OPEN."""
  165. results = scrape(f'ba_ingestd_circuit_breaker_state{{component="{component}"}}')
  166. if not results:
  167. return -1 # not initialized
  168. return int(float(results[0]["value"][1]))