mirror of
https://github.com/XRPLF/rippled.git
synced 2026-09-27 07:26:51 +00:00
capture_timings.py decides whether a captured timings file may become a regression baseline. Every way of getting that wrong is silently green: a capture that asked Prometheus for nothing still writes valid JSON, and once accepted it is pasted in as a baseline, still reads as a placeholder, and the regression gate stays off while the workflow reports it as activated. Covered: an empty surface is not complete (0 of 0 is 100% by arithmetic), the minimum ratio is inclusive, null values count as declared but not captured, the threshold is recorded so a rejected capture can be judged later, and the exit code follows the flag rather than recomputing the ratio. The empty case has its own error path because the percentage message divides by the declared count. Neither this file nor test_validate_telemetry.py ran anywhere before: not in CI, not in run-full-validation.sh, not in pre-commit. They now run in the naming job, which is fast and fires on nearly every PR, so a broken harness surfaces in seconds rather than after an xrpld build. They run as plain scripts. unittest discover would collect nothing from them, since they hold bare functions rather than TestCase subclasses, and would exit 0 -- which is why each file fails when it collects no tests. The dependency install is a separate step, placed after every stdlib-only check so those stay reachable if PyPI is unavailable.
277 lines
11 KiB
Python
277 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for capture_timings.py's completeness guard.
|
|
|
|
Run with plain python3 -- there is no pytest in the harness requirements, and
|
|
this file needs nothing but the standard library plus the aiohttp that
|
|
capture_timings.py already imports:
|
|
|
|
python3 docker/telemetry/workload/test_capture_timings.py
|
|
|
|
What is under test is the rule that decides whether a captured timings file may
|
|
become a regression baseline. It is worth pinning because every way of getting
|
|
it wrong is silently green: a capture that asked Prometheus for nothing, or got
|
|
almost nothing back, still produces a well-formed JSON file. If such a file is
|
|
accepted, it is pasted in as a baseline, still reads as a placeholder, and the
|
|
regression gate stays off while the workflow reports it as activated.
|
|
|
|
Both halves are covered: the predicate itself, and the exit code, because the
|
|
workflow keys on the exit code while the paste-me step keys on the flag in the
|
|
file. They must not be able to disagree.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
import capture_timings as ct # noqa: E402
|
|
|
|
|
|
def _surface(*values: float | None) -> dict[str, dict[str, Any]]:
|
|
"""A metrics dict of len(values) keys, None meaning nothing came back."""
|
|
return {f"metric_{i}": {"value": v} for i, v in enumerate(values)}
|
|
|
|
|
|
def test_empty_surface_is_not_complete() -> None:
|
|
"""A capture that declared nothing must never count as complete.
|
|
|
|
This is the vacuous-truth trap: 0 of 0 keys is 100% by arithmetic, so a
|
|
plain ratio test calls an empty capture a perfect one. It happens for real
|
|
whenever --metrics points at the wrong or a truncated file, since
|
|
build_query_plan returns an empty plan for that without complaining.
|
|
|
|
The production change that makes this fail: dropping the `declared > 0`
|
|
term from the `complete` predicate.
|
|
"""
|
|
status = ct._capture_status({}, min_ratio=0.5)
|
|
assert status["declared"] == 0
|
|
assert status["captured"] == 0
|
|
assert status["complete"] is False, status
|
|
|
|
|
|
def test_ratio_exactly_at_the_minimum_is_complete() -> None:
|
|
"""The bar is inclusive, so a capture sitting exactly on it passes.
|
|
|
|
The production change that makes this fail: using `>` instead of `>=`,
|
|
which would reject a run that met the stated minimum exactly.
|
|
"""
|
|
status = ct._capture_status(_surface(1.0, None), min_ratio=0.5)
|
|
assert (status["captured"], status["declared"]) == (1, 2)
|
|
assert status["complete"] is True, status
|
|
|
|
|
|
def test_ratio_just_below_the_minimum_is_not_complete() -> None:
|
|
"""One captured key out of three is below half and must be rejected.
|
|
|
|
The production change that makes this fail: comparing against a constant,
|
|
or ignoring min_ratio and accepting any non-zero capture.
|
|
"""
|
|
status = ct._capture_status(_surface(1.0, None, None), min_ratio=0.5)
|
|
assert (status["captured"], status["declared"]) == (1, 3)
|
|
assert status["complete"] is False, status
|
|
|
|
|
|
def test_null_values_are_declared_but_not_captured() -> None:
|
|
"""A key Prometheus had no answer for counts against the ratio.
|
|
|
|
Every declared key is present in the artifact, some with value null, so
|
|
counting keys rather than values would report a full capture on a run that
|
|
got nothing back.
|
|
|
|
The production change that makes this fail: setting captured to
|
|
len(metrics).
|
|
"""
|
|
status = ct._capture_status(_surface(1.0, 2.0, None, None), min_ratio=0.5)
|
|
assert status["declared"] == 4
|
|
assert status["captured"] == 2
|
|
|
|
|
|
def test_the_bar_it_was_judged_against_is_recorded() -> None:
|
|
"""The artifact must state its own threshold, not just the verdict.
|
|
|
|
Without it a rejected capture cannot be judged after the fact -- 8 of 20 is
|
|
a pass at 0.4 and a failure at 0.5, and the file is the only record of
|
|
which was asked for.
|
|
"""
|
|
assert ct._capture_status(_surface(1.0), min_ratio=0.75)["min_ratio"] == 0.75
|
|
|
|
|
|
def _run_main(monkey_report: dict[str, Any]) -> tuple[int, dict[str, Any]]:
|
|
"""Run main() against a crafted report, returning (exit code, written file).
|
|
|
|
capture() is replaced rather than mocked at the HTTP layer because what is
|
|
under test is what main() does with a report, not how the report is
|
|
obtained.
|
|
"""
|
|
original_capture, original_argv = ct.capture, sys.argv
|
|
|
|
async def fake_capture(**_kwargs: Any) -> dict[str, Any]:
|
|
return monkey_report
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "timings.json"
|
|
ct.capture = fake_capture
|
|
sys.argv = ["capture_timings.py", "--output", str(out)]
|
|
try:
|
|
code = ct.main()
|
|
written = json.loads(out.read_text())
|
|
finally:
|
|
ct.capture, sys.argv = original_capture, original_argv
|
|
return code, written
|
|
|
|
|
|
def _report(metrics: dict[str, Any], min_ratio: float = 0.5) -> dict[str, Any]:
|
|
"""A report shaped like capture()'s, with a real status block."""
|
|
return {
|
|
"schema_version": ct.SCHEMA_VERSION,
|
|
"captured_at": "2026-01-01T00:00:00Z",
|
|
"window": "3m",
|
|
"git_sha": "0" * 40,
|
|
"profile": "regression",
|
|
"capture": ct._capture_status(metrics, min_ratio),
|
|
"metrics": metrics,
|
|
}
|
|
|
|
|
|
def test_incomplete_capture_exits_nonzero_and_says_so_in_the_file() -> None:
|
|
"""The exit code and the flag in the file must agree.
|
|
|
|
The workflow gates on the exit code while the paste-me step reads the flag,
|
|
so a run where they disagreed would be refused by one and offered as
|
|
baseline material by the other.
|
|
|
|
The production change that makes this fail: returning 0 regardless, or
|
|
computing the exit code from something other than status["complete"].
|
|
"""
|
|
code, written = _run_main(_report(_surface(1.0, None, None)))
|
|
assert code == 1, f"incomplete capture exited {code}"
|
|
assert written["capture"]["complete"] is False
|
|
|
|
|
|
def test_complete_capture_exits_zero_and_says_so_in_the_file() -> None:
|
|
"""The passing direction, so the guard cannot be satisfied by always failing."""
|
|
code, written = _run_main(_report(_surface(1.0, 2.0)))
|
|
assert code == 0, f"complete capture exited {code}"
|
|
assert written["capture"]["complete"] is True
|
|
|
|
|
|
def test_empty_surface_exits_nonzero_without_dividing_by_zero() -> None:
|
|
"""The empty case needs its own error path, not the percentage one.
|
|
|
|
Reporting "0/0 (0%)" requires captured / total, which raises
|
|
ZeroDivisionError on an empty surface -- turning a clear rejection into a
|
|
traceback, and on some callers into a non-1 exit the workflow reads
|
|
differently.
|
|
|
|
The production change that makes this fail: removing the `total == 0`
|
|
branch and letting the percentage message handle every shortfall.
|
|
"""
|
|
code, written = _run_main(_report({}))
|
|
assert code == 1, f"empty capture exited {code}"
|
|
assert written["capture"]["declared"] == 0
|
|
assert written["capture"]["complete"] is False
|
|
|
|
|
|
def test_capture_records_a_status_block_carrying_the_requested_ratio() -> None:
|
|
"""capture() itself must build the status block, from the ratio it was given.
|
|
|
|
The other tests here hand main() a report built by this file, so none of them
|
|
runs the real capture(). Without this, capture() could ignore
|
|
--min-capture-ratio, or stop emitting the capture block at all, and the suite
|
|
would stay green while every downstream consumer lost the flag it keys on.
|
|
|
|
Only the Prometheus call is replaced; the query plan is built from the real
|
|
regression-metrics.json.
|
|
|
|
The production change that makes this fail: dropping the capture block from
|
|
the returned report, or hardcoding the ratio instead of using the argument.
|
|
"""
|
|
original = ct.run_query_plan
|
|
|
|
async def fake_run_query_plan(_session: Any, _url: str, _plan: Any) -> dict:
|
|
return {"kept": {"value": 1.0}, "missing": {"value": None}}
|
|
|
|
ct.run_query_plan = fake_run_query_plan
|
|
try:
|
|
report = asyncio.run(
|
|
ct.capture(
|
|
prom_url="http://prometheus.invalid",
|
|
metrics_path=Path(__file__).parent / "regression-metrics.json",
|
|
window="3m",
|
|
profile="regression",
|
|
min_capture_ratio=0.75,
|
|
)
|
|
)
|
|
finally:
|
|
ct.run_query_plan = original
|
|
|
|
assert "capture" in report, sorted(report)
|
|
status = report["capture"]
|
|
assert status["min_ratio"] == 0.75, status
|
|
assert (status["declared"], status["captured"]) == (2, 1), status
|
|
# 1 of 2 is 0.5, below the 0.75 that was asked for.
|
|
assert status["complete"] is False, status
|
|
|
|
|
|
def test_the_exit_code_follows_the_flag_not_a_recomputed_ratio() -> None:
|
|
"""main() must read capture.complete, not judge the counts itself.
|
|
|
|
The workflow gates on the exit code and the paste-me step reads the flag, so
|
|
the flag has to be the single source of truth. Recomputing the ratio in
|
|
main() would work today and drift the moment the two rules differed. Both
|
|
directions are asserted, because a mutation that recomputes agrees with the
|
|
flag on every self-consistent report -- only a contradictory one separates
|
|
them.
|
|
|
|
The production change that makes this fail: deriving the exit code from
|
|
captured/declared rather than from status["complete"].
|
|
"""
|
|
complete_flag_says_no = _report(_surface(1.0, 2.0))
|
|
complete_flag_says_no["capture"]["complete"] = False
|
|
code, _ = _run_main(complete_flag_says_no)
|
|
assert code == 1, "a report flagged incomplete must exit non-zero"
|
|
|
|
complete_flag_says_yes = _report(_surface(1.0, None, None))
|
|
complete_flag_says_yes["capture"]["complete"] = True
|
|
code, _ = _run_main(complete_flag_says_yes)
|
|
assert code == 0, "a report flagged complete must exit zero"
|
|
|
|
|
|
def main() -> int:
|
|
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
|
# Collecting nothing is a failure, not a pass. A rename of the test_ prefix,
|
|
# or running this file from a context where the globals are not populated,
|
|
# would otherwise print "0/0 passed" and exit 0 -- the silent green these
|
|
# tests exist to prevent, reproduced in the runner itself.
|
|
if not tests:
|
|
print("FAIL: no tests were collected")
|
|
return 1
|
|
failed = 0
|
|
for test in tests:
|
|
try:
|
|
test()
|
|
except AssertionError as exc:
|
|
failed += 1
|
|
print(f"FAIL {test.__name__}: {exc}")
|
|
except SystemExit as exc:
|
|
# argparse and other sys.exit() paths raise SystemExit, which is NOT
|
|
# an Exception subclass. Uncaught it aborts the whole file, so the
|
|
# remaining tests never run and nothing prints a FAIL line.
|
|
failed += 1
|
|
print(f"ERROR {test.__name__}: SystemExit({exc.code})")
|
|
except Exception as exc: # noqa: BLE001 - report any error as a failure
|
|
failed += 1
|
|
print(f"ERROR {test.__name__}: {type(exc).__name__}: {exc}")
|
|
else:
|
|
print(f"PASS {test.__name__}")
|
|
print(f"\n{len(tests) - failed}/{len(tests)} passed")
|
|
return 1 if failed else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|