mirror of
https://github.com/hanzoai/datastore.git
synced 2026-10-30 14:48:37 +00:00
CI compresses uploaded text artifacts larger than a threshold with zstd (ci/praktika/s3.py, COMPRESS_THRESHOLD_MB), so the same object (e.g. a perf shard's all-query-metrics.tsv) can be served either plain or as a `.zst` sibling. The report tools decoded the bytes as UTF-8 and failed on the zstd magic byte, silently dropping the larger shards. Make every artifact reader handle plain, zstd, and gzip transparently: detect by magic bytes and, for downloads, fall back to the `.zst` sibling when the plain object is 403/404. - fetch_perf_report.py: maybe_decompress + `.zst` fallback in the fetch and download paths. - fetch_ci_report.js: decompress the response body by magic; retry `.zst` on 403/404 (zlib.zstdDecompressSync, with a zstd-CLI fallback). - perf_api.py: read local `.zst`/`.gz` TSVs transparently. - Regression tests: plain vs gzip vs zstd parse identically. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1384 lines
60 KiB
Python
Executable File
1384 lines
60 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Small helper for the ClickHouse performance.ci API.
|
|
|
|
This is intentionally dependency-free: Python stdlib only.
|
|
It prints markdown-ish summaries for agents/humans.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import gzip
|
|
import io
|
|
import json
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
import urllib.parse
|
|
import urllib.request
|
|
from collections import defaultdict
|
|
from typing import Any
|
|
|
|
BASE_DEFAULT = "https://performance.ci.clickhouse.com/api/v1"
|
|
PLAY_DEFAULT = "https://play.clickhouse.com/"
|
|
DEFAULT_METRICS = "client_time,real_time,cpu_time,memory"
|
|
|
|
|
|
def q(s: Any) -> str:
|
|
return urllib.parse.quote(str(s), safe="")
|
|
|
|
|
|
def fetch_json(base: str, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
url = base.rstrip("/") + path
|
|
if params:
|
|
clean = {k: v for k, v in params.items() if v is not None}
|
|
if clean:
|
|
url += "?" + urllib.parse.urlencode(clean, doseq=True)
|
|
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "clickhouse-performance-analysis-skill"})
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
return json.load(r)
|
|
|
|
|
|
def api_url(base: str, path: str, params: dict[str, Any] | None = None) -> str:
|
|
url = base.rstrip("/") + path
|
|
if params:
|
|
clean = {k: v for k, v in params.items() if v is not None}
|
|
if clean:
|
|
url += "?" + urllib.parse.urlencode(clean, doseq=True)
|
|
return url
|
|
|
|
|
|
def change_pct(x: Any) -> str:
|
|
if x is None:
|
|
return "—"
|
|
try:
|
|
v = float(x)
|
|
except Exception:
|
|
return str(x)
|
|
# Observed API values are fractional: 0.232 means +23.2%.
|
|
if abs(v) <= 10:
|
|
return f"{v * 100:+.2f}%"
|
|
return f"{v:+.2f}%"
|
|
|
|
|
|
def num(x: Any) -> str:
|
|
if x is None:
|
|
return "—"
|
|
try:
|
|
v = float(x)
|
|
except Exception:
|
|
return str(x)
|
|
if abs(v) >= 1000:
|
|
return f"{v:.0f}"
|
|
if abs(v) >= 10:
|
|
return f"{v:.3f}"
|
|
return f"{v:.6g}"
|
|
|
|
|
|
def over_threshold(row: dict[str, Any]) -> str:
|
|
if row.get("diffPercent") is None or row.get("statThreshold") is None:
|
|
return "—"
|
|
try:
|
|
diff = abs(float(row.get("diffPercent")))
|
|
threshold = abs(float(row.get("statThreshold")))
|
|
except Exception:
|
|
return "—"
|
|
# API floats are often rounded in display but differ by tiny binary/decimal error.
|
|
return "yes" if diff + 1e-6 >= threshold else "no"
|
|
|
|
|
|
def optional_fetch_json(base: str, path: str, params: dict[str, Any] | None = None) -> tuple[Any | None, str | None]:
|
|
try:
|
|
return fetch_json(base, path, params), None
|
|
except Exception as e:
|
|
return None, f"{type(e).__name__}: {e}"
|
|
|
|
|
|
def sql_quote(value: Any) -> str:
|
|
return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'"
|
|
|
|
|
|
def clickhouse_play_json_each_row(sql: str, play_url: str = PLAY_DEFAULT, user: str = "explorer") -> list[dict[str, Any]]:
|
|
url = play_url.rstrip("/") + "/?" + urllib.parse.urlencode({"user": user})
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=sql.encode(),
|
|
method="POST",
|
|
headers={"User-Agent": "clickhouse-performance-analysis-skill"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
text = r.read().decode()
|
|
rows = []
|
|
for line in text.splitlines():
|
|
line = line.strip()
|
|
if line:
|
|
rows.append(json.loads(line))
|
|
return rows
|
|
|
|
|
|
def shorten(text: Any, max_len: int = 170) -> str:
|
|
if text is None:
|
|
return "—"
|
|
value = " ".join(str(text).split())
|
|
if len(value) <= max_len:
|
|
return value
|
|
return value[: max_len - 1].rstrip() + "…"
|
|
|
|
|
|
def module_fact_summary(module: dict[str, Any], max_items: int = 4) -> str:
|
|
facts = []
|
|
for item in module.get("rows") or []:
|
|
label = item.get("label") or ""
|
|
value = item.get("value")
|
|
if not label or label.lower() == "status":
|
|
continue
|
|
facts.append(f"{label}: {value}")
|
|
if not facts:
|
|
return "—"
|
|
return "; ".join(facts[:max_items])
|
|
|
|
|
|
def confidence_overall(row: dict[str, Any]) -> tuple[str, str]:
|
|
conf = row.get("confidence") or {}
|
|
tier = conf.get("tier") or "scored"
|
|
reason = conf.get("reason") or "no overall reason returned"
|
|
# The current dashboard confidence vocabulary is slowdown/regression-oriented.
|
|
# Do not print slowdown/regression wording on a speedup row in a run summary.
|
|
if row.get("direction") == "speedup":
|
|
if "regression" in tier:
|
|
tier = "stable_speedup"
|
|
reason = reason.replace("slowdown", "change").replace("regression", "change")
|
|
return tier, reason
|
|
|
|
|
|
def evidence_class(row: dict[str, Any]) -> str:
|
|
conf = row.get("confidence") or {}
|
|
raw_tier = str(conf.get("tier") or "").lower()
|
|
raw_reason = str(conf.get("reason") or "").lower()
|
|
if row.get("bucket") == "high-noise/unstable-threshold":
|
|
return "HIGH-NOISE"
|
|
tier, reason = confidence_overall(row) if conf else ("", "")
|
|
tier_l = tier.lower()
|
|
reason_l = reason.lower() or raw_reason
|
|
# Tier is authoritative. Reasons can mention "history noise" even for confirmed rows.
|
|
if "noise" in tier_l:
|
|
return "NOISE"
|
|
if row.get("direction") == "speedup" and ("stable" in tier_l or "confirmed" in tier_l or "stable" in raw_tier or "confirmed" in raw_tier):
|
|
return "STABLE SPEEDUP"
|
|
if row.get("direction") == "slowdown" and ("confirmed" in tier_l or "regression" in tier_l or "confirmed" in raw_tier or "regression" in raw_tier):
|
|
return "SLOWDOWN SIGNAL"
|
|
if tier_l or raw_tier:
|
|
return "SUSPECT"
|
|
if "noise" in reason_l:
|
|
return "NOISE"
|
|
return "UNSCORED"
|
|
|
|
|
|
def is_noise_or_uncertain(row: dict[str, Any]) -> bool:
|
|
return evidence_class(row) in {"NOISE", "HIGH-NOISE", "UNSCORED"}
|
|
|
|
|
|
def display_confidence_tier(row: dict[str, Any], tier: str) -> str:
|
|
tier_l = str(tier or "").lower()
|
|
if row.get("direction") == "slowdown" and ("confirmed" in tier_l or "regression" in tier_l):
|
|
return "supported slowdown"
|
|
if row.get("direction") == "speedup" and ("stable" in tier_l or "confirmed" in tier_l):
|
|
return "supported speedup"
|
|
return str(tier or "scored")
|
|
|
|
|
|
def confidence_status(row: dict[str, Any]) -> str:
|
|
conf = row.get("confidence") or {}
|
|
prefix = evidence_class(row)
|
|
if conf.get("tier") or conf.get("reason"):
|
|
tier, reason = confidence_overall(row)
|
|
return f"**{prefix}** — {display_confidence_tier(row, tier)}: {shorten(reason, 95)}"
|
|
modules = conf.get("modules") or []
|
|
if not modules:
|
|
return "—"
|
|
useful = []
|
|
for m in modules:
|
|
status = m.get("status")
|
|
if status and status != "neutral":
|
|
title = m.get("title") or m.get("code") or "module"
|
|
useful.append(f"{title}: {status}")
|
|
return f"**{prefix}** — {shorten('; '.join(useful) if useful else 'scored, no decisive module', 120)}"
|
|
|
|
|
|
def print_confidence_details(rows: list[dict[str, Any]]) -> None:
|
|
seen = set()
|
|
any_conf = False
|
|
for r in rows:
|
|
conf = r.get("confidence") or {}
|
|
modules = conf.get("modules") or []
|
|
if not conf:
|
|
continue
|
|
key = row_key(r)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
any_conf = True
|
|
tier, reason = confidence_overall(r)
|
|
print(f"\n### Confidence explanation: `{r.get('test','—')}/{r.get('queryIndex','—')}` `{r.get('metric','—')}/{r.get('arch','—')}`\n")
|
|
print(f"Overall: **{display_confidence_tier(r, tier)}** — {reason}\n")
|
|
if modules:
|
|
print("| Evidence check | Status | Useful facts | Interpretation |")
|
|
print("|---|---|---|---|")
|
|
for m in modules:
|
|
title = m.get("title") or m.get("code") or "module"
|
|
print(f"| {title} | {m.get('status','—')} | {shorten(module_fact_summary(m), 180)} | {shorten(m.get('interpretation'), 220)} |")
|
|
if not any_conf:
|
|
print("\nNo confidence details returned for the selected row(s).")
|
|
|
|
def row_key(row: dict[str, Any]) -> tuple:
|
|
query_index = row.get("queryIndex")
|
|
if query_index is None:
|
|
query_index = -1
|
|
return (row.get("arch") or "", row.get("test") or "", query_index, row.get("metric") or "")
|
|
|
|
|
|
def rows_for_display(rows: list[dict[str, Any]], limit: int | None = None) -> list[dict[str, Any]]:
|
|
rows = sorted(rows, key=lambda r: abs(float(r.get("diffPercent") or 0)), reverse=True)
|
|
return rows[:limit] if limit else rows
|
|
|
|
|
|
def dashboard_link_for_row(run_id: str | None, row: dict[str, Any]) -> str:
|
|
if not run_id:
|
|
return "—"
|
|
test = row.get("test")
|
|
query_index = row.get("queryIndex")
|
|
if test is None or query_index is None:
|
|
return "—"
|
|
return f"[open]({dashboard_query_url(run_id, test, query_index, row.get('metric'))})"
|
|
|
|
|
|
def dashboard_test_query_link_for_row(run_id: str | None, row: dict[str, Any]) -> str:
|
|
if not run_id:
|
|
return "—"
|
|
test = row.get("test")
|
|
query_index = row.get("queryIndex")
|
|
if test is None or query_index is None:
|
|
return "—"
|
|
return f"[test page]({dashboard_test_url(run_id, test, query_index, row.get('metric'))})"
|
|
|
|
|
|
def print_rows(title: str, rows: list[dict[str, Any]], limit: int | None = None, run_id: str | None = None) -> None:
|
|
print(f"\n## {title}\n")
|
|
if not rows:
|
|
print("No rows.\n")
|
|
return
|
|
rows = rows_for_display(rows, limit)
|
|
if run_id:
|
|
print("| Direction | Arch | Test | Query | Dashboard | Metric | Old | New | Diff | Threshold | Over threshold | Confidence |")
|
|
print("|---|---|---|---:|---|---|---:|---:|---:|---:|---|---|")
|
|
else:
|
|
print("| Direction | Arch | Test | Query | Metric | Old | New | Diff | Threshold | Over threshold | Confidence |")
|
|
print("|---|---|---|---:|---|---:|---:|---:|---:|---|---|")
|
|
for r in rows:
|
|
common = (
|
|
f"| {r.get('direction','—')} | {r.get('arch','—')} | `{r.get('test','—')}` | {r.get('queryIndex','—')} | "
|
|
)
|
|
if run_id:
|
|
common += f"{dashboard_link_for_row(run_id, r)} | "
|
|
print(
|
|
common
|
|
+ f"`{r.get('metric','—')}` | {num(r.get('oldValue'))} | {num(r.get('newValue'))} | "
|
|
f"{change_pct(r.get('diffPercent'))} | {change_pct(r.get('statThreshold'))} | {over_threshold(r)} | {confidence_status(r)} |"
|
|
)
|
|
print()
|
|
|
|
|
|
def cmd_runs(args: argparse.Namespace) -> None:
|
|
search = args.pr if args.pr is not None else args.q
|
|
data = fetch_json(args.base, "/runs", {"q": search, "metrics": args.metrics})
|
|
items = data.get("items") or []
|
|
if args.pr is not None:
|
|
try:
|
|
pr = int(args.pr)
|
|
items = [x for x in items if (x.get("identity") or {}).get("prNumber") == pr]
|
|
except Exception:
|
|
pass
|
|
print(f"API: {api_url(args.base, '/runs', {'q': search, 'metrics': args.metrics})}\n")
|
|
print("| Time | Run ID | Dashboard | PR | Old | New | Arches | Changed | Slowdowns | Speedups | Reports |")
|
|
print("|---|---|---|---:|---|---|---|---:|---:|---:|---:|")
|
|
for item in items:
|
|
ident = item.get("identity") or {}
|
|
run_id = ident.get('runId', '—')
|
|
print(
|
|
f"| {ident.get('runTime','—')} | `{run_id}` | [open]({dashboard_run_url(run_id)}) | {ident.get('prNumber','—')} | "
|
|
f"`{str(ident.get('oldSha',''))[:12]}` | `{str(ident.get('newSha',''))[:12]}` | "
|
|
f"{','.join(item.get('arches') or [])} | {item.get('changedQueries','—')} | "
|
|
f"{item.get('slowdownQueries','—')} | {item.get('speedupQueries','—')} | {item.get('reportCount','—')} |"
|
|
)
|
|
|
|
|
|
|
|
def attach_confidence_for_display(args: argparse.Namespace, row_groups: list[list[dict[str, Any]]]) -> list[str]:
|
|
if not getattr(args, "with_confidence", False):
|
|
return []
|
|
rows: list[dict[str, Any]] = []
|
|
for group in row_groups:
|
|
rows.extend(rows_for_display(group, args.limit))
|
|
tests = sorted({r.get("test") for r in rows if r.get("test")})
|
|
confidence_by_key: dict[tuple, dict[str, Any]] = {}
|
|
errors: list[str] = []
|
|
for test in tests:
|
|
conf_path = f"/runs/{q(args.run_id)}/tests/{q(test)}/confidence"
|
|
conf, err = optional_fetch_json(args.base, conf_path, {"metrics": args.metrics})
|
|
if err:
|
|
errors.append(f"{test}: {err}")
|
|
continue
|
|
for cr in (conf.get("changedMetrics") or []) + (conf.get("unstableMetrics") or []):
|
|
if cr.get("confidence"):
|
|
confidence_by_key[row_key(cr)] = cr.get("confidence")
|
|
for r in rows:
|
|
conf = confidence_by_key.get(row_key(r))
|
|
if conf:
|
|
r["confidence"] = conf
|
|
return errors
|
|
|
|
|
|
def cmd_run(args: argparse.Namespace) -> None:
|
|
data = fetch_json(args.base, f"/runs/{q(args.run_id)}", {"metrics": args.metrics})
|
|
slowdowns = data.get("slowdowns") or []
|
|
speedups = data.get("speedups") or []
|
|
unstable = data.get("unstableQueries") or []
|
|
confidence_errors = attach_confidence_for_display(args, [slowdowns, speedups, unstable])
|
|
print(f"API: {api_url(args.base, f'/runs/{q(args.run_id)}', {'metrics': args.metrics})}\n")
|
|
print(f"Dashboard: {dashboard_run_url(args.run_id)}\n")
|
|
run = data.get("run") or {}
|
|
print(f"# Run `{run.get('runId', args.run_id)}`\n")
|
|
for card in data.get("summaryCards") or []:
|
|
print(f"- {card.get('label')}: **{card.get('value')}** {card.get('detail') or ''}")
|
|
if getattr(args, "with_confidence", False):
|
|
print("\n_Confidence requested: fetched per-test confidence for displayed rows where available._")
|
|
for err in confidence_errors[:5]:
|
|
print(f"- confidence unavailable for {err}")
|
|
print_rows("Slowdowns", slowdowns, args.limit, run_id=args.run_id)
|
|
print_rows("Speedups", speedups, args.limit, run_id=args.run_id)
|
|
if unstable:
|
|
print("\n_Note: `unstableQueries` from the API are high-noise/high-threshold rows. They are useful for flakiness context, but many are not over the reporting threshold and may not be counted in the run summary cards._")
|
|
print_rows("High-noise / unstable-threshold rows", unstable, args.limit, run_id=args.run_id)
|
|
|
|
|
|
|
|
def cmd_changes(args: argparse.Namespace) -> None:
|
|
cmd_run(args)
|
|
|
|
|
|
|
|
def parse_time(value: Any) -> datetime | None:
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def percentile(values: list[float], p: float) -> float:
|
|
if not values:
|
|
return float("nan")
|
|
xs = sorted(values)
|
|
if len(xs) == 1:
|
|
return xs[0]
|
|
pos = (len(xs) - 1) * p
|
|
lo = int(pos)
|
|
hi = min(lo + 1, len(xs) - 1)
|
|
frac = pos - lo
|
|
return xs[lo] * (1 - frac) + xs[hi] * frac
|
|
|
|
|
|
def period_text(times: list[datetime]) -> str:
|
|
if not times:
|
|
return "unknown period"
|
|
start = min(times)
|
|
end = max(times)
|
|
delta = end - start
|
|
if delta.days >= 2:
|
|
span = f"{delta.days}d"
|
|
else:
|
|
span = f"{delta.total_seconds() / 3600:.1f}h"
|
|
return f"{start.date()} → {end.date()} ({span})"
|
|
|
|
|
|
def point_pairs(points: list[dict[str, Any]]) -> list[tuple[datetime | None, float]]:
|
|
out = []
|
|
for p in points:
|
|
value = p.get("value")
|
|
if isinstance(value, (int, float)):
|
|
out.append((parse_time(p.get("time")), float(value)))
|
|
return out
|
|
|
|
|
|
def pct_summary(values: list[float]) -> str:
|
|
if not values:
|
|
return "no numeric values"
|
|
return (
|
|
f"p05={num(percentile(values, 0.05))}, "
|
|
f"p25={num(percentile(values, 0.25))}, "
|
|
f"p50={num(percentile(values, 0.50))}, "
|
|
f"p75={num(percentile(values, 0.75))}, "
|
|
f"p95={num(percentile(values, 0.95))}"
|
|
)
|
|
|
|
|
|
def summarize_points(points: list[dict[str, Any]], recent_n: int = 30) -> str:
|
|
pairs = point_pairs(points)
|
|
if not pairs:
|
|
return "No numeric points."
|
|
pairs.sort(key=lambda x: x[0] or datetime.min.replace(tzinfo=timezone.utc))
|
|
vals = [v for _, v in pairs]
|
|
times = [t for t, _ in pairs if t is not None]
|
|
recent = pairs[-min(recent_n, len(pairs)) :]
|
|
recent_vals = [v for _, v in recent]
|
|
recent_times = [t for t, _ in recent if t is not None]
|
|
return (
|
|
f"{len(vals)} points over {period_text(times)}; "
|
|
f"all: {pct_summary(vals)}; "
|
|
f"recent {len(recent_vals)} points over {period_text(recent_times)}: {pct_summary(recent_vals)}"
|
|
)
|
|
|
|
|
|
def selected_value_context(rows: list[dict[str, Any]], points: list[dict[str, Any]], label: str) -> str:
|
|
pairs = point_pairs(points)
|
|
vals = [v for _, v in pairs]
|
|
if not vals or not rows:
|
|
return ""
|
|
row = rows[0]
|
|
old = row.get("oldValue")
|
|
new = row.get("newValue")
|
|
try:
|
|
old_f = float(old)
|
|
new_f = float(new)
|
|
except Exception:
|
|
return ""
|
|
p50 = percentile(vals, 0.50)
|
|
p95 = percentile(vals, 0.95)
|
|
p05 = percentile(vals, 0.05)
|
|
def ratio(v: float, denom: float) -> str:
|
|
return "—" if not denom else f"{v / denom:.2f}x"
|
|
return (
|
|
f"Selected row vs {label}: old={num(old_f)} ({ratio(old_f, p50)} p50), "
|
|
f"new={num(new_f)} ({ratio(new_f, p50)} p50, {ratio(new_f, p95)} p95; p05={num(p05)}, p95={num(p95)})."
|
|
)
|
|
|
|
|
|
def change_points_summary(change_points: list[dict[str, Any]], limit: int = 8) -> str:
|
|
if not change_points:
|
|
return "No change points returned."
|
|
seen = set()
|
|
deduped = []
|
|
for cp in change_points:
|
|
key = (cp.get("time"), cp.get("direction"), cp.get("causalSha"), cp.get("magnitude"), cp.get("disparity"))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
deduped.append(cp)
|
|
deduped.sort(key=lambda cp: parse_time(cp.get("time")) or datetime.min.replace(tzinfo=timezone.utc))
|
|
times = [parse_time(cp.get("time")) for cp in deduped if parse_time(cp.get("time"))]
|
|
lines = [f"{len(deduped)} unique change points over {period_text(times)}; latest {min(limit, len(deduped))}:"]
|
|
for cp in deduped[-limit:]:
|
|
lines.append(
|
|
f"- {cp.get('time','—')} {cp.get('direction','—')} "
|
|
f"magnitude={num(cp.get('magnitude'))} disparity={num(cp.get('disparity'))} "
|
|
f"sha={str(cp.get('causalSha') or '')[:12] or '—'} trust={cp.get('trustLabel') or '—'}"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def dashboard_run_url(run_id: str) -> str:
|
|
return f"https://performance.ci.clickhouse.com/runs/{q(run_id)}"
|
|
|
|
|
|
def dashboard_test_url(run_id: str, test: str, query_index: int | str | None = None, metric: str | None = None) -> str:
|
|
params = {}
|
|
if query_index is not None:
|
|
params["query_index"] = query_index
|
|
if metric:
|
|
params["metrics"] = metric
|
|
search = ("?" + urllib.parse.urlencode(params)) if params else ""
|
|
return f"https://performance.ci.clickhouse.com/runs/{q(run_id)}/tests/{q(test)}{search}"
|
|
|
|
|
|
def dashboard_query_url(run_id: str, test: str, query_index: int | str, metric: str | None = None) -> str:
|
|
params = {}
|
|
if metric:
|
|
params["metrics"] = metric
|
|
search = ("?" + urllib.parse.urlencode(params)) if params else ""
|
|
return f"https://performance.ci.clickhouse.com/runs/{q(run_id)}/tests/{q(test)}/queries/{query_index}{search}"
|
|
|
|
|
|
def ascii_diff_chart(points: list[tuple[str, str, dict[str, Any] | None]]) -> str:
|
|
numeric = []
|
|
for time_text, run_id, row in points:
|
|
if not row:
|
|
continue
|
|
try:
|
|
numeric.append((time_text, run_id, float(row.get("diffPercent")), over_threshold(row)))
|
|
except Exception:
|
|
continue
|
|
if not numeric:
|
|
return "No numeric changed rows for chart."
|
|
max_abs = max(abs(v) for _, _, v, _ in numeric) or 1.0
|
|
lines = ["Diff chart, oldest → newest (`*` = over threshold):"]
|
|
for time_text, run_id, diff, over in numeric:
|
|
width = max(1, int(abs(diff) / max_abs * 24))
|
|
bar = ("+" if diff >= 0 else "-") * width
|
|
star = "*" if over == "yes" else " "
|
|
lines.append(f"{time_text[:10]} {change_pct(diff):>9} {star} {bar} {run_id[:18]}…")
|
|
return "\n".join(lines)
|
|
|
|
def leaf(stack: str) -> str:
|
|
if not stack:
|
|
return "—"
|
|
return stack.split(";")[-1][:160]
|
|
|
|
|
|
def cmd_query(args: argparse.Namespace) -> None:
|
|
params = {"metrics": args.metrics}
|
|
path = f"/runs/{q(args.run_id)}/tests/{q(args.test)}/queries/{args.query_index}"
|
|
data = fetch_json(args.base, path, params)
|
|
print(f"API: {api_url(args.base, path, params)}\n")
|
|
print(f"Dashboard: {dashboard_query_url(args.run_id, args.test, args.query_index, args.metric)}\n")
|
|
print(f"# `{args.test}/{args.query_index}`\n")
|
|
print(f"Query display: {data.get('queryDisplayName') or '—'}\n")
|
|
if data.get("queryText"):
|
|
print("```sql")
|
|
print(data["queryText"].strip())
|
|
print("```\n")
|
|
rows = data.get("metrics") or []
|
|
if args.metric:
|
|
rows = [r for r in rows if r.get("metric") == args.metric]
|
|
if args.arch:
|
|
rows = [r for r in rows if r.get("arch") == args.arch]
|
|
print_rows("Matching metric rows", rows, None, run_id=args.run_id)
|
|
|
|
if args.with_confidence:
|
|
conf_path = f"/runs/{q(args.run_id)}/tests/{q(args.test)}/confidence"
|
|
conf, err = optional_fetch_json(args.base, conf_path, {"metrics": args.metrics})
|
|
print(f"\n## Confidence\n\nAPI: {api_url(args.base, conf_path, {'metrics': args.metrics})}\n")
|
|
if err:
|
|
print(f"Not available: `{err}`\n")
|
|
else:
|
|
conf_rows = (conf.get("changedMetrics") or []) + (conf.get("unstableMetrics") or [])
|
|
conf_rows = [r for r in conf_rows if r.get("queryIndex") == args.query_index]
|
|
if args.metric:
|
|
conf_rows = [r for r in conf_rows if r.get("metric") == args.metric]
|
|
if args.arch:
|
|
conf_rows = [r for r in conf_rows if r.get("arch") == args.arch]
|
|
print_rows("Matching confidence rows", conf_rows, None, run_id=args.run_id)
|
|
print_confidence_details(conf_rows)
|
|
|
|
if args.with_trend:
|
|
trend_path = f"/runs/{q(args.run_id)}/tests/{q(args.test)}/trend"
|
|
trend_params = {"metric": args.metric, "queryIndex": args.query_index, "arch": args.arch}
|
|
trend, err = optional_fetch_json(args.base, trend_path, trend_params)
|
|
print(f"\n## Trend\n\nAPI: {api_url(args.base, trend_path, trend_params)}\n")
|
|
if err:
|
|
print(f"Not available: `{err}`")
|
|
else:
|
|
trend_points = trend.get("points") or []
|
|
print(summarize_points(trend_points))
|
|
ctx = selected_value_context(rows, trend_points, "trend distribution")
|
|
if ctx:
|
|
print(ctx)
|
|
selected = trend.get("selectedRunPoint")
|
|
if selected:
|
|
print(f"Selected run marker: `{selected.get('time')}` sha={str(selected.get('label') or '')[:12]} (marker only; measured values are in the table above)")
|
|
|
|
if args.with_history:
|
|
hist_path = f"/history/{q(args.test)}/{args.query_index}"
|
|
hist_params = {"metric": args.metric, "arch": args.arch}
|
|
hist, err = optional_fetch_json(args.base, hist_path, hist_params)
|
|
print(f"\n## History\n\nAPI: {api_url(args.base, hist_path, hist_params)}\n")
|
|
if err:
|
|
print(f"Not available: `{err}`")
|
|
else:
|
|
center = hist.get("centerTrend") or []
|
|
print("Center trend:", summarize_points(center))
|
|
ctx = selected_value_context(rows, center, "history center trend")
|
|
if ctx:
|
|
print(ctx)
|
|
print(change_points_summary(hist.get("changePoints") or []))
|
|
period = hist.get("periodComparisons") or []
|
|
if period:
|
|
print("\nPeriod comparisons returned by API:")
|
|
for pc in period[:8]:
|
|
print(f"- {pc.get('direction','—')} magnitude={num(pc.get('magnitude'))} disparity={num(pc.get('disparity'))} leadingSha={str(pc.get('leadingSha') or '')[:12]} trust={pc.get('trustLabel') or '—'}")
|
|
|
|
if args.with_coverage:
|
|
cov_path = f"/runs/{q(args.run_id)}/tests/{q(args.test)}/coverage"
|
|
cov_params = {"fileSet": "intersecting"}
|
|
cov, err = optional_fetch_json(args.base, cov_path, cov_params)
|
|
print(f"\n## Coverage / PR overlap\n\nAPI: {api_url(args.base, cov_path, cov_params)}\n")
|
|
if err:
|
|
print(f"Not available: `{err}`")
|
|
else:
|
|
totals = cov.get("totals") or {}
|
|
files = cov.get("files") or []
|
|
intersecting = totals.get('intersectingFiles')
|
|
if intersecting is None:
|
|
intersecting = len(files)
|
|
print(f"coverageAvailable={cov.get('coverageAvailable')} source={cov.get('coverageSource') or '—'} message={cov.get('message') or '—'}")
|
|
print(f"touched={totals.get('touchedFiles','—')} covered={totals.get('coveredFiles','—')} intersecting={intersecting}")
|
|
for f in files[:20]:
|
|
ranges = f.get("coverageRanges") or []
|
|
print(f"- `{f.get('path')}` status={f.get('status')} changes={f.get('changes')} ranges={len(ranges)}")
|
|
|
|
if args.with_flamegraph_diff:
|
|
fg_path = f"/runs/{q(args.run_id)}/tests/{q(args.test)}/queries/{args.query_index}/flamegraph-diff"
|
|
fg_params = {"metric": args.metric, "arch": args.arch, "traceType": args.trace_type}
|
|
fg, err = optional_fetch_json(args.base, fg_path, fg_params)
|
|
fg_url = api_url(args.base, fg_path, fg_params)
|
|
ui_url = dashboard_query_url(args.run_id, args.test, args.query_index, args.metric)
|
|
print(f"\n## Flamegraph diff ({args.trace_type})\n\nUI (query page, Flamegraphs card): {ui_url}\n")
|
|
print(f"Raw API: {fg_url}\n")
|
|
if err:
|
|
print(f"Not available: `{err}`")
|
|
else:
|
|
frames = fg.get("frames") or []
|
|
frames = sorted(frames, key=lambda f: abs(float(f.get("candidateSamples") or 0) - float(f.get("baselineSamples") or 0)), reverse=True)
|
|
if not frames:
|
|
print("No diff frames returned.")
|
|
else:
|
|
print("| Leaf | Baseline | Candidate | Delta |")
|
|
print("|---|---:|---:|---:|")
|
|
for f in frames[:20]:
|
|
b = float(f.get("baselineSamples") or 0)
|
|
c = float(f.get("candidateSamples") or 0)
|
|
print(f"| `{leaf(f.get('stack') or '')}` | {b:.0f} | {c:.0f} | {c-b:+.0f} |")
|
|
|
|
|
|
|
|
def fetch_pr_runs(base: str, pr: str, metrics: str) -> list[dict[str, Any]]:
|
|
runs = fetch_json(base, "/runs", {"q": pr, "metrics": metrics}).get("items") or []
|
|
try:
|
|
pr_int = int(pr)
|
|
runs = [x for x in runs if (x.get("identity") or {}).get("prNumber") == pr_int]
|
|
except Exception:
|
|
pass
|
|
return runs
|
|
|
|
|
|
def collect_run_rows(base: str, items: list[dict[str, Any]], metrics: str, metric_filter: str = "client_time", arch_filter: str = "") -> list[dict[str, Any]]:
|
|
rows: list[dict[str, Any]] = []
|
|
for item in items:
|
|
ident = item.get("identity") or {}
|
|
run_id = ident.get("runId")
|
|
if not run_id:
|
|
continue
|
|
detail = fetch_json(base, f"/runs/{q(run_id)}", {"metrics": metrics})
|
|
for bucket, label in (("slowdowns", "slowdown"), ("speedups", "speedup"), ("unstableQueries", "high-noise/unstable-threshold")):
|
|
for r in detail.get(bucket) or []:
|
|
if metric_filter and r.get("metric") != metric_filter:
|
|
continue
|
|
if arch_filter and r.get("arch") != arch_filter:
|
|
continue
|
|
row = dict(r)
|
|
row["runId"] = run_id
|
|
row["runTime"] = ident.get("runTime")
|
|
row["oldSha"] = ident.get("oldSha")
|
|
row["newSha"] = ident.get("newSha")
|
|
row["bucket"] = label
|
|
# unstableQueries still carry a real direction; keep it for sorting/counts.
|
|
row["direction"] = row.get("direction") or label
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def attach_confidence_to_rows(base: str, metrics: str, rows: list[dict[str, Any]]) -> list[str]:
|
|
"""Attach dashboard confidence to API inventory rows in-place, grouped by run/test."""
|
|
by_run_test: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
|
for r in rows:
|
|
run_id = r.get("runId")
|
|
test = r.get("test")
|
|
if run_id and test:
|
|
by_run_test[(run_id, test)].append(r)
|
|
errors: list[str] = []
|
|
for (run_id, test), group in sorted(by_run_test.items()):
|
|
conf_path = f"/runs/{q(run_id)}/tests/{q(test)}/confidence"
|
|
conf, err = optional_fetch_json(base, conf_path, {"metrics": metrics})
|
|
if err:
|
|
errors.append(f"{run_id} {test}: {err}")
|
|
continue
|
|
confidence_by_key: dict[tuple, dict[str, Any]] = {}
|
|
for cr in (conf.get("changedMetrics") or []) + (conf.get("unstableMetrics") or []):
|
|
if cr.get("confidence"):
|
|
confidence_by_key[row_key(cr)] = cr.get("confidence")
|
|
for r in group:
|
|
row_conf = confidence_by_key.get(row_key(r))
|
|
if row_conf:
|
|
r["confidence"] = row_conf
|
|
return errors
|
|
|
|
|
|
def tsv_bool(value: Any) -> bool:
|
|
return str(value).strip().lower() in {"1", "true", "yes"}
|
|
|
|
|
|
def float_or(value: Any, default: float) -> float:
|
|
try:
|
|
if value is None or value == "":
|
|
return default
|
|
return float(value)
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
RAW_ALL_QUERY_METRICS_COLUMNS = [
|
|
"metric_name",
|
|
"left",
|
|
"right",
|
|
"diff",
|
|
"times_change",
|
|
"stat_threshold",
|
|
"test",
|
|
"query_index",
|
|
"query_display_name",
|
|
"changed_threshold",
|
|
"unstable_threshold",
|
|
]
|
|
DEFAULT_CHANGED_THRESHOLD = 0.15
|
|
DEFAULT_UNSTABLE_THRESHOLD = 0.25
|
|
|
|
|
|
def read_text_maybe_compressed(path: str) -> str:
|
|
"""Read a file as text, transparently decompressing zstd/gzip content.
|
|
|
|
CI stores text artifacts above a size threshold as zstd (see ci/praktika/s3.py), so a saved
|
|
all-query-metrics.tsv may be either plain or compressed. Detected by magic bytes.
|
|
"""
|
|
with open(path, "rb") as f:
|
|
data = f.read()
|
|
if data[:4] == b"\x28\xb5\x2f\xfd": # zstd magic
|
|
try:
|
|
import zstandard # optional dependency
|
|
data = zstandard.ZstdDecompressor().stream_reader(io.BytesIO(data)).read()
|
|
except ImportError:
|
|
proc = subprocess.run(["zstd", "-dcq"], input=data, capture_output=True, timeout=120)
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"zstd decompression failed: {proc.stderr.decode('utf-8', 'replace')}")
|
|
data = proc.stdout
|
|
elif data[:2] == b"\x1f\x8b": # gzip magic
|
|
data = gzip.decompress(data)
|
|
return data.decode("utf-8")
|
|
|
|
|
|
def iter_tsv_dicts(path: str) -> list[dict[str, str]]:
|
|
"""Read named TSV or headerless raw all-query-metrics.tsv rows."""
|
|
reader = csv.reader(io.StringIO(read_text_maybe_compressed(path)), delimiter="\t")
|
|
raw_rows = [row for row in reader if row]
|
|
if not raw_rows:
|
|
return []
|
|
first = raw_rows[0]
|
|
header_names = {"metric", "metric_name", "test", "query_index", "queryIndex", "diff"}
|
|
if header_names.intersection(first):
|
|
header = first
|
|
data_rows = raw_rows[1:]
|
|
else:
|
|
header = RAW_ALL_QUERY_METRICS_COLUMNS
|
|
data_rows = raw_rows
|
|
out: list[dict[str, str]] = []
|
|
for row in data_rows:
|
|
padded = row + [""] * max(0, len(header) - len(row))
|
|
out.append(dict(zip(header, padded)))
|
|
return out
|
|
|
|
|
|
def parse_perf_tsv(paths: list[str], metric_filter: str = "client_time", arch_filter: str = "") -> list[dict[str, Any]]:
|
|
"""Parse fetch_perf_report.py --tsv output or raw all-query-metrics.tsv-like files."""
|
|
rows: list[dict[str, Any]] = []
|
|
for path in paths:
|
|
for src in iter_tsv_dicts(path):
|
|
metric = src.get("metric") or src.get("metric_name") or metric_filter
|
|
if metric_filter and metric != metric_filter:
|
|
continue
|
|
arch = src.get("arch") or arch_filter or ""
|
|
if arch_filter and arch != arch_filter:
|
|
continue
|
|
try:
|
|
diff = float(src.get("diff") or 0)
|
|
threshold = float(src.get("stat_threshold") or src.get("threshold") or 0)
|
|
except Exception:
|
|
continue
|
|
changed_threshold = float_or(src.get("changed_threshold"), DEFAULT_CHANGED_THRESHOLD)
|
|
unstable_threshold = float_or(src.get("unstable_threshold"), DEFAULT_UNSTABLE_THRESHOLD)
|
|
if "is_changed" in src:
|
|
is_changed = tsv_bool(src.get("is_changed"))
|
|
else:
|
|
# Match ci/jobs/scripts/perf/compare.sh changed_fail:
|
|
# abs(diff) > changed_threshold AND abs(diff) >= stat_threshold
|
|
is_changed = abs(diff) > changed_threshold and abs(diff) >= threshold
|
|
if "is_unstable" in src:
|
|
is_unstable = tsv_bool(src.get("is_unstable"))
|
|
else:
|
|
# Match compare.sh unstable_fail:
|
|
# NOT changed_fail AND stat_threshold > unstable_threshold
|
|
is_unstable = (not is_changed) and threshold > unstable_threshold
|
|
direction_raw = (src.get("direction") or "").lower()
|
|
if direction_raw in {"slower", "slowdown"}:
|
|
direction = "slowdown"
|
|
elif direction_raw in {"faster", "speedup"}:
|
|
direction = "speedup"
|
|
elif diff > 0:
|
|
direction = "slowdown"
|
|
elif diff < 0:
|
|
direction = "speedup"
|
|
else:
|
|
direction = "same"
|
|
try:
|
|
query_index: int | str = int(src.get("query_index") or src.get("queryIndex") or 0)
|
|
except Exception:
|
|
query_index = src.get("query_index") or src.get("queryIndex") or "—"
|
|
rows.append({
|
|
"source": path,
|
|
"bucket": "changed" if is_changed else ("unstable" if is_unstable else "unchanged"),
|
|
"metric": metric,
|
|
"arch": arch,
|
|
"shard": src.get("shard") or src.get("shard_num") or "",
|
|
"test": src.get("test") or "",
|
|
"queryIndex": query_index,
|
|
"queryDisplayName": src.get("query") or src.get("query_display_name") or "",
|
|
"oldValue": src.get("old") or src.get("left"),
|
|
"newValue": src.get("new") or src.get("right"),
|
|
"diffPercent": diff,
|
|
"timesChange": src.get("times_change"),
|
|
"statThreshold": threshold,
|
|
"isChanged": is_changed,
|
|
"isUnstable": is_unstable,
|
|
"direction": direction,
|
|
})
|
|
return rows
|
|
|
|
|
|
def compare_key(row: dict[str, Any]) -> tuple:
|
|
query_index = row.get("queryIndex")
|
|
if query_index is None:
|
|
query_index = -1
|
|
return (row.get("arch") or "", row.get("test") or "", query_index, row.get("metric") or "")
|
|
|
|
|
|
def row_abs_diff(row: dict[str, Any]) -> float:
|
|
try:
|
|
return abs(float(row.get("diffPercent") or 0))
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
def row_evidence(row: dict[str, Any]) -> str:
|
|
conf = confidence_status(row)
|
|
if conf != "—":
|
|
return conf
|
|
if row.get("bucket") == "high-noise/unstable-threshold":
|
|
if over_threshold(row) == "yes":
|
|
return "**HIGH-NOISE** — raw diff is at/above threshold; confidence not returned"
|
|
return "**HIGH-NOISE** — below adaptive/stat threshold; confidence not returned"
|
|
return "**UNSCORED** — confidence not returned"
|
|
|
|
|
|
def print_inventory_table(rows: list[dict[str, Any]], limit: int | None = None) -> None:
|
|
rows = sorted(rows, key=row_abs_diff, reverse=True)
|
|
if limit:
|
|
rows = rows[:limit]
|
|
print("| Time | Direction | Arch | Test | Query | Dashboard | Old | New | Diff | Threshold | Over threshold | Evidence / next action |")
|
|
print("|---|---|---|---|---:|---|---:|---:|---:|---:|---|---|")
|
|
for r in rows:
|
|
print(
|
|
f"| {r.get('runTime','—')} | {r.get('direction','—')} | {r.get('arch','—')} | `{r.get('test','—')}` | {r.get('queryIndex','—')} | "
|
|
f"{dashboard_test_query_link_for_row(r.get('runId'), r)} | {num(r.get('oldValue'))} | {num(r.get('newValue'))} | "
|
|
f"{change_pct(r.get('diffPercent'))} | {change_pct(r.get('statThreshold'))} | {over_threshold(r)} | {row_evidence(r)} |"
|
|
)
|
|
print()
|
|
|
|
|
|
def print_inventory_rows(title: str, rows: list[dict[str, Any]], limit: int | None, note: str = "", group_evidence: bool = False) -> None:
|
|
print(f"\n## {title}\n")
|
|
if note:
|
|
print(f"_{note}_\n")
|
|
if not rows:
|
|
print("No rows.\n")
|
|
return
|
|
if not group_evidence:
|
|
print_inventory_table(rows, limit)
|
|
return
|
|
signal = [r for r in rows if not is_noise_or_uncertain(r)]
|
|
rest = [r for r in rows if is_noise_or_uncertain(r)]
|
|
if signal:
|
|
print(f"### Signal evidence ({len(signal)})\n")
|
|
print_inventory_table(signal, limit)
|
|
if rest:
|
|
print(f"### Noise / uncertain evidence ({len(rest)})\n")
|
|
print_inventory_table(rest, limit)
|
|
|
|
|
|
def summary_rank(row: dict[str, Any]) -> tuple[int, float]:
|
|
klass = evidence_class(row)
|
|
weight = {
|
|
"SLOWDOWN SIGNAL": 5,
|
|
"STABLE SPEEDUP": 5,
|
|
"SUSPECT": 3,
|
|
"UNSCORED": 1,
|
|
"NOISE": 0,
|
|
"HIGH-NOISE": -1,
|
|
}.get(klass, 0)
|
|
return (weight, row_abs_diff(row))
|
|
|
|
|
|
def print_summary_bullets(title: str, rows: list[dict[str, Any]], limit: int = 5) -> None:
|
|
print(f"\n### {title}\n")
|
|
if not rows:
|
|
print("- None in the current candidate.\n")
|
|
return
|
|
for r in sorted(rows, key=summary_rank, reverse=True)[:limit]:
|
|
print(
|
|
f"- {row_evidence(r)} — {r.get('direction','—')} `{r.get('arch','—')}` "
|
|
f"`{r.get('test','—')}` q{r.get('queryIndex','—')} "
|
|
f"{change_pct(r.get('diffPercent'))}; {dashboard_test_query_link_for_row(r.get('runId'), r)}"
|
|
)
|
|
print()
|
|
|
|
|
|
def print_inventory_summary(current_slow: list[dict[str, Any]], current_fast: list[dict[str, Any]], current_high_noise: list[dict[str, Any]]) -> None:
|
|
likely_signal = [r for r in current_slow if not is_noise_or_uncertain(r)]
|
|
likely_improvements = [r for r in current_fast if not is_noise_or_uncertain(r)]
|
|
likely_noise = [r for r in current_slow + current_fast + current_high_noise if is_noise_or_uncertain(r)]
|
|
print("\n## Summary layer\n")
|
|
print("_Small generated shortlist before the full tables. Use this to decide what to read first; the full tables below remain the source of detail._")
|
|
print_summary_bullets("Top likely signal", likely_signal)
|
|
print_summary_bullets("Top likely noise", likely_noise)
|
|
print_summary_bullets("Top improvements", likely_improvements)
|
|
|
|
|
|
def aggregate_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
by_key: dict[tuple, list[dict[str, Any]]] = defaultdict(list)
|
|
for r in rows:
|
|
query_index = r.get("queryIndex")
|
|
if query_index is None:
|
|
query_index = -1
|
|
by_key[(r.get("arch") or "", r.get("test") or "", query_index, r.get("metric") or "")].append(r)
|
|
out = []
|
|
for (arch, test, query_index, metric), vals in by_key.items():
|
|
vals = sorted(vals, key=lambda r: r.get("runTime") or "")
|
|
slow = [r for r in vals if r.get("direction") == "slowdown"]
|
|
fast = [r for r in vals if r.get("direction") == "speedup"]
|
|
high_noise = [r for r in vals if r.get("bucket") == "high-noise/unstable-threshold"]
|
|
latest = vals[-1]
|
|
max_slow = max([float(r.get("diffPercent") or 0) for r in slow], default=0.0)
|
|
max_fast = min([float(r.get("diffPercent") or 0) for r in fast], default=0.0)
|
|
out.append({
|
|
"arch": arch,
|
|
"test": test,
|
|
"queryIndex": query_index,
|
|
"metric": metric,
|
|
"runs": len(vals),
|
|
"slowdowns": len(slow),
|
|
"speedups": len(fast),
|
|
"max_slowdown": max_slow,
|
|
"max_speedup": max_fast,
|
|
"high_noise": len(high_noise),
|
|
"flip": bool(slow and fast),
|
|
"latest": latest,
|
|
"max_abs": max(row_abs_diff(r) for r in vals),
|
|
})
|
|
return sorted(out, key=lambda x: (x["flip"], x["runs"], x["max_abs"]), reverse=True)
|
|
|
|
|
|
def print_aggregate_rows(title: str, aggs: list[dict[str, Any]], limit: int | None) -> None:
|
|
print(f"\n## {title}\n")
|
|
if not aggs:
|
|
print("No rows.\n")
|
|
return
|
|
if limit:
|
|
aggs = aggs[:limit]
|
|
print("| Arch | Test | Query | Runs | Slowdowns | Speedups | High-noise rows | Direction flip | Max slowdown | Max speedup | Latest test page |")
|
|
print("|---|---|---:|---:|---:|---:|---:|---|---:|---:|---|")
|
|
for a in aggs:
|
|
latest = a["latest"]
|
|
print(
|
|
f"| {a['arch'] or '—'} | `{a['test']}` | {a['queryIndex']} | {a['runs']} | {a['slowdowns']} | {a['speedups']} | {a['high_noise']} | "
|
|
f"{'yes' if a['flip'] else 'no'} | {change_pct(a['max_slowdown']) if a['slowdowns'] else '—'} | "
|
|
f"{change_pct(a['max_speedup']) if a['speedups'] else '—'} | {dashboard_test_query_link_for_row(latest.get('runId'), latest)} |"
|
|
)
|
|
print()
|
|
|
|
|
|
def print_tsv_rows(title: str, rows: list[dict[str, Any]], limit: int | None) -> None:
|
|
print(f"\n## {title}\n")
|
|
rows = sorted(rows, key=row_abs_diff, reverse=True)
|
|
if limit:
|
|
rows = rows[:limit]
|
|
if not rows:
|
|
print("No rows.\n")
|
|
return
|
|
print("| Bucket | Direction | Arch | Shard | Test | Query | Old | New | Diff | Times | Threshold | Changed | Unstable | Query text |")
|
|
print("|---|---|---|---:|---|---:|---:|---:|---:|---:|---:|---|---|---|")
|
|
for r in rows:
|
|
print(
|
|
f"| {r.get('bucket','—')} | {r.get('direction','—')} | {r.get('arch','—')} | {r.get('shard','—')} | `{r.get('test','—')}` | {r.get('queryIndex','—')} | "
|
|
f"{num(r.get('oldValue'))} | {num(r.get('newValue'))} | {change_pct(r.get('diffPercent'))} | {num(r.get('timesChange'))} | "
|
|
f"{change_pct(r.get('statThreshold'))} | {'yes' if r.get('isChanged') else 'no'} | {'yes' if r.get('isUnstable') else 'no'} | {shorten(r.get('queryDisplayName'), 110)} |"
|
|
)
|
|
print()
|
|
|
|
|
|
def cmd_tsv_inventory(args: argparse.Namespace) -> None:
|
|
rows = parse_perf_tsv(args.tsv, args.metric, args.arch)
|
|
if not args.show_all:
|
|
rows = [r for r in rows if r.get("isChanged") or r.get("isUnstable")]
|
|
slow = [r for r in rows if r.get("isChanged") and r.get("direction") == "slowdown"]
|
|
fast = [r for r in rows if r.get("isChanged") and r.get("direction") == "speedup"]
|
|
unstable = [r for r in rows if r.get("isUnstable") and not r.get("isChanged")]
|
|
print("# TSV/raw-artifact performance inventory\n")
|
|
print("Input TSVs:")
|
|
for path in args.tsv:
|
|
print(f"- `{path}`")
|
|
print("\n## Counts\n")
|
|
print(f"- Rows considered: **{len(rows)}**")
|
|
print(f"- Changed slowdowns: **{len(slow)}**")
|
|
print(f"- Changed improvements/speedups: **{len(fast)}**")
|
|
print(f"- Unstable/non-changed rows: **{len(unstable)}**")
|
|
print_tsv_rows("Changed slowdowns from TSV/raw artifacts", slow, args.limit)
|
|
print_tsv_rows("Changed improvements / speedups from TSV/raw artifacts", fast, args.limit)
|
|
print_tsv_rows("Unstable/non-changed rows from TSV/raw artifacts", unstable, args.limit)
|
|
|
|
|
|
|
|
def cmd_pr_inventory(args: argparse.Namespace) -> None:
|
|
items = fetch_pr_runs(args.base, args.pr, args.metrics)
|
|
if not items:
|
|
print(f"No runs found for PR {args.pr}.")
|
|
return
|
|
print(f"# PR {args.pr} performance inventory\n")
|
|
print(f"API: {api_url(args.base, '/runs', {'q': args.pr, 'metrics': args.metrics})}\n")
|
|
print("| Time | Run | Old | New | Arches | Changed | Slowdowns | Speedups |")
|
|
print("|---|---|---|---|---|---:|---:|---:|")
|
|
for item in items:
|
|
ident = item.get("identity") or {}
|
|
run_id = ident.get("runId", "")
|
|
print(
|
|
f"| {ident.get('runTime','—')} | [open]({dashboard_run_url(run_id)}) | `{str(ident.get('oldSha',''))[:12]}` | `{str(ident.get('newSha',''))[:12]}` | "
|
|
f"{','.join(item.get('arches') or [])} | {item.get('changedQueries','—')} | {item.get('slowdownQueries','—')} | {item.get('speedupQueries','—')} |"
|
|
)
|
|
newest_sha = (items[0].get("identity") or {}).get("newSha")
|
|
current_items = [it for it in items if (it.get("identity") or {}).get("newSha") == newest_sha]
|
|
all_rows = collect_run_rows(args.base, items, args.metrics, args.metric, args.arch)
|
|
confidence_errors: list[str] = []
|
|
if getattr(args, "with_confidence", False):
|
|
confidence_errors = attach_confidence_to_rows(args.base, args.metrics, all_rows)
|
|
current_rows = [r for r in all_rows if r.get("runId") in {(it.get("identity") or {}).get("runId") for it in current_items}]
|
|
current_slow = [r for r in current_rows if r.get("bucket") == "slowdown"]
|
|
current_fast = [r for r in current_rows if r.get("bucket") == "speedup"]
|
|
current_high_noise = [r for r in current_rows if r.get("bucket") == "high-noise/unstable-threshold"]
|
|
all_slow = [r for r in all_rows if r.get("bucket") == "slowdown"]
|
|
all_fast = [r for r in all_rows if r.get("bucket") == "speedup"]
|
|
all_high_noise = [r for r in all_rows if r.get("bucket") == "high-noise/unstable-threshold"]
|
|
print("\n## Counts\n")
|
|
print(f"- Runs found: **{len(items)}**")
|
|
print(f"- Current candidate SHA: `{str(newest_sha or '')[:12]}`; current run(s): **{len(current_items)}**")
|
|
print(f"- Current candidate: changed slowdowns **{len(current_slow)}**, changed improvements/speedups **{len(current_fast)}**, high-noise/unstable-threshold rows **{len(current_high_noise)}**")
|
|
print(f"- All PR runs: changed slowdowns **{len(all_slow)}**, changed improvements/speedups **{len(all_fast)}**, high-noise/unstable-threshold rows **{len(all_high_noise)}**")
|
|
if getattr(args, "with_confidence", False):
|
|
attached = sum(1 for r in all_rows if r.get("confidence"))
|
|
print(f"- Confidence attached: **{attached}/{len(all_rows)}** rows")
|
|
for err in confidence_errors[:5]:
|
|
print(f" - confidence unavailable for {err}")
|
|
print_inventory_summary(current_slow, current_fast, current_high_noise)
|
|
print_inventory_rows("Current changed slowdowns", current_slow, args.limit, "Raw changed rows with slowdown direction. Evidence can still downgrade a raw change to noise/flaky.", group_evidence=True)
|
|
print_inventory_rows("Current changed improvements / speedups", current_fast, args.limit, "Raw changed rows with speedup direction. Evidence can still downgrade a raw change to noise/flaky.", group_evidence=True)
|
|
print_inventory_rows("Current high-noise / unstable-threshold rows", current_high_noise, args.limit, "Separate from changed slowdowns/speedups. Inspect only if magnitude/context makes them relevant.")
|
|
if not args.current_only and len(items) > 1:
|
|
aggs = aggregate_rows(all_rows)
|
|
flips = [a for a in aggs if a["flip"]]
|
|
repeated = [a for a in aggs if a["runs"] > 1]
|
|
if repeated:
|
|
print_aggregate_rows("Rows repeated across PR runs", repeated, args.aggregate_limit)
|
|
if flips:
|
|
print_aggregate_rows("Direction flips across PR runs", flips, args.aggregate_limit)
|
|
if args.show_all_run_tables:
|
|
print_inventory_rows("All PR-run changed slowdowns", all_slow, args.limit)
|
|
print_inventory_rows("All PR-run changed improvements / speedups", all_fast, args.limit)
|
|
print_inventory_rows("All PR-run high-noise / unstable-threshold rows", all_high_noise, args.limit)
|
|
|
|
|
|
def master_test_name(row: dict[str, Any]) -> str:
|
|
return f"{row.get('test')} #{row.get('queryIndex')}"
|
|
|
|
|
|
def fetch_master_check_counts(rows: list[dict[str, Any]], days: int, play_url: str, play_user: str) -> dict[tuple[str, str], dict[str, Any]]:
|
|
by_arch: dict[str, set[str]] = defaultdict(set)
|
|
for r in rows:
|
|
arch = r.get("arch") or ""
|
|
test = r.get("test")
|
|
query_index = r.get("queryIndex")
|
|
if arch in {"amd", "arm"} and test is not None and query_index is not None:
|
|
by_arch[arch].add(master_test_name(r))
|
|
out: dict[tuple[str, str], dict[str, Any]] = {}
|
|
for arch, tests in sorted(by_arch.items()):
|
|
if not tests:
|
|
continue
|
|
names = ",\n ".join(sql_quote(t + "::new") for t in sorted(tests))
|
|
sql = f"""
|
|
SELECT
|
|
replaceRegexpOne(test_name, '::(new|old)$', '') AS test,
|
|
countIf(test_status = 'slower') AS slower_count,
|
|
countIf(test_status = 'faster') AS faster_count,
|
|
countIf(test_status = 'unstable') AS unstable_count,
|
|
count() AS total_runs
|
|
FROM default.checks
|
|
WHERE pull_request_number = 0
|
|
AND check_name LIKE '%Performance%{arch}%'
|
|
AND check_start_time >= now() - INTERVAL {int(days)} DAY
|
|
AND test_name IN (
|
|
{names}
|
|
)
|
|
GROUP BY test
|
|
FORMAT JSONEachRow
|
|
"""
|
|
for row in clickhouse_play_json_each_row(sql, play_url=play_url, user=play_user):
|
|
out[(arch, row.get("test") or "")] = row
|
|
return out
|
|
|
|
|
|
def master_counts_text(hist: dict[str, Any] | None) -> str:
|
|
if not hist:
|
|
return "0/0/0/0"
|
|
return f"{hist.get('slower_count',0)}/{hist.get('faster_count',0)}/{hist.get('unstable_count',0)}/{hist.get('total_runs',0)}"
|
|
|
|
|
|
def classify_with_master_counts(row: dict[str, Any], hist: dict[str, Any] | None) -> str:
|
|
if not hist:
|
|
return "no master data"
|
|
slower = int(hist.get("slower_count") or 0)
|
|
faster = int(hist.get("faster_count") or 0)
|
|
unstable = int(hist.get("unstable_count") or 0)
|
|
total = int(hist.get("total_runs") or 0)
|
|
if total <= 0:
|
|
return "no master data"
|
|
slower_rate = slower / total
|
|
faster_rate = faster / total
|
|
unstable_rate = unstable / total
|
|
direction = row.get("direction")
|
|
if unstable >= 5 or unstable_rate > 0.01:
|
|
return "unstable on master"
|
|
if direction == "slowdown":
|
|
if slower_rate > 0.01 or slower >= 3:
|
|
return "flaky/slower on master"
|
|
if 1 <= slower <= 2:
|
|
return "rarely slower on master"
|
|
if total >= 50:
|
|
return "new in PR — investigate"
|
|
return "insufficient master history"
|
|
if direction == "speedup":
|
|
if slower_rate > 0.01 or slower >= 3:
|
|
return "fixes known master regression"
|
|
if faster_rate > 0.01 or faster >= 3:
|
|
return "flaky/faster on master"
|
|
if 1 <= faster <= 2:
|
|
return "rarely faster on master"
|
|
if total >= 50:
|
|
return "new in PR — improvement"
|
|
return "insufficient master history"
|
|
return "unknown direction"
|
|
|
|
|
|
def cmd_master_checks(args: argparse.Namespace) -> None:
|
|
if not args.tsv and not args.pr:
|
|
raise SystemExit("master-checks requires either --pr or --tsv")
|
|
rows: list[dict[str, Any]] = []
|
|
source_note = ""
|
|
if args.tsv:
|
|
rows = [r for r in parse_perf_tsv(args.tsv, args.metric, args.arch) if r.get("isChanged")]
|
|
source_note = "TSV/raw artifact rows"
|
|
else:
|
|
items = fetch_pr_runs(args.base, args.pr, args.metrics)
|
|
if not args.all_runs:
|
|
newest_sha = (items[0].get("identity") or {}).get("newSha") if items else None
|
|
items = [it for it in items if (it.get("identity") or {}).get("newSha") == newest_sha]
|
|
rows = collect_run_rows(args.base, items, args.metrics, args.metric, args.arch)
|
|
rows = [r for r in rows if r.get("bucket") in {"slowdown", "speedup"}]
|
|
source_note = "performance.ci API changed rows"
|
|
if not rows:
|
|
print("No changed rows found for master-check classification.")
|
|
return
|
|
# Attach dashboard confidence by default when rows came from performance.ci.
|
|
# This lets the report separate likely signal from noise/uncertain rows.
|
|
confidence_errors: list[str] = []
|
|
if not args.tsv:
|
|
confidence_errors = attach_confidence_to_rows(args.base, args.metrics, rows)
|
|
counts = fetch_master_check_counts(rows, args.days, args.play_url, args.play_user)
|
|
rows = rows_for_display(rows, args.limit)
|
|
grouped: dict[tuple[str, str, str, str], list[tuple[dict[str, Any], dict[str, Any] | None]]] = defaultdict(list)
|
|
for r in rows:
|
|
key = (r.get("arch") or "", master_test_name(r))
|
|
hist = counts.get(key)
|
|
verdict = classify_with_master_counts(r, hist)
|
|
signal_group = "noise / uncertain evidence" if is_noise_or_uncertain(r) else "not-noise evidence"
|
|
grouped[(signal_group, r.get("arch") or "—", r.get("direction") or "—", verdict)].append((r, hist))
|
|
|
|
verdict_order = {
|
|
"new in PR — investigate": 0,
|
|
"new in PR — improvement": 0,
|
|
"fixes known master regression": 1,
|
|
"rarely slower on master": 2,
|
|
"rarely faster on master": 2,
|
|
"flaky/slower on master": 3,
|
|
"flaky/faster on master": 3,
|
|
"unstable on master": 4,
|
|
"insufficient master history": 5,
|
|
"no master data": 6,
|
|
}
|
|
signal_order = {"not-noise evidence": 0, "noise / uncertain evidence": 1}
|
|
direction_order = {"slowdown": 0, "speedup": 1}
|
|
|
|
print(f"# Master-check classification ({args.days}d)\n")
|
|
print(f"Source: {source_note}\n")
|
|
print("Counts column is `master slower/faster/unstable/total` from `play.clickhouse.com default.checks` over master-only runs.\n")
|
|
if confidence_errors:
|
|
print(f"Confidence unavailable for {len(confidence_errors)} test/run groups; those rows may appear under noise / uncertain evidence.\n")
|
|
|
|
current_signal_group = None
|
|
for (signal_group, arch, direction, verdict), group in sorted(grouped.items(), key=lambda kv: (signal_order.get(kv[0][0], 9), kv[0][1], direction_order.get(kv[0][2], 9), verdict_order.get(kv[0][3], 9), -len(kv[1]))):
|
|
if signal_group != current_signal_group:
|
|
print(f"\n## {signal_group} ({sum(len(v) for k, v in grouped.items() if k[0] == signal_group)})\n")
|
|
current_signal_group = signal_group
|
|
print(f"\n### {arch} {direction} — {verdict} ({len(group)})\n")
|
|
print("| Test | Query | Diff | Test page | Master s/f/u/total | Evidence |")
|
|
print("|---|---:|---:|---|---:|---|")
|
|
for r, hist in sorted(group, key=lambda item: row_abs_diff(item[0]), reverse=True):
|
|
print(
|
|
f"| `{r.get('test','—')}` | {r.get('queryIndex','—')} | {change_pct(r.get('diffPercent'))} | "
|
|
f"{dashboard_test_query_link_for_row(r.get('runId'), r)} | {master_counts_text(hist)} | {row_evidence(r)} |"
|
|
)
|
|
|
|
|
|
def cmd_pr_query_history(args: argparse.Namespace) -> None:
|
|
runs = fetch_json(args.base, "/runs", {"q": args.pr, "metrics": args.metrics}).get("items") or []
|
|
try:
|
|
pr_int = int(args.pr)
|
|
runs = [x for x in runs if (x.get("identity") or {}).get("prNumber") == pr_int]
|
|
except Exception:
|
|
pass
|
|
print(f"# PR {args.pr}: `{args.test}/{args.query_index}` `{args.metric}/{args.arch}` across dashboard runs\n")
|
|
print("| Time | Dashboard | Old | New | Diff | Threshold | Over threshold | Direction | Severity |")
|
|
print("|---|---|---:|---:|---:|---:|---|---|---|")
|
|
seen = 0
|
|
chart_points: list[tuple[str, str, dict[str, Any] | None]] = []
|
|
for item in runs:
|
|
ident = item.get("identity") or {}
|
|
run_id = ident.get("runId")
|
|
if not run_id:
|
|
continue
|
|
try:
|
|
detail = fetch_json(args.base, f"/runs/{q(run_id)}/tests/{q(args.test)}/queries/{args.query_index}", {"metrics": args.metrics})
|
|
except Exception:
|
|
dash = dashboard_query_url(run_id, args.test, args.query_index, args.metric)
|
|
print(f"| {ident.get('runTime','—')} | [open]({dash}) | — | — | — | — | — | not available | — |")
|
|
chart_points.append((ident.get('runTime','—'), run_id, None))
|
|
continue
|
|
rows = detail.get("metrics") or []
|
|
match = [r for r in rows if r.get("metric") == args.metric and (not args.arch or r.get("arch") == args.arch)]
|
|
dash = dashboard_query_url(run_id, args.test, args.query_index, args.metric)
|
|
if not match:
|
|
print(f"| {ident.get('runTime','—')} | [open]({dash}) | — | — | — | — | — | not listed | — |")
|
|
chart_points.append((ident.get('runTime','—'), run_id, None))
|
|
continue
|
|
for r in match:
|
|
seen += 1
|
|
chart_points.append((ident.get('runTime','—'), run_id, r))
|
|
print(
|
|
f"| {ident.get('runTime','—')} | [open]({dash}) | {num(r.get('oldValue'))} | {num(r.get('newValue'))} | "
|
|
f"{change_pct(r.get('diffPercent'))} | {change_pct(r.get('statThreshold'))} | {over_threshold(r)} | {r.get('direction','—')} | {r.get('severity','—')} |"
|
|
)
|
|
print(f"\nMatched rows: {seen}/{len(runs)} runs.\n")
|
|
chronological = list(reversed(chart_points))
|
|
print("```text")
|
|
print(ascii_diff_chart(chronological))
|
|
print("```\n")
|
|
|
|
|
|
|
|
def main() -> None:
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument("--base", default=BASE_DEFAULT)
|
|
p.add_argument("--metrics", default=DEFAULT_METRICS)
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
sp = sub.add_parser("runs", help="List performance.ci runs for a PR/search query")
|
|
sp.add_argument("--pr")
|
|
sp.add_argument("--q")
|
|
sp.set_defaults(func=cmd_runs)
|
|
|
|
sp = sub.add_parser("run", help="Summarize one run")
|
|
sp.add_argument("--run-id", required=True)
|
|
sp.add_argument("--limit", type=int)
|
|
sp.add_argument("--with-confidence", action="store_true")
|
|
sp.set_defaults(func=cmd_run)
|
|
|
|
sp = sub.add_parser("changes", help="Alias for run summary")
|
|
sp.add_argument("--run-id", required=True)
|
|
sp.add_argument("--limit", type=int)
|
|
sp.add_argument("--with-confidence", action="store_true")
|
|
sp.set_defaults(func=cmd_changes)
|
|
|
|
sp = sub.add_parser("query", help="Inspect one run/test/query")
|
|
sp.add_argument("--run-id", required=True)
|
|
sp.add_argument("--test", required=True)
|
|
sp.add_argument("--query-index", required=True, type=int)
|
|
sp.add_argument("--metric", default="client_time")
|
|
sp.add_argument("--arch", default="")
|
|
sp.add_argument("--with-confidence", action="store_true")
|
|
sp.add_argument("--with-trend", action="store_true")
|
|
sp.add_argument("--with-history", action="store_true")
|
|
sp.add_argument("--with-coverage", action="store_true")
|
|
sp.add_argument("--with-flamegraph-diff", action="store_true")
|
|
sp.add_argument("--trace-type", default="CPU", choices=["CPU", "REAL_TIME", "MEMORY"])
|
|
sp.set_defaults(func=cmd_query)
|
|
|
|
sp = sub.add_parser("pr-inventory", help="Inventory slowdowns and improvements for all performance.ci runs of a PR")
|
|
sp.add_argument("--pr", required=True)
|
|
sp.add_argument("--metric", default="client_time")
|
|
sp.add_argument("--arch", default="")
|
|
sp.add_argument("--limit", type=int, default=20)
|
|
sp.add_argument("--aggregate-limit", type=int, default=20)
|
|
sp.add_argument("--current-only", action="store_true")
|
|
sp.add_argument("--show-all-run-tables", action="store_true", help="Also print full all-run top slowdown/speedup/high-noise tables")
|
|
sp.add_argument("--with-confidence", action="store_true", help="Fetch per-test confidence for inventory rows")
|
|
sp.set_defaults(func=cmd_pr_inventory)
|
|
|
|
sp = sub.add_parser("tsv-inventory", help="Inventory rows from fetch_perf_report.py --tsv or raw all-query-metrics TSV files")
|
|
sp.add_argument("--tsv", nargs="+", required=True)
|
|
sp.add_argument("--metric", default="client_time")
|
|
sp.add_argument("--arch", default="")
|
|
sp.add_argument("--limit", type=int, default=20)
|
|
sp.add_argument("--show-all", action="store_true")
|
|
sp.set_defaults(func=cmd_tsv_inventory)
|
|
|
|
sp = sub.add_parser("master-checks", help="Classify changed rows using 30-day master status counts from play.clickhouse.com default.checks")
|
|
sp.add_argument("--pr", help="PR number; uses performance.ci API changed rows")
|
|
sp.add_argument("--tsv", nargs="+", help="Optional fetch_perf_report.py --tsv / raw artifact TSV input instead of API rows")
|
|
sp.add_argument("--metric", default="client_time")
|
|
sp.add_argument("--arch", default="")
|
|
sp.add_argument("--limit", type=int)
|
|
sp.add_argument("--days", type=int, default=30)
|
|
sp.add_argument("--all-runs", action="store_true", help="Use all PR dashboard runs instead of current candidate SHA only")
|
|
sp.add_argument("--play-url", default=PLAY_DEFAULT)
|
|
sp.add_argument("--play-user", default="explorer")
|
|
sp.set_defaults(func=cmd_master_checks)
|
|
|
|
sp = sub.add_parser("pr-query-history", help="Compare one query across all PR dashboard runs")
|
|
sp.add_argument("--pr", required=True)
|
|
sp.add_argument("--test", required=True)
|
|
sp.add_argument("--query-index", required=True, type=int)
|
|
sp.add_argument("--metric", default="client_time")
|
|
sp.add_argument("--arch", default="")
|
|
sp.set_defaults(func=cmd_pr_query_history)
|
|
|
|
args = p.parse_args()
|
|
if getattr(args, "pr", None) is None and getattr(args, "q", None) is None and args.cmd == "runs":
|
|
p.error("runs requires --pr or --q")
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|