mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-27 15:28:03 +00:00
The regression gate has been red on runs with no code change. Only two of the 25 gated keys ever tripped, both on the same span and never together: run 32862589645 failed p99 at 25.8750 ms against a 1.0600 ms baseline (+2341%), run 32867433073 failed p95 at 0.7500 ms against 0.2404 ms (+212%), and in each run the other quantile sat well inside its own bound. A real slowdown would move both. This is variance, not a defect. Measured across four CI runs: span.ledger.validate.p50 0.0484 to 0.0778 ms 1.6x spread kept span.ledger.validate.p95 0.1281 to 0.7500 ms 5.9x spread excluded span.ledger.validate.p99 0.3875 to 25.8750 ms 66.8x spread excluded Both excluded quantiles reach past their trip point on a healthy run. The mechanism is arrival timing, not slow code: the span opens only once a quorum-completing validation arrives (LedgerMaster.cpp:987, inside checkAccept, past the early return) and wraps the promotion work that follows, so one slow consensus round dominates the tail of a 3m rate window and which round that is differs every run. Widening is not available and must not be attempted later: tolerating 25.8750 ms against a 1.0600 ms baseline needs a bound of about 24.8 ms, which gates nothing. A bound admitting every healthy run's worst case admits every regression too. p50 stays gated; it is stable. THE GENERAL RULE, recorded so this does not recur: an absolute bound derived as hi_next minus baseline comes from the histogram ladder, so it budgets for quantization noise and for nothing else. It knows nothing about how far a metric moves between runs on identical code. Before gating any key, check its observed maximum across several runs against its trip point and gate it only with margin. Spread alone proves nothing: tx.apply.p50 swings 364x and never fires, because its 5 ms trip point absorbs the range. Of the 23 keys still gated the worst reaches 0.67 of its trip point. Mechanism: spans.names lists span names while _quantiles is shared, so dropping two quantiles of one span cannot be expressed by deleting a name. regression-metrics.json gains an excluded_keys map from a flat key to the reason it is not gated, subtracted by both prom_queries.py (so the key is never queried) and check_regression_bounds.py rule A. A per-name quantile override was rejected: a typo there leaves the key gating, whereas a typo in an exclusion subtracts nothing and new rule F rejects it, along with an empty reason, a leftover threshold override and a leftover baseline value. Derived figures recomputed from the committed baseline: 25 gated keys to 23, detection floor 2.02x-9.43x to 2.02x-9.42x, weakly guarded keys ten to nine, bound over baseline 102%-843% to 102%-842%. The baseline edit is a deletion of two entries only, with no value rewritten. Verified: both previously failing runs replay to zero regressions and exit 0; a tenfold increase injected into each of the 23 remaining keys in turn is still caught in all 23 cases; rule F was confirmed load-bearing by stubbing it out, which lets a stale exclusion pass.
279 lines
9.9 KiB
Python
279 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
||
"""Shared Prometheus query helpers for the regression gate.
|
||
|
||
Single source of truth for how regression metrics are computed. Both
|
||
``capture_timings.py`` and any future tooling consume this module so metric
|
||
name → PromQL expression stays consistent.
|
||
|
||
Design:
|
||
- Every captured metric has a key in the form ``{category}.{name}.p{quantile}``
|
||
(e.g. ``span.tx.process.p99``). Keys are flat strings so JSON diffing is
|
||
trivial.
|
||
- Quantile queries go through ``histogram_quantile`` over the standard
|
||
``_bucket`` series. The rate window is a parameter (defaults to the
|
||
capture window, not Prometheus's default 5m) so short CI runs are usable.
|
||
- The catalogue of what to capture lives in ``regression-metrics.json`` —
|
||
this module only knows how to translate that JSON into HTTP queries.
|
||
|
||
Usage::
|
||
|
||
import asyncio, aiohttp
|
||
from prom_queries import build_query_plan, run_query_plan
|
||
|
||
plan = build_query_plan("regression-metrics.json", window="3m")
|
||
async with aiohttp.ClientSession() as s:
|
||
timings = await run_query_plan(s, "http://localhost:9090", plan)
|
||
# timings = {"span.tx.process.p99": {"value": 12.4, "unit": "ms"}, ...}
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import aiohttp
|
||
|
||
logger = logging.getLogger("prom_queries")
|
||
|
||
# Instant queries run in parallel, but not all at once: a single-node
|
||
# Prometheus is easily saturated by a burst of the whole plan. With this cap
|
||
# the worst case (every query hitting the 30 s timeout) is
|
||
# ceil(len(plan) / 8) * 30 s rather than len(plan) * 30 s, which for a
|
||
# ~30-entry plan is ~2 min instead of ~15 min of a 30 min CI job.
|
||
MAX_CONCURRENT_QUERIES = 8
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class QueryEntry:
|
||
"""One metric to capture from Prometheus.
|
||
|
||
Attributes:
|
||
key: Flat output key, e.g. ``span.tx.process.p99``.
|
||
promql: The PromQL expression to send to /api/v1/query.
|
||
unit: Unit of the returned value, e.g. ``ms`` or ``us``.
|
||
Baseline JSON preserves this so the comparator can
|
||
sanity-check unit drift.
|
||
"""
|
||
|
||
key: str
|
||
promql: str
|
||
unit: str
|
||
|
||
|
||
def _build_simple_entries(
|
||
cfg: dict,
|
||
prefix: str,
|
||
window: str,
|
||
excluded: frozenset[str] = frozenset(),
|
||
) -> list[QueryEntry]:
|
||
"""Build QueryEntry list for a single-template category (spans, rpc).
|
||
|
||
``excluded`` holds flat keys the surface declares but does not gate (see
|
||
``excluded_keys`` in regression-metrics.json). They are dropped here rather
|
||
than filtered later, so a key that no longer gates is never queried and
|
||
never appears in ``timings.json`` — a captured key with no baseline reports
|
||
as "new metric (not in baseline)", which reads as coverage while being
|
||
unable to gate.
|
||
"""
|
||
tmpl = cfg.get("_query_template", "")
|
||
unit = cfg.get("_unit", "ms")
|
||
entries: list[QueryEntry] = []
|
||
for name in cfg.get("names", []):
|
||
for q in cfg.get("_quantiles", []):
|
||
if f"{prefix}.{name}.p{_quantile_label(q)}" in excluded:
|
||
continue
|
||
expr = (
|
||
tmpl.replace("{quantile}", _format_quantile(q))
|
||
.replace("{name}", name)
|
||
.replace("{window}", window)
|
||
)
|
||
entries.append(
|
||
QueryEntry(
|
||
key=f"{prefix}.{name}.p{_quantile_label(q)}",
|
||
promql=expr,
|
||
unit=unit,
|
||
)
|
||
)
|
||
return entries
|
||
|
||
|
||
def _build_job_entries(
|
||
cfg: dict, window: str, excluded: frozenset[str] = frozenset()
|
||
) -> list[QueryEntry]:
|
||
"""Build QueryEntry list for the job_queue category (multi-phase).
|
||
|
||
``excluded`` is applied for the same reason as in _build_simple_entries; the
|
||
key format is flat, so one exclusion list covers both categories.
|
||
"""
|
||
unit = cfg.get("_unit", "us")
|
||
phases = cfg.get("_phases", ["queued", "running"])
|
||
tmpl_map = {
|
||
"queued": cfg.get("_queued_template", ""),
|
||
"running": cfg.get("_running_template", ""),
|
||
}
|
||
entries: list[QueryEntry] = []
|
||
for name in cfg.get("names", []):
|
||
for phase in phases:
|
||
tmpl = tmpl_map.get(phase, "")
|
||
if not tmpl:
|
||
continue
|
||
for q in cfg.get("_quantiles", []):
|
||
if f"job.{name}.{phase}.p{_quantile_label(q)}" in excluded:
|
||
continue
|
||
expr = (
|
||
tmpl.replace("{quantile}", _format_quantile(q))
|
||
.replace("{name}", name)
|
||
.replace("{window}", window)
|
||
)
|
||
entries.append(
|
||
QueryEntry(
|
||
key=f"job.{name}.{phase}.p{_quantile_label(q)}",
|
||
promql=expr,
|
||
unit=unit,
|
||
)
|
||
)
|
||
return entries
|
||
|
||
|
||
def build_query_plan(metrics_path: str | Path, window: str = "3m") -> list[QueryEntry]:
|
||
"""Translate regression-metrics.json into a list of PromQL queries.
|
||
|
||
Args:
|
||
metrics_path: Path to ``regression-metrics.json``.
|
||
window: Rate window passed to ``rate()``. For short CI runs
|
||
keep this close to the test duration so the bucket
|
||
counts are meaningful. Default 3m matches the
|
||
``regression`` workload profile.
|
||
|
||
Returns:
|
||
A list of ``QueryEntry`` values, one per (metric × quantile), less any
|
||
key listed in the config's ``excluded_keys`` map.
|
||
"""
|
||
with open(metrics_path) as f:
|
||
cfg = json.load(f)
|
||
|
||
excluded = frozenset(cfg.get("excluded_keys", {}))
|
||
plan: list[QueryEntry] = []
|
||
plan.extend(_build_simple_entries(cfg.get("spans", {}), "span", window, excluded))
|
||
plan.extend(
|
||
_build_simple_entries(cfg.get("rpc_methods", {}), "rpc", window, excluded)
|
||
)
|
||
plan.extend(_build_job_entries(cfg.get("job_queue", {}), window, excluded))
|
||
return plan
|
||
|
||
|
||
async def run_query_plan(
|
||
session: aiohttp.ClientSession,
|
||
prom_url: str,
|
||
plan: list[QueryEntry],
|
||
) -> dict[str, dict[str, Any]]:
|
||
"""Execute a query plan and return a flat ``key → {value, unit}`` map.
|
||
|
||
Queries that return no data (NaN, empty result) are still included in
|
||
the output with ``value: null`` — the comparator treats missing values
|
||
as "not yet observed" rather than as a regression. This keeps the
|
||
baseline schema stable across runs with different load levels.
|
||
|
||
Queries run concurrently, at most MAX_CONCURRENT_QUERIES at a time. Keys
|
||
are emitted in plan order regardless of which query answers first.
|
||
|
||
``return_exceptions=True`` is what keeps the fan-out self-contained.
|
||
Without it the first escaping exception returns from ``gather`` while its
|
||
siblings keep running against a session the caller is about to close, so
|
||
the capture ends with dozens of orphaned queries and warnings from a
|
||
closed session. With it, every query is awaited before this returns, and
|
||
an unexpected failure is logged and recorded as no data -- the same
|
||
outcome _instant_query already produces for a query that fails.
|
||
|
||
Args:
|
||
session: Shared aiohttp session.
|
||
prom_url: Base URL of Prometheus (e.g. ``http://localhost:9090``).
|
||
plan: Output of :func:`build_query_plan`.
|
||
|
||
Returns:
|
||
Mapping from metric key to ``{"value": float|None, "unit": str}``.
|
||
"""
|
||
gate = asyncio.Semaphore(MAX_CONCURRENT_QUERIES)
|
||
|
||
async def fetch(entry: QueryEntry) -> float | None:
|
||
"""Run one plan entry once a query slot is free."""
|
||
async with gate:
|
||
return await _instant_query(session, prom_url, entry.promql)
|
||
|
||
results = await asyncio.gather(
|
||
*(fetch(entry) for entry in plan), return_exceptions=True
|
||
)
|
||
|
||
captured: dict[str, dict[str, Any]] = {}
|
||
for entry, result in zip(plan, results, strict=True):
|
||
value: float | None
|
||
if isinstance(result, BaseException):
|
||
logger.error(
|
||
"query for %s raised %s: %s",
|
||
entry.key,
|
||
type(result).__name__,
|
||
result,
|
||
)
|
||
value = None
|
||
else:
|
||
value = result
|
||
captured[entry.key] = {"value": value, "unit": entry.unit}
|
||
return captured
|
||
|
||
|
||
async def _instant_query(
|
||
session: aiohttp.ClientSession,
|
||
prom_url: str,
|
||
promql: str,
|
||
) -> float | None:
|
||
"""POST an instant query to Prometheus; return the scalar value or None.
|
||
|
||
None is returned for NaN, empty results, or HTTP errors — every call
|
||
site treats None identically ("no data captured").
|
||
"""
|
||
url = f"{prom_url.rstrip('/')}/api/v1/query"
|
||
try:
|
||
async with session.post(
|
||
url, data={"query": promql}, timeout=aiohttp.ClientTimeout(total=30)
|
||
) as resp:
|
||
if resp.status != 200:
|
||
logger.warning("query HTTP %d: %s", resp.status, promql)
|
||
return None
|
||
body = await resp.json()
|
||
# JSONDecodeError covers a 200 response whose body is not JSON: without it
|
||
# one malformed reply aborts the whole capture, since no caller of
|
||
# run_query_plan wraps it.
|
||
except (aiohttp.ClientError, TimeoutError, json.JSONDecodeError) as exc:
|
||
logger.warning("query failed: %s — %s", promql, exc)
|
||
return None
|
||
|
||
if body.get("status") != "success":
|
||
logger.warning("query status=%s: %s", body.get("status"), promql)
|
||
return None
|
||
|
||
result = body.get("data", {}).get("result", [])
|
||
if not result:
|
||
return None
|
||
|
||
raw = result[0].get("value", [None, None])[1]
|
||
if raw is None or raw in ("NaN", "+Inf", "-Inf"):
|
||
return None
|
||
try:
|
||
return float(raw)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _format_quantile(q: float) -> str:
|
||
"""Format a quantile for PromQL (``0.99`` → ``"0.99"``)."""
|
||
return f"{q:g}"
|
||
|
||
|
||
def _quantile_label(q: float) -> str:
|
||
"""Format a quantile for the output key (``0.95`` → ``"95"``)."""
|
||
return str(int(round(q * 100)))
|