Files
rippled/docker/telemetry/workload/prom_queries.py
Pratik Mankawde 3836078a78 fix(telemetry): stop gating ledger.validate p95 and p99, which vary too much
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.
2026-08-25 18:21:35 +01:00

279 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)))