Skip to content

Commit 4f8ed7c

Browse files
authored
[HUD] Add sole viable/strict blocker % to reliability page (#8379)
[HUD] Add sole viable/strict blocker % to reliability page ## Summary Adds a **Sole viable/strict blockers** table under the Primary jobs table on `/reliability`. For each gating job (shards folded to config granularity) it shows the % of all *decided* trunk commits where it is the **only** job blocking viable/strict, plus a column that aggregates over the job type. ## Details - **Attributes sole-blocking among the workflows that actually reported**, using the same job-level red signal as the real gate (`conclusion_kg`, latest attempt per workflow run, `pull`/`trunk`/`lint`/`docs-build` prefixes, unstable jobs excluded) — so a job is flagged only when it genuinely failed. The one gate case it doesn't reproduce (a required workflow never reporting at all) is left unattributed and counted non-blocking, so it errs toward **under**-counting rather than over-blaming. All four gating workflows fire on essentially every push to main, so it's a close approximation of the gate. - **Denominator** is every fully-decided commit in the range (commits with a pending gating job are dropped). A caption shows the evaluated commit span (count + oldest/newest sha + title) so the % is debuggable against the selected range. - **Job-type %** folds all configs of a job together and is shared across its rows (not additive). Rows that are never individually sole are pruned, unless the job type only ever blocks via several configs failing together. - **Rename**: page heading `Failures` → `Reliability`, and the Dev Infra nav entry → **Reliability Metrics**. ## Test plan - Query validated live against ClickHouse - Aggregation (`computeSoleBlockers`) unit-tested - `tsc` / `next lint` / `prettier` clean - Inspected results over a 3 day window by cross-comparing with HUD.
1 parent 427a93d commit 4f8ed7c

7 files changed

Lines changed: 740 additions & 6 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"params": {
3+
"startTime": "DateTime64(3)",
4+
"stopTime": "DateTime64(3)"
5+
},
6+
"tests": [
7+
{
8+
"startTime": "2026-07-20T00:00:00.000",
9+
"stopTime": "2026-07-27T00:00:00.000"
10+
}
11+
]
12+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
-- Powers the "Sole viable/strict blockers" table on
2+
-- https://hud.pytorch.org/reliability/pytorch/pytorch
3+
--
4+
-- Approximates the viable/strict gate for FAILURE ATTRIBUTION -- it is not a
5+
-- full reimplementation. It uses the same job-level red definition as
6+
-- pytorch/.github/scripts/fetch_latest_green_commit.py (via commit_jobs_batch_query):
7+
-- * gating workflows are prefix-matched against ^(pull|trunk|lint|docs-build)
8+
-- (case-insensitive), same as the `requires` list in update-viablestrict.yml
9+
-- * a job blocks if its latest run attempt (per workflow run) has a
10+
-- conclusion_kg other than success/skipped
11+
-- * jobs marked unstable (name contains "unstable", or the shard-folded name
12+
-- matches an open UNSTABLE issue) are excluded from gating
13+
--
14+
-- It differs from the real gate in one bounded way: the gate also rejects a
15+
-- commit when a required workflow never reported at all ("missing required
16+
-- workflows"). This query only models the job-failure path, so a decided commit
17+
-- that is entirely missing a required workflow (or that has no gating jobs at
18+
-- all) is treated as green here rather than blocked. On post-merge main all four
19+
-- gating workflows run on every push, so that case is rare; treat the numbers as
20+
-- descriptive triage, not the gate's true rate.
21+
--
22+
-- Job names are folded to config granularity by stripping only the trailing
23+
-- shard fields (", <shard>, <num_shards>[, <runner>]") from the final config
24+
-- group and keeping the rest of the name. So
25+
-- `trunk / linux-jammy-rocm-py3.10-mi350 / test (default, 1, 3)` becomes
26+
-- `trunk / linux-jammy-rocm-py3.10-mi350 / test (default)`, and nested jobs with
27+
-- more than two " / " components keep their config instead of collapsing, e.g.
28+
-- `... / dynamo-test (3.11) / test (dynamo_wrapped, 1, 7)` and
29+
-- `... / dynamo-test (3.11) / test (dynamo_core, 1, 1)` stay distinct. One row is
30+
-- returned per fully-evaluated commit with the list of blocking folded jobs; the
31+
-- client computes how often each job (and each job type) is the *only* blocker.
32+
WITH commits AS (
33+
SELECT DISTINCT
34+
p.head_commit. 'id' AS sha,
35+
p.head_commit. 'timestamp' AS time,
36+
-- first line of the commit message, for the commit-range caption
37+
substring(arrayElement(splitByChar('\n', p.head_commit. 'message'), 1), 1, 120) AS title
38+
FROM
39+
default.push p
40+
WHERE
41+
p.ref = 'refs/heads/main'
42+
AND p.repository. 'owner'.'name' = 'pytorch'
43+
AND p.repository. 'name' = 'pytorch'
44+
AND p.head_commit. 'timestamp' >= {startTime: DateTime64(3) }
45+
AND p.head_commit. 'timestamp' < {stopTime: DateTime64(3) }
46+
),
47+
-- Open UNSTABLE issues, trimmed to the shard-folded job name they disable
48+
unstable_jobs AS (
49+
SELECT
50+
trim(substring(issue.title, length('UNSTABLE') + 1)) AS name
51+
FROM
52+
default.issues AS issue FINAL
53+
WHERE
54+
arrayExists(x -> x. 'name' = 'unstable', issue.labels)
55+
AND issue.state = 'open'
56+
AND issue.title LIKE 'UNSTABLE%'
57+
),
58+
-- Every gating job (raw shard-attempt rows) for the commits in range
59+
raw_jobs AS (
60+
SELECT
61+
w.head_sha AS sha,
62+
w.id AS workflow_id,
63+
-- Fold shards only: strip a trailing ", <shard>, <num_shards>[, <runner>]"
64+
-- from the final config group and keep the rest of the (possibly nested)
65+
-- name, so jobs with more than two " / " components don't collapse distinct
66+
-- configs (e.g. dynamo_core vs dynamo_wrapped).
67+
CONCAT(
68+
j.workflow_name,
69+
' / ',
70+
replaceRegexpOne(j.name, ', [0-9]+, [0-9]+.*\\)$', ')')
71+
) AS folded_name,
72+
j.name AS shard,
73+
j.run_attempt AS run_attempt,
74+
j.conclusion_kg AS conclusion
75+
FROM
76+
default.workflow_job j FINAL
77+
INNER JOIN default.workflow_run w FINAL ON w.id = j.run_id
78+
WHERE
79+
j.id IN (
80+
SELECT id FROM materialized_views.workflow_job_by_head_sha
81+
WHERE head_sha IN (SELECT sha FROM commits)
82+
)
83+
AND w.id IN (
84+
SELECT id FROM materialized_views.workflow_run_by_head_sha
85+
WHERE head_sha IN (SELECT sha FROM commits)
86+
)
87+
-- Gating workflow prefixes; keep in sync with the `requires` list in
88+
-- pytorch/pytorch .github/workflows/update-viablestrict.yml
89+
-- (["pull", "trunk", "lint", "docs-build"] as of 2026-07-27).
90+
AND match(lower(j.workflow_name), '^(pull|trunk|lint|docs-build)')
91+
-- Match the gate's job-level filtering (commit_jobs_batch_query), which
92+
-- only drops ciflow_should_run and generate-test-matrix. We additionally
93+
-- drop unstable and rerun_disabled_tests jobs: the gate ignores unstable
94+
-- via is_unstable(), and both carry a trailing config marker
95+
-- (", unstable" / ", rerun_disabled_tests", after the shard fields) that
96+
-- the shard fold strips -- which would collapse them onto, and falsely
97+
-- redden, the real gating job of the same config (e.g. a scheduled
98+
-- rerun_disabled_tests failure landing on `test (default)`). We do NOT
99+
-- filter `job-filter / compute` or slashless lint jobs: the gate gates on
100+
-- those and they fold to distinct names, so excluding them would miss real
101+
-- blockers or make another job look falsely sole.
102+
AND j.name != 'ciflow_should_run'
103+
AND j.name != 'generate-test-matrix'
104+
AND j.name NOT LIKE '%unstable%'
105+
AND j.name NOT LIKE '%rerun_disabled_tests%'
106+
AND w.event != 'workflow_run' -- these are unrelated to the SHA
107+
AND w.event != 'repository_dispatch'
108+
AND NOT (w.event = 'workflow_dispatch' AND w.head_branch LIKE 'trunk/%') -- restart jobs
109+
),
110+
-- Collapse reruns: keep the latest run attempt per shard, per workflow run.
111+
-- workflow_id is in the key because run_attempt is a per-run counter (not
112+
-- globally comparable), so duplicate workflow runs for one commit must stay
113+
-- separate; folded_job below then ORs a red run in -- mirroring the gate's
114+
-- per-(workflow_id, job) grouping in commit_jobs_batch_query.
115+
shard_latest AS (
116+
SELECT
117+
sha,
118+
folded_name AS name,
119+
shard,
120+
workflow_id,
121+
argMax(conclusion, run_attempt) AS conclusion
122+
FROM
123+
raw_jobs
124+
GROUP BY
125+
sha,
126+
name,
127+
shard,
128+
workflow_id
129+
),
130+
-- Collapse shards: a folded job is blocking if any shard's latest attempt is a
131+
-- real failure; pending if any shard has no terminal conclusion yet
132+
folded_job AS (
133+
SELECT
134+
sha,
135+
name,
136+
maxIf(
137+
1,
138+
conclusion IS NOT NULL
139+
AND conclusion != ''
140+
AND conclusion NOT IN ('success', 'skipped')
141+
) AS blocking,
142+
maxIf(1, conclusion IS NULL OR conclusion = '') AS pending
143+
FROM
144+
shard_latest
145+
WHERE
146+
name NOT IN (SELECT name FROM unstable_jobs) -- drop already-unstable jobs
147+
GROUP BY
148+
sha,
149+
name
150+
),
151+
commit_agg AS (
152+
SELECT
153+
sha,
154+
max(pending) AS any_pending,
155+
arrayFilter(x -> x != '', groupArray(IF(blocking = 1, name, ''))) AS blocking
156+
FROM
157+
folded_job
158+
GROUP BY
159+
sha
160+
)
161+
SELECT
162+
c.time AS time,
163+
ca.sha AS sha,
164+
c.title AS title,
165+
ca.blocking AS blocking
166+
FROM
167+
commit_agg ca
168+
JOIN commits c ON c.sha = ca.sha
169+
WHERE
170+
ca.any_pending = 0 -- only fully-evaluated commits count toward the denominator
171+
ORDER BY
172+
time DESC

torchci/components/layout/NavBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function NavBar() {
3131
href: "/job_cancellation_dashboard",
3232
},
3333
{
34-
name: "Failures Metric",
34+
name: "Reliability Metrics",
3535
href: "/reliability",
3636
},
3737
{

torchci/lib/metricUtils.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,142 @@ export function approximateFailureByType(
153153
return failuresByTypes;
154154
}
155155

156+
// A commit's viable/strict gating state: the list of gating jobs (folded to
157+
// config granularity, shards collapsed) that are blocking it. Produced by the
158+
// viable_strict_sole_blocker ClickHouse query.
159+
export interface SoleBlockerCommit {
160+
time: string;
161+
sha: string;
162+
// first line of the commit message (for the commit-range caption)
163+
title?: string;
164+
blocking: string[];
165+
}
166+
167+
// The span of commits the sole-blocker table was computed over, for
168+
// debuggability ("Last 1 day = commit A .. commit B").
169+
export interface SoleBlockerRange {
170+
count: number;
171+
oldest?: { sha: string; title: string; time: string };
172+
newest?: { sha: string; title: string; time: string };
173+
}
174+
175+
export function soleBlockerCommitRange(
176+
data?: SoleBlockerCommit[]
177+
): SoleBlockerRange {
178+
if (!data || data.length === 0) {
179+
return { count: 0 };
180+
}
181+
182+
// ISO timestamps sort lexicographically, so scan for min/max without
183+
// assuming the input is ordered.
184+
let oldest = data[0];
185+
let newest = data[0];
186+
data.forEach((commit) => {
187+
if (commit.time < oldest.time) oldest = commit;
188+
if (commit.time > newest.time) newest = commit;
189+
});
190+
191+
const pick = (c: SoleBlockerCommit) => ({
192+
sha: c.sha,
193+
title: c.title ?? "",
194+
time: c.time,
195+
});
196+
return { count: data.length, oldest: pick(oldest), newest: pick(newest) };
197+
}
198+
199+
export interface SoleBlockerRow {
200+
name: string;
201+
// % of evaluated commits where this exact job (config) is the only blocker
202+
sole: number;
203+
// % of evaluated commits where only this job's job type is blocking (possibly
204+
// via several of its configs at once)
205+
soleJobType: number;
206+
}
207+
208+
// The job type is the job with its trailing test config dropped, so a job's
209+
// configs fold together. For the common "workflow / machine / test (config)"
210+
// shape this is "workflow / machine". For nested jobs (>2 " / " components, e.g.
211+
// "trunk / dynamo-unittest / dynamo-test (3.11) / test (dynamo_core)") it drops
212+
// only the config and keeps the matrix instance
213+
// ("trunk / dynamo-unittest / dynamo-test (3.11)"), so distinct python versions
214+
// aren't collapsed together. Slashless / 2-part names have no config to drop.
215+
export function jobTypeOf(name: string): string {
216+
const parts = name.split(" / ");
217+
return parts.length >= 3 ? parts.slice(0, -1).join(" / ") : name;
218+
}
219+
220+
// For each gating job, compute how often it is the *only* thing blocking
221+
// viable/strict, both at config granularity and folded up to the job type.
222+
// The denominator is every fully-evaluated commit in the range, so the value
223+
// reads as "this job alone blocks X% of all main commits".
224+
//
225+
// Rows are pruned to the ones that carry signal: a config is shown if it was
226+
// ever individually the sole blocker (sole > 0). Configs that are never
227+
// individually sole but belong to a sole-blocking job type are suppressed as
228+
// redundant, UNLESS no sibling config of that job type is individually sole --
229+
// i.e. the job type only ever blocks via several of its configs failing
230+
// together. In that combo-only case the 0-config rows are kept so the job-type
231+
// signal is never hidden.
232+
export function computeSoleBlockers(
233+
data?: SoleBlockerCommit[]
234+
): SoleBlockerRow[] {
235+
if (!data) {
236+
return [];
237+
}
238+
239+
const total = data.length;
240+
const soleConfigCount: { [name: string]: number } = {};
241+
const soleJobTypeCount: { [jobType: string]: number } = {};
242+
const seenConfigs = new Set<string>();
243+
244+
data.forEach((commit) => {
245+
const blocking = (commit.blocking ?? []).filter((n) => n && n.length > 0);
246+
blocking.forEach((n) => seenConfigs.add(n));
247+
248+
// Sole at config granularity: exactly one folded job is blocking
249+
const configs = new Set(blocking);
250+
if (configs.size === 1) {
251+
const only = configs.values().next().value as string;
252+
soleConfigCount[only] = (soleConfigCount[only] ?? 0) + 1;
253+
}
254+
255+
// Sole at job-type granularity: all blocking jobs belong to one job type
256+
// (there may be several configs of the same job type)
257+
const jobTypes = new Set(blocking.map(jobTypeOf));
258+
if (blocking.length >= 1 && jobTypes.size === 1) {
259+
const only = jobTypes.values().next().value as string;
260+
soleJobTypeCount[only] = (soleJobTypeCount[only] ?? 0) + 1;
261+
}
262+
});
263+
264+
const candidates = Array.from(seenConfigs)
265+
.map((name) => ({
266+
name,
267+
sole: total ? ((soleConfigCount[name] ?? 0) / total) * 100 : 0,
268+
soleJobType: total
269+
? ((soleJobTypeCount[jobTypeOf(name)] ?? 0) / total) * 100
270+
: 0,
271+
}))
272+
.filter((row) => row.sole > 0 || row.soleJobType > 0);
273+
274+
// Job types that already have an individually-sole config, i.e. an actionable
275+
// row. Their non-sole sibling configs are redundant and get dropped.
276+
const jobTypesWithSoleConfig = new Set(
277+
candidates.filter((row) => row.sole > 0).map((row) => jobTypeOf(row.name))
278+
);
279+
280+
return candidates
281+
.filter(
282+
(row) => row.sole > 0 || !jobTypesWithSoleConfig.has(jobTypeOf(row.name))
283+
)
284+
.sort(
285+
(a, b) =>
286+
b.soleJobType - a.soleJobType ||
287+
b.sole - a.sole ||
288+
a.name.localeCompare(b.name)
289+
);
290+
}
291+
156292
export function approximateFailureByTypePercent(
157293
// The data is sorted by time DESC, so newer commits come first
158294
data?: JobsPerCommitData[],

0 commit comments

Comments
 (0)