Skip to content

Commit 6177c2f

Browse files
authored
Adds a script to analyze autorevert decisions and correlate with TD data and behaviour (#8333)
## What Adds a new standalone CLI, `flake-test-fail-autorevert`, that reads pytorch-auto-revert's already-published decisions from ClickHouse and exports them as a long-format CSV (one row per test signal, categorized `regression` or `flaky`), plus a companion `flake-report` command that renders a self-contained HTML report (flakiness/regression rankings, per-day time-series, and a pre-merge trunk-gate funnel) from that CSV. ## Why We need visibility into what the autorevert system is actually catching and how those regressions relate to the pre-merge gate. The exporter answers "which test signals triggered reverts or were flagged flaky, over a commit-landing range," and the report turns that into a shareable view — notably the `premerge_status` classification, which shows how many trunk/pull regressions were landraces (green pre-merge, red on main) versus caught-but-merged, force-merged, or coverage gaps (TD-deselected / not-in-matrix). The tool is read-only against ClickHouse; it re-reads stored decisions and never re-runs analysis. --------- Signed-off-by: Jean Schmidt <contato@jschmidt.me>
1 parent a503cbb commit 6177c2f

28 files changed

Lines changed: 5481 additions & 0 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
CLICKHOUSE_HOST=
2+
CLICKHOUSE_PORT=8443
3+
CLICKHOUSE_USERNAME=
4+
CLICKHOUSE_PASSWORD=
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
**/report/assets/*.js
2+
*.report.html
3+
.venv/
4+
__pycache__/
5+
*.egg-info/
6+
.pytest_cache/
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# flake-test-fail-autorevert
2+
3+
Standalone CLI that reads pytorch-auto-revert's **already-published** decisions
4+
from ClickHouse and writes a CSV. It does not re-run any analysis: it only reads
5+
stored decisions.
6+
7+
The CSV is **long**: one row per signal. A commit (landing on `main` within the
8+
requested range) contributes one row for each test signal that either:
9+
10+
- triggered an autorevert on a **test** regression (`category = regression`), or
11+
- was flagged **flaky** by autorevert (`category = flaky`).
12+
13+
Only test-level signals are reported (signal keys containing `::`); job-level
14+
signals are excluded.
15+
16+
## Output columns
17+
18+
- `commit_sha` — full 40-char sha
19+
- `commit_url``https://github.com/{repo}/commit/{commit_sha}`
20+
- `commit_time` — landing time on `main` (`YYYY-MM-DD HH:MM:SS`, UTC)
21+
- `category``regression` (this signal triggered an autorevert) or `flaky`
22+
(autorevert observed both a pass and a fail on this commit for this signal)
23+
- `workflow` — the CI workflow the signal belongs to (e.g. `trunk`, `pull`,
24+
`inductor`, `periodic`, `slow`), or `unknown`. See attribution below.
25+
- `signal_key` — the test signal key, `file.py::test_name`
26+
- `advisor_verdict` — for `regression` rows, the auto-revert advisor's verdict
27+
(`related`, `not_related`, `infra_issue`, `garbage`, `revert`, `unsure`) when
28+
one exists, else empty. Always empty for `flaky` rows.
29+
- `advisor_confidence` — for `regression` rows with a verdict, the advisor's
30+
confidence formatted to two decimals (e.g. `0.99`), else empty. Always empty
31+
for `flaky` rows.
32+
- `premerge_status` — for `regression` rows on the `trunk` or `pull` workflow, the
33+
pre-merge trunk-gate status of the test on the merged commit's validated head;
34+
empty for all other rows. One of:
35+
- `RUN_SUCCEEDED` — the test ran on the pre-merge head and passed. Only ever
36+
emitted from a POSITIVE success-row observation, never from an empty read.
37+
- `RUN_FAILED` — the test ran and at least one shard failed ("merged despite
38+
red"). Checked before success, so a mixed pass/fail set reports as failed.
39+
- `NOT_RUN:force_merge` — a REAL force merge (`skip_mandatory_checks` set on the
40+
merge, i.e. `-f`) that bypassed the gate AND the test did not run at all. A
41+
force merge that still ran the test reports the test's real verdict instead —
42+
force_merge never masks a real outcome.
43+
- `NOT_RUN:skipped` — the test ran but every run was skipped.
44+
- `NOT_RUN:td_deselected` — the test's file ran but the test was deselected
45+
(test dependency / target determination).
46+
- `NOT_RUN:not_in_matrix` — the test's file never ran on the head (job not in
47+
the matrix, or no gate jobs at all on a non-force merge).
48+
- `NOT_RUN:no_merge_record` — no `default.merges` row resolved a pre-merge head
49+
for this commit, so we cannot classify it. This is the honest label for a
50+
ghstack **non-tip** commit (only the stack's tip PR gets a merges row keyed by
51+
its squashed commit), a revert, a direct push, or data predating the merges
52+
table. It is NOT an inference of force merge. See the coverage note below.
53+
- `ERROR` — a query failed after retries, or the merge timestamp was missing.
54+
55+
Rows are sorted by `(commit_time, category, workflow, signal_key)` ascending.
56+
57+
The same `(commit, signal_key)` can appear as both a `regression` row and a
58+
`flaky` row: the two categories answer independent questions (did it trigger an
59+
autorevert vs. did autorevert observe both a pass and a fail on that commit).
60+
61+
### How `workflow` is attributed
62+
63+
- **Flaky rows**: the workflow is exact, taken directly from the autorevert
64+
state snapshot the flaky signal was read from. A single `(commit, signal_key)`
65+
legitimately observed flaky under two workflows produces two rows.
66+
- **Regression rows**: the reverted event stores the triggering workflows and
67+
the source signals as two independent arrays (a deduped set and an ordered
68+
list) that cannot be positionally zipped, so the workflow is resolved per
69+
signal with a fallback: the auto-revert advisor's workflow for that
70+
`(commit, signal_key)` if present, otherwise the revert event's sole workflow
71+
when it triggered on exactly one workflow, otherwise `unknown`.
72+
73+
## Environment
74+
75+
Reads the same connection variables as the `pytorch-auto-revert` lambda:
76+
77+
- `CLICKHOUSE_HOST` — host or URL (`https://` prefix and `:8443` suffix are
78+
stripped automatically)
79+
- `CLICKHOUSE_PORT` — optional, defaults to `8443`
80+
- `CLICKHOUSE_USERNAME`
81+
- `CLICKHOUSE_PASSWORD`
82+
83+
Set these in the environment or in a `.env` file in the tool dir (see
84+
`.env.example`), which is loaded on startup.
85+
86+
## Run
87+
88+
```
89+
cd tools/flake-test-fail-autorevert
90+
uv run flake-test-fail-autorevert --start 2026-07-01 --end 2026-07-14 \
91+
[--repo pytorch/pytorch] [--output out.csv]
92+
```
93+
94+
`uv` auto-creates and manages a local `.venv` and installs the dependencies
95+
declared in `pyproject.toml`, so there is no manual venv or `pip install` step.
96+
97+
Run the tests with:
98+
99+
```
100+
uv run pytest
101+
```
102+
103+
### Alternative (pip)
104+
105+
```
106+
pip install -e .
107+
flake-test-fail-autorevert --start 2026-07-01 --end 2026-07-14
108+
# or equivalently:
109+
python -m flake_test_fail_autorevert --start 2026-07-01 --end 2026-07-14
110+
```
111+
112+
- `--start` / `--end` are dates (`YYYY-MM-DD`). The range is by commit landing
113+
time on `main`, and `--end` is **inclusive** (the whole end day is included):
114+
the effective window is `[start 00:00:00, (end + 1 day) 00:00:00)`.
115+
- `--repo` defaults to `pytorch/pytorch`.
116+
- `--output` defaults to
117+
`flake_test_fail_autorevert_<start>_<end>.csv`. The path and a one-line summary
118+
(`N rows across M commits: R regression, F flaky`) are printed on completion.
119+
120+
## Notes on `premerge_status` coverage
121+
122+
The pre-merge head is resolved from `default.merges`, which is keyed by the
123+
**merge command's** commit — for a ghstack stack that is only the **tip** PR's
124+
squashed commit. A ghstack **non-tip** commit lands its own squashed commit on
125+
`main` but has no `default.merges` row keyed by that commit, so its pre-merge head
126+
cannot be resolved and it is reported as `NOT_RUN:no_merge_record`. Autorevert
127+
frequently bisects a regression to a non-tip culprit, so this is a real coverage
128+
gap, not an edge case. There is currently no clean, reliable way to recover the
129+
non-tip pre-merge head from ClickHouse, so `no_merge_record` is the honest label
130+
rather than guessing. Reverts and direct pushes (no merges row) also land here.
131+
132+
A real `-f` force merge, by contrast, DOES write a `default.merges` row (with
133+
`skip_mandatory_checks` set), so it resolves a head and its test status is queried
134+
normally; `NOT_RUN:force_merge` is reported only when the gate was bypassed AND the
135+
test genuinely did not run.
136+
137+
## Notes on the flaky scan
138+
139+
Flaky signals are read from the `misc.autorevert_state` JSON snapshots. Several
140+
independent autorevert configurations run concurrently, each publishing its own
141+
snapshot stream distinguished by its `workflows` set. The flaky query scans **all**
142+
autorevert state snapshots in the range (exhaustive — every snapshot, deduped in
143+
Python), so the `flaky` rows reflect every (workflow, commit, test) triple autorevert
144+
flagged flaky (both a passing and a failing run on that commit) while it was in the
145+
state window. Scanning every snapshot rather than only the day's latest is required
146+
because commits age out of autorevert's sliding state window mid-day, so a
147+
latest-only sample would miss flaky states that appeared earlier in the day.
148+
149+
The query is run once per 6-hour chunk of the padded window (~4 chunks/day) and
150+
results are accumulated and deduped in Python. Cost note: each chunked query runs
151+
with capped parallelism and peaks at roughly 4 GiB server-side on the busiest
152+
observed days, taking a few seconds per chunk. Results are exhaustive (every
153+
snapshot in range, deduped), and very large ranges scale linearly in the number of
154+
chunks.
155+
156+
DNS to the ClickHouse cloud host flaps intermittently and the shared cluster can
157+
return transient server errors (e.g. `MEMORY_LIMIT_EXCEEDED`), so each query is
158+
retried with exponential backoff on connection, name-resolution, and transient
159+
database errors. Genuine query bugs fail fast on the first attempt: the driver
160+
raises a bare `DatabaseError` for server errors with the ClickHouse numeric code in
161+
the message, so a deterministic code (syntax error, unknown table/column/function,
162+
type mismatch, bad arguments, access denied, etc.) is detected and not retried;
163+
unknown or transient codes default to being retried.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Read-only CLI that exports pytorch-auto-revert's published test decisions to CSV."""
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import argparse
2+
import csv
3+
import logging
4+
import sys
5+
from datetime import date, datetime, timedelta
6+
from typing import Dict, List, Optional, Set, Tuple
7+
8+
from dotenv import load_dotenv # type: ignore[import-not-found]
9+
10+
from .client import get_clickhouse_client
11+
from .logic import build_rows, COLUMNS, iter_time_chunks
12+
from .premerge import (
13+
classify_with_context,
14+
parse_pr_from_message,
15+
PremergeContext,
16+
resolve_premerge_context,
17+
)
18+
from .queries import (
19+
fetch_advisor_verdicts,
20+
fetch_commit_messages,
21+
fetch_commit_times,
22+
fetch_flaky_for_day,
23+
fetch_regressions,
24+
)
25+
26+
27+
EVENT_PAD_DAYS = 2
28+
FLAKY_PAD_DAYS = 1
29+
FLAKY_CHUNK_HOURS = 6
30+
31+
32+
def parse_date(value: str) -> date:
33+
try:
34+
return datetime.strptime(value, "%Y-%m-%d").date()
35+
except ValueError as exc:
36+
raise argparse.ArgumentTypeError(
37+
f"invalid date '{value}', expected YYYY-MM-DD"
38+
) from exc
39+
40+
41+
def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
42+
parser = argparse.ArgumentParser(
43+
prog="flake_test_fail_autorevert",
44+
description=(
45+
"Export pytorch-auto-revert's published test decisions (regression "
46+
"reverts and flaky-flagged test signals) to CSV for a commit landing "
47+
"range on main."
48+
),
49+
)
50+
parser.add_argument("--start", type=parse_date, required=True, help="YYYY-MM-DD")
51+
parser.add_argument(
52+
"--end", type=parse_date, required=True, help="YYYY-MM-DD (inclusive)"
53+
)
54+
parser.add_argument("--repo", default="pytorch/pytorch")
55+
parser.add_argument("--output", default=None)
56+
args = parser.parse_args(argv)
57+
if args.start > args.end:
58+
parser.error(f"--start ({args.start}) must be <= --end ({args.end})")
59+
return args
60+
61+
62+
def default_output(start: date, end: date) -> str:
63+
return f"flake_test_fail_autorevert_{start.isoformat()}_{end.isoformat()}.csv"
64+
65+
66+
def write_csv(path: str, rows: List[dict]) -> None:
67+
with open(path, "w", newline="") as f:
68+
writer = csv.DictWriter(f, fieldnames=COLUMNS, quoting=csv.QUOTE_MINIMAL)
69+
writer.writeheader()
70+
writer.writerows(rows)
71+
72+
73+
def collect(args: argparse.Namespace) -> List[dict]:
74+
client = get_clickhouse_client()
75+
76+
ev_start = datetime.combine(
77+
args.start - timedelta(days=EVENT_PAD_DAYS), datetime.min.time()
78+
)
79+
ev_end = datetime.combine(
80+
args.end + timedelta(days=EVENT_PAD_DAYS + 1), datetime.min.time()
81+
)
82+
regressions = fetch_regressions(client, args.repo, ev_start, ev_end)
83+
84+
flaky: Dict[str, Set[Tuple[str, str]]] = {}
85+
flaky_start = args.start - timedelta(days=FLAKY_PAD_DAYS)
86+
flaky_end = args.end + timedelta(days=FLAKY_PAD_DAYS)
87+
for chunk_start, chunk_end in iter_time_chunks(
88+
flaky_start, flaky_end, FLAKY_CHUNK_HOURS
89+
):
90+
for workflow, signal_key, commit_sha in fetch_flaky_for_day(
91+
client, args.repo, chunk_start, chunk_end
92+
):
93+
flaky.setdefault(commit_sha, set()).add((workflow, signal_key))
94+
n_pairs = sum(len(v) for v in flaky.values())
95+
logging.info(
96+
"flaky scan %s: %d distinct (workflow, signal) pairs so far",
97+
chunk_start.isoformat(),
98+
n_pairs,
99+
)
100+
101+
candidate_shas = sorted(set(regressions.by_commit) | set(flaky))
102+
commit_times = fetch_commit_times(client, candidate_shas)
103+
104+
regression_shas = sorted(regressions.by_commit)
105+
verdicts = fetch_advisor_verdicts(client, args.repo, regression_shas)
106+
107+
rows = build_rows(
108+
regressions.by_commit,
109+
regressions.single_workflow,
110+
flaky,
111+
commit_times,
112+
verdicts,
113+
args.start,
114+
args.end,
115+
args.repo,
116+
)
117+
118+
# Only trunk+pull regressions get a premerge lookup; everything else stays "".
119+
qualifying = [
120+
r
121+
for r in rows
122+
if r["category"] == "regression" and r["workflow"] in ("trunk", "pull")
123+
]
124+
msg_shas = sorted({r["commit_sha"] for r in qualifying})
125+
messages = fetch_commit_messages(client, msg_shas) if msg_shas else {}
126+
127+
# Initialize every row so csv.DictWriter always has the premerge_status field.
128+
for r in rows:
129+
r["premerge_status"] = ""
130+
131+
total = len(qualifying)
132+
# head_sha/merge_ts/job_ids depend only on the commit, so resolve the per-commit
133+
# context once and reuse it for every failing signal on that commit.
134+
context_cache: Dict[str, PremergeContext] = {}
135+
for i, r in enumerate(qualifying, start=1):
136+
file, sep, name = r["signal_key"].partition("::")
137+
if not sep:
138+
continue
139+
message = messages.get(r["commit_sha"], "")
140+
pr = parse_pr_from_message(message)
141+
context = context_cache.get(r["commit_sha"])
142+
if context is None:
143+
context = resolve_premerge_context(client, r["commit_sha"], repo=args.repo)
144+
context_cache[r["commit_sha"]] = context
145+
status = classify_with_context(client, context, file, name)
146+
r["premerge_status"] = status
147+
logging.info(
148+
"premerge %d/%d commit=%s pr=%s signal=%s -> %s",
149+
i,
150+
total,
151+
r["commit_sha"][:10],
152+
pr,
153+
r["signal_key"],
154+
status,
155+
)
156+
157+
return rows
158+
159+
160+
def main(argv: Optional[List[str]] = None) -> int:
161+
logging.basicConfig(
162+
level=logging.INFO,
163+
format="%(asctime)s %(levelname)s %(message)s",
164+
stream=sys.stderr,
165+
)
166+
load_dotenv()
167+
args = parse_args(argv)
168+
169+
rows = collect(args)
170+
171+
output = args.output or default_output(args.start, args.end)
172+
write_csv(output, rows)
173+
174+
n_reg = sum(1 for r in rows if r["category"] == "regression")
175+
n_flaky = sum(1 for r in rows if r["category"] == "flaky")
176+
n_commits = len({r["commit_sha"] for r in rows})
177+
print(output)
178+
print(
179+
f"{len(rows)} rows across {n_commits} commits: "
180+
f"{n_reg} regression, {n_flaky} flaky"
181+
)
182+
return 0
183+
184+
185+
if __name__ == "__main__":
186+
sys.exit(main())

0 commit comments

Comments
 (0)