mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 14:20:56 +00:00
Fixes the review findings on this PR that belong to files it owns, plus several defects found while verifying those fixes. Findings in files owned by upstream branches are routed there and left untouched here. Correctness: - tx_submitter: advance the account sequence only on results that actually consume one (tes*, tec*, terQUEUED). tem*/tef*/tel* never reach the ledger, so advancing left a permanent gap that every later submit from that account inherited. Add a re-fetch hatch so a repeated non-consuming failure cannot livelock on the same sequence, and gate the account check on funded-ness rather than list length. - validate_telemetry: filter spans by name before collecting attributes, so a per-span attribute contract can no longer be satisfied by a sibling span; require exact name equality for non-wildcard children and glob matching for wildcards; bounds-check every returned series instead of only the first. - collect_system_metrics: select xrpld by argv[0] rather than a substring match on the whole command line, which averaged in unrelated processes and reported their RSS as xrpld's. Count genuine 0.0 CPU readings, use a clamped nearest-rank p99 index, and record RPC latency only on success. - benchmark: return each verdict through a named variable instead of a command substitution, so the pass/fail counters survive and the exit gate can fire. Scale before dividing in the percentage math, which truncated a 1.26% impact to 1.00% and cleared a 1% threshold. - compare_to_baseline: fall back to the absolute bound when the baseline is not positive, so a 0 -> 500 ms jump is no longer "within bounds". - rpc_load_generator: bound each connection to one in-flight recv(), drain in-flight requests before closing, use a nearest-rank percentile, and report delivery shortfall so an under-delivered run cannot pass with a 0% error rate. Fail loudly instead of silently: - run-full-validation: treat a consensus timeout and a missing validated ledger as fatal infrastructure errors, and fold the orchestrator and benchmark exit codes into the final status. A degraded cluster previously ran a full validation pass and reported misleading downstream failures. - collect_system_metrics: warn per empty measurement source, emit metrics_complete, and exit non-zero instead of substituting zeros that pass every threshold. Require GNU date with %N rather than falling back to a per-sample python3 fork that costs more than the threshold it is measured against. - benchmark: distinguish "could not measure" from "exceeded thresholds", install a cleanup trap so a failure cannot leak nodes and ports, and report an unusable baseline as inconclusive. - workload_orchestrator: bound subprocess communicate() and fail the exit gate on per-phase errors. Also pins the workload compose images to the versions the sibling stack already uses, hash-pins the Python dependencies, restricts the validator config template to loopback, corrects the dashboard and metric counts in the reference docs, drops a span from the regression gate that cannot fire under a WebSocket-only workload, and narrows the teardown pkill pattern so it no longer matches processes that merely mention the work directory. Verified with a full harness run against a local five-node cluster: 158 of 158 checks passed with no regressions detected.
256 lines
8.9 KiB
Python
256 lines
8.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,
|
||
) -> list[QueryEntry]:
|
||
"""Build QueryEntry list for a single-template category (spans, rpc)."""
|
||
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", []):
|
||
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) -> list[QueryEntry]:
|
||
"""Build QueryEntry list for the job_queue category (multi-phase)."""
|
||
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", []):
|
||
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).
|
||
"""
|
||
with open(metrics_path) as f:
|
||
cfg = json.load(f)
|
||
|
||
plan: list[QueryEntry] = []
|
||
plan.extend(_build_simple_entries(cfg.get("spans", {}), "span", window))
|
||
plan.extend(_build_simple_entries(cfg.get("rpc_methods", {}), "rpc", window))
|
||
plan.extend(_build_job_entries(cfg.get("job_queue", {}), window))
|
||
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)))
|