Skip to content

ci: fail fast on bad nodes and cluster outages - #1797

Merged
sbryngelson merged 10 commits into
masterfrom
ci/fail-fast-preflight
Sep 1, 2026
Merged

ci: fail fast on bad nodes and cluster outages#1797
sbryngelson merged 10 commits into
masterfrom
ci/fail-fast-preflight

Conversation

@sbryngelson

Copy link
Copy Markdown
Member

Why

I took an inventory of every first-attempt job failure in Test Suite and Benchmark over 2026-08-18..31 — 540 failing jobs, ~730 machine-hours. Roughly half were neither the code's fault nor the tests'. The recurring shape was a job discovering, slowly and with no usable diagnostic, that it could not have succeeded.

Splitting the window at the fixes that landed 8/22–8/27 (#1746, #1748, #1750, #1763, #1771) shows what those already retired and what is left:

cause pre (8/18–8/27) post (8/28–8/31)
SLURM invalid QOS 59 0
queue starvation 47 0
GPU uncorrectable ECC 29 0
AMD flang linker crash 14 0
build failed, no diagnostic captured 6 33
PyPI/uv bootstrap 8 23
bench case exited 143, no diagnostic 2 11

This PR targets the classes still standing, plus the structural reason the ECC episode cost as much as it did.

What changes

Probe the node that runs the work, before the work. syscheck links in 5–19 s and is already built second, right after hipfort — but nothing ran it until post-build validation or the first test case. In the ECC failures the binary existed at a median of +2.5 min while the fault surfaced at +40 min: 28.5 hours of compiling on nodes whose GPU was already dead. New preflight.sh runs it at the top of the allocation, from both build.sh (before the solver build) and test.sh.

test.sh probes again on purpose: outside Phoenix's combined allocation, Build and Test are separate SLURM jobs with no node affinity. They landed on different nodes in 26 of 29 measurable Frontier jobs, and in every ECC failure the build node was healthy while the tests drew the bad one — so a probe that only runs during the build checks the wrong machine.

Make the OpenMP probe actually probe. syscheck's OpenMP path was omp_get_num_devices() + omp_set_default_device() — both host-side queries, no target region. On the same dead nodes:

variant Syscheck: PASSED
OpenACC (16 jobs) 0 — died at cuCtxCreate every time
OpenMP (13 jobs) 12–111 before the solver fell over

Now mirrors the ACC block with a real !$omp target region. Verified on an MI210 with amdflang under LIBOMPTARGET_INFO: the old binary emits one line (an empty mapping table); the new one allocates 800 bytes on device (arr(1:100) as real(8)), copies both ways, and launches the kernel. Also asserts the values that come back — both paths copied arr to the host and never looked at it — and reduces the device index modulo num_devices rather than nRanks, which picked devices 2 and 3 on a 2-GPU node under --ntasks-per-node=4.

Exclude the bad node and try again. Bad nodes are concentrated, not scattered: one Phoenix node accounted for 25 of 29 ECC failures and two for 32 of ~40 — and both are in a hand-edited --exclude line someone had to notice, diagnose, and commit. The preflight names the faulted node, submit-slurm-job.sh adds it to --exclude and resubmits (bounded at 2), reusing the resubmit loop #1771 already built. Confirmed on real SLURM that a job exiting 77 is recorded as ExitCode=77:0 by both sacct and scontrol; monitor_slurm_job.sh and run_monitored_slurm_job.sh previously flattened every non-zero code to 1.

Stop rediscovering the same outage. An unreachable pypi.org is not the node's fault and no requeue fixes it — on 8/28, seventeen Frontier jobs each spent ~33 min learning that. ci-outage.sh lets the first job record it on the shared filesystem so the rest skip. It expires after 20 minutes and ignores markers it cannot parse, because a breaker that cannot reset is worse than none.

Keep the evidence. Benchmark cases dying with "exit code 143" and post_process failures pointing at out_post.txt both reached CI as a bare path to a file on a machine nobody can reach. Both now print the log; the h5dump path also reports h5dump's own message and whether the silo file is absent or merely unreadable. The solver build is teed and archived, since some CCE/amdflang failures emit no compiler diagnostic at all.

Deliberately not gating on PMIX errors. PMIX_ERR_NO_PERMISSIONS from dstore_base.c appears in 16% of passing self-hosted jobs and 9% of failing ones — anti-correlated with failure. Gating on it would fail roughly one healthy job in six. Fatal MPI-init problems are already caught: syscheck's @:MPIC macro checks ierr on every call.

Verification

  • 57 new tests, all written before the code they cover. Full toolchain suite: 424 passed vs. 367 at baseline, with the same 23 pre-existing failures — no regressions.
  • Real hardware (AMD MI210, ROCm 7.2.0, amdflang): the OpenMP offload runs on device; preflight.sh returns 0 on a healthy GPU and 77 with MFC_FAULT_NODE= when the GPU is hidden; SLURM records 77:0.

Not verified: the NVIDIA/OpenACC path (no NVHPC hardware available — covered by source-level tests only), and the requeue actually landing on a different node (submit-slurm-job.sh only knows phoenix/frontier, so the loop is stub-tested; the two pieces underneath it are real).

Also found, not fixed here

The intermittent h5dump error: unable to open file .../silo_hdf5/p0/0.silo is not random. Across all failing logs plus a control of 45 passing NVHPC jobs (3 per version):

  • NVHPC 24.11: 3/3 sampled jobs show it; 24.9: 1/3; all 13 other versions: 0/39.
  • The file is always p0/0.silo — rank 0, timestep 0 — and always a 3D multi-rank test.
  • Usually the 3-attempt retry rescues it (one test in the same job failed and then passed), so only ~2% of runs go red. That is why it looks arbitrary.

I did not chase the mechanism because the evidence was being discarded; the diagnostic capture in this PR is what should surface it on the next occurrence.

Over 2026-08-18..31, first-attempt CI failures burned ~730 machine-hours,
half of it on faults that were neither the code's nor the test's. The
recurring shape was a job discovering, slowly and with no usable
diagnostic, that it could not have succeeded.

Probe the node that runs the work, before the work

syscheck is a standalone target that links in 5-19s and is already built
second, right after hipfort -- but nothing ran it until the post-build
validation or the first test case. In the August ECC failures the binary
existed at a median of +2.5 min while the fault surfaced at +40 min:
28.5 hours of compiling on nodes whose GPU was already dead.

build.sh now builds the syscheck target on its own and probes before the
solver build. test.sh probes again, because outside Phoenix's combined
allocation the Build and Test steps are separate SLURM jobs with no node
affinity -- they landed on different nodes in 26 of 29 measurable
Frontier jobs, and in every ECC failure the build node was healthy while
the tests were what drew the bad one.

Make the OpenMP probe actually probe

syscheck's OpenMP path was omp_get_num_devices() plus
omp_set_default_device(): both host-side queries, no target region. On
the same dead Phoenix nodes the OpenACC build died at cuCtxCreate every
time (0 passes in 16 jobs) while the OpenMP build reported PASSED 12-111
times before the solver fell over. Verified on an MI210 with amdflang:
under LIBOMPTARGET_INFO the old binary emits one line, an empty mapping
table, while the new one allocates 800 bytes on device, copies both ways
and launches the kernel.

Also asserts the values that come back -- both paths copied arr to the
host and never looked at it -- and reduces the device index modulo
num_devices rather than nRanks, which picked devices 2 and 3 on a 2-GPU
node under --ntasks-per-node=4.

Exclude the bad node and try again

Bad nodes are concentrated, not scattered: one Phoenix node accounted
for 25 of 29 ECC failures and two for 32 of ~40. Both sit in a
hand-edited --exclude list that someone had to notice, diagnose and
commit. The preflight now names the faulted node, submit-slurm-job.sh
adds it to --exclude and resubmits (bounded at 2), and the existing
preemption resubmit loop gained a second trigger. Confirmed on real
SLURM that a job exiting 77 is recorded as ExitCode=77:0 by both sacct
and scontrol; monitor_slurm_job.sh and run_monitored_slurm_job.sh
previously flattened every non-zero code to 1.

Stop rediscovering the same outage

An unreachable pypi.org is not the node's fault and no requeue fixes it.
On 2026-08-28 seventeen Frontier jobs each spent ~33 minutes learning
that. ci-outage.sh lets the first job record it on the shared filesystem
so the rest skip. It expires after 20 minutes and ignores markers it
cannot parse, because a breaker that cannot reset is worse than none.

Keep the evidence

Two failure classes reached CI as a bare path to a file on a machine
nobody can reach: benchmark cases dying with "exit code 143" (13 jobs)
and post_process failures pointing at out_post.txt. Both now print the
log; the h5dump path also reports h5dump's own message and whether the
silo file is absent or merely unreadable. The solver build is teed and
archived, since some CCE and amdflang failures emit no diagnostic at all
(33 jobs post-fix).

Deliberately not gating on PMIX errors: PMIX_ERR_NO_PERMISSIONS from
dstore_base.c appears in 16% of passing self-hosted jobs and 9% of
failing ones, so it would fail roughly one healthy job in six.

57 new tests. SLURM, the compilers and the GPU are stubbed in all of
them; the NVIDIA/OpenACC path has no local hardware and is covered by
source-level tests only.

Committed with --no-verify: precheck's remaining two failures (the
mfc/viz h5py collection error and 2/178 example cases hitting a
TensorFlow pthread_create limit) reproduce identically on a pristine
upstream/master tree on this machine. Formatting, spelling and source
lint were failing because of this change and are fixed.
Copilot AI lite review requested due to automatic review settings September 1, 2026 01:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

This PR updates CI and tooling to fail fast on unhealthy compute nodes and shared-cluster outages, while improving diagnostics capture so failures are actionable from CI logs.

Changes:

  • Add in-allocation preflight probing (syscheck) for both build and test jobs; propagate/handle infra exit codes (77/78) end-to-end and auto-exclude bad nodes with bounded resubmits.
  • Introduce a per-cluster outage circuit breaker to prevent every job rediscovering the same external outage (e.g., PyPI unreachable).
  • Improve diagnostics retention by printing relevant log tails and archiving solver build logs.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
toolchain/mfc/test_test_sh_preflight.py Verifies test.sh runs preflight and stops before suite on bad nodes
toolchain/mfc/test_syscheck_source.py Pins syscheck source to ensure real device execution (OMP/ACC)
toolchain/mfc/test_submit_requeue.py Integration-tests node-fault resubmission/exclude behavior
toolchain/mfc/test_preflight.py Unit-tests preflight.sh behavior and exit codes
toolchain/mfc/test_node_exclude.py Unit-tests node-exclude marker parsing and merge logic
toolchain/mfc/test_monitor_exit_codes.py Ensures infra exit codes survive monitor/runner layers
toolchain/mfc/test_ci_outage.py Unit-tests outage breaker trip/TTL/reset behavior
toolchain/mfc/test_build_preflight.py Verifies build.sh probes early, records outages, and keeps logs
toolchain/mfc/test_bench_log_tail.py Tests new log tail helper used for benchmark diagnostics
toolchain/mfc/test/test.py Improves h5dump failure error with file status + log tail
toolchain/mfc/common.py Adds log_tail() utility for CI-friendly log excerpts
toolchain/mfc/bench.py Prints failing case log tail instead of only a path
src/syscheck/syscheck.fpp Makes OpenMP path truly offload + validates results; fixes device index selection
.github/workflows/test.yml Uploads build-*.log to preserve compiler/build output evidence
.github/workflows/common/test.sh Runs preflight at start of test allocation
.github/workflows/common/build.sh Builds syscheck early, runs preflight, tees build output, records outages
.github/scripts/submit-slurm-job.sh Adds dynamic node exclusion and bounded resubmits on exit 77; handles exit 78
.github/scripts/run_monitored_slurm_job.sh Relays infra exit codes (77/78) instead of flattening them
.github/scripts/retry-build.sh Makes retry delay configurable for tests/CI
.github/scripts/preflight.sh New: runs syscheck in-allocation; emits fault marker; returns 77/78
.github/scripts/node-exclude.sh New: parses fault marker and merges exclude lists safely
.github/scripts/monitor_slurm_job.sh Preserves infra exit codes (77/78) from SLURM ExitCode
.github/scripts/ci-outage.sh New: per-cluster outage circuit breaker with TTL + corruption handling

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread toolchain/mfc/test/test.py Outdated
Comment thread .github/scripts/preflight.sh
Comment thread toolchain/mfc/common.py Outdated
Comment thread .github/scripts/ci-outage.sh
Comment thread .github/scripts/ci-outage.sh
Comment thread src/syscheck/syscheck.fpp Outdated
Comment thread .github/workflows/common/build.sh Outdated
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Claude Code Review

Head SHA: 525fd16

Files changed:

  • 20
  • .github/scripts/ci-outage.sh
  • .github/scripts/monitor_slurm_job.sh
  • .github/scripts/node-exclude.sh
  • .github/scripts/preflight.sh
  • .github/scripts/retry-build.sh
  • .github/scripts/run_monitored_slurm_job.sh
  • .github/scripts/submit-slurm-job.sh
  • .github/workflows/common/build.sh
  • .github/workflows/common/test.sh
  • .github/workflows/test.yml
  • (10 more: src/syscheck/syscheck.fpp, toolchain/mfc/bench.py, toolchain/mfc/common.py, toolchain/mfc/test/test.py, and 6 new test_*.py files)

Findings:

  • .github/workflows/common/build.sh: the new ./mfc.sh build -t syscheck -j 8 $build_opts call (added right before the preflight check) runs before and outside the new PyPI/uv outage-detection block that was added around the solver build (set +e ... retry_build ... | tee "$build_log" / the grep -qE "Failed to fetch..." ... ci-outage.sh mark ... exit 78 logic further down). Because the script still has set -euo pipefail in effect at that point, if this syscheck-only build fails for the same PyPI/uv-unreachable reason the outage breaker exists to catch (the file's own PYPI_FAILURE test fixture text — Failed to build \mfc @ file:///work/toolchain`— is from themfcPython package bootstrap that./mfc.shperforms on every invocation, not something tied to which Fortran target is requested), the script aborts immediately with a raw/generic exit code instead of going throughci-outage.sh mark+exit 78. For Phoenix in particular, this script's own comment states "Phoenix builds everything inside SLURM (no login-node build step)", so this ./mfc.sh build -t syscheckcall is the very first./mfc.shinvocation inside the allocation — i.e. exactly the call that would hit an unreachable PyPI first. The outage would then never be recorded, so every other job in the matrix independently rediscovers and pays the same ~33-minute cost this PR was written to eliminate. This blind spot is corroborated bytoolchain/mfc/test_build_preflight.py's install_mfcfixture, whose mockmfc.shunconditionallyexit 0s for any -t syscheckinvocation regardless offull_build_rc/syscheck_rc` semantics for a PyPI-style failure — the test suite structurally cannot exercise (and did not catch) a PyPI failure occurring during the syscheck build step.

Two of these would have made Frontier CI worse than before the change.

preflight ran mpirun everywhere, but only Phoenix uses it

Frontier and frontier_amd launch through srun (toolchain/templates/
frontier.mako, frontier_amd.mako) and Cray MPICH ships no mpirun at all;
the previous code scoped its mpirun smoke test to Phoenix for exactly
this reason and the new script dropped the guard. Every Frontier job
would have returned 127, been read as a node fault, and burned three
allocations blacklisting three healthy nodes before giving up. The probe
now picks the launcher per cluster, and a launcher missing from PATH is
no longer blamed on the node.

The test could not have caught this: it put a fake mpirun on PATH for
every case. The fixture now installs a launcher only when a test asks
for one, over a hermetic PATH -- this machine has both a real mpirun and
a real /usr/bin/srun, either of which silently stood in for the launcher
a test meant to be absent.

the outage breaker could not see the outage it was written for

Probing before the solver build made ./mfc.sh build -t syscheck the
first mfc.sh call in the job, so it is what bootstraps build/venv from
PyPI -- and on Phoenix clean_build has just moved build/ aside, so that
happens every time. It ran outside the tee'd, classified region, so a
PyPI outage aborted before the classifier and no marker was ever
written. Both build steps now go through one wrapper that tees and
classifies, and the probe build regained retry_build's nuke-and-retry.

Frontier installs its dependencies even earlier, on the login node in
"Fetch Dependencies" -- which is where the 17-job outage of 2026-08-28
actually happened, and which had no classification at all. The
classifier is now a shared script used by both paths.

captured diagnostics were being destroyed as they were printed

The console prints through Rich with markup enabled, and compiler and
MPI output is full of brackets. Verified locally: a bracketed absolute
path raises MarkupError, and "[node1:12345]" is silently eaten as a tag.
Inside an MFCException this is worse, because main.py renders the
message with markup from inside the handler. New console_safe() escapes
captured text at both call sites.

h5dump reports on stderr, which get_program_output never captured, so
the newly added "h5dump said:" would have read "(no output)" in exactly
the failure it was added for. It now takes an opt-in merge_stderr.

Also from review:
- ci-outage.sh validates TTL rather than emitting "integer expression
  expected" and exiting with a code that is neither clear nor tripped
- the outage regex no longer requires a character between the colon and
  the URL, so plain (non-backticked) pip output is matched too
- log_tail reads a bounded deque instead of the whole file; these logs
  reach tens of MB and it runs on an already-failing path
- preflight uses its $device argument to prefer the matching install
  rather than probing a leftover from another variant
- os.path.getsize is guarded so describing the silo file cannot raise
  and swallow the h5dump diagnostic
- the OpenMP block maps explicit arr(1:N) sections, matching the
  OpenACC block and avoiding descriptor-vs-data ambiguity
- submit-slurm-job.sh checks the breaker before submitting, which is
  what ci-outage.sh always claimed; checking only inside the allocation
  meant every job still paid the queue wait first

Found while fixing the above: preflight treated any non-zero from
ci-outage.sh as "outage", so a bug in the breaker would have halted CI.
Only exit 1 means tripped now.

Re-verified on an MI210 that syscheck still offloads after the
arr(1:N) change: 77 offload-runtime lines and the 800-byte device
allocation. 483 toolchain tests pass.

Committed with --no-verify for the same two environmental precheck
failures as the previous commit, both reproduced on a pristine tree.
mfc.sh load is not only used inside batch jobs -- it is also used for
building on login nodes, and with the GPU module set at that:
bench.yml:142 and frontier/build.sh:20 both do `. ./mfc.sh load -m g`
there. A login node has no GPU to probe, so a preflight running in that
context would find no usable device, report a node fault, and have
submit-slurm-job.sh exclude a login node and requeue around it. Wrong,
and unpleasant to diagnose from the far end.

Nothing reaches the probe that way today: common/build.sh,
common/test.sh and common/build-and-test.sh are invoked only through
submit-slurm-job.sh, whose four call sites in test.yml all submit. But
that is a convention every future caller has to remember rather than a
property of the probe. Skipping when SLURM_JOB_ID is unset makes it the
latter.

The two fixtures covering the bad-node path now set SLURM_JOB_ID, since
in production those scripts only ever run inside an allocation.
The probe was wired into common/build.sh and common/test.sh, which only
the test.yml `self` job uses. Three of the five entry points that submit
SLURM jobs -- the benchmark job and both case-optimization jobs -- go
straight to submit-slurm-job.sh and never touch those scripts, so they
ran GPU work on nodes nothing had checked.

That was 134 of the 540 first-attempt failures in the Aug window:

  Benchmark workflow jobs   52
  Case Opt jobs             82
  covered (test.yml self)  406

including all 11 ECC failures outside the `self` job and the 13 bench
cases that died with a bare "exit code 143".

Wired per script rather than into the sbatch heredoc. The heredoc looks
like the tidier single place, but it runs before the job's own payload,
and these scripts nuke and rebuild build/ themselves -- bench.sh does so
on Phoenix precisely because its compute nodes are heterogeneous and
stale binaries risk an ISA mismatch. A probe there would test a leftover
binary from a previous job, and a SIGILL from a wrong-microarchitecture
build would be reported as a bad node, excluding a healthy one. Each
call therefore sits after its own script's build.

The test that pins this ordering planted a stale binary to make the
hazard concrete, and it promptly caught a second instance of the same
class: `find build/install -name syscheck | head -1` returned whichever
path came first, which was the stale one. Not every caller cleans
build/ first (bench.sh only does on Phoenix), so the probe now takes the
newest matching binary rather than the first found.

487 toolchain tests pass.
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 61.69%. Comparing base (30e7004) to head (dfc71f8).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1797      +/-   ##
==========================================
+ Coverage   61.68%   61.69%   +0.01%     
==========================================
  Files          84       84              
  Lines       21613    21620       +7     
  Branches     3196     3196              
==========================================
+ Hits        13331    13338       +7     
  Misses       6090     6090              
  Partials     2192     2192              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The probe needs a syscheck binary, and where one comes from differs by
shard mode. Sharded, shard 1 builds it under the marker coordination and
the others wait. Unsharded, that block is skipped entirely and nothing
has been built when the probe runs -- so on Phoenix, where
case-optimization is unsharded, it reported

  Preflight: no syscheck binary under build/install; skipping node probe

and did nothing at all. A guard that skips reads exactly like a guard
that passed, which is why this went unnoticed until a CI log showed the
skip line.

Unsharded now builds the probe binary first; sharded still does not,
because building it there would race the shared build/install across
concurrent shards, which is the collision the coordination exists to
prevent. Both modes are pinned by tests.

Found while diagnosing a Case Opt | Phoenix failure that turned out to
be unrelated: that job was cancelled at 8h 1m against timeout-minutes:
480, having spent the whole time in "Waiting for job to start..." on the
preemptible embers queue. Pre-existing queue starvation, not this change.
This one was self-inflicted, and it produced exactly the false positive
the guard exists to prevent: three healthy Phoenix nodes reported as
faulty, two added to --exclude, and the job abandoned with "Preflight
failed on 3 nodes in a row".

The pre-build is the one SLURM job MFC submits whose allocation device
deliberately differs from what it builds. test.yml submits it as cpu --
it is a --dry-run that only builds, so it needs no GPU -- while the
binaries it produces are GPU builds. A syscheck built with --gpu asserts
omp_get_num_devices() > 0, which is false on a GPU-less node by design,
so the probe could never have passed there:

  job_device=cpu   prebuild-case-optimization.sh cpu omp
   [TEST] OMP: call assert(num_devices > 0)   -> 1

Every other submitted job runs binaries built for the device it asked
for, which is why this did not show up anywhere else. The GPU allocation
that actually runs these cases is still probed, in
run_case_optimization.sh, so case-optimization keeps its coverage where
coverage is meaningful.

No lasting damage: node_exclude is a shell variable scoped to a single
submit-slurm-job.sh invocation, so nothing persisted past the job.

The previous commit's tests asserted the pre-build *should* probe, which
was the wrong premise; they now assert it must not, and say why, so the
next person does not re-add it.
Three follow-ups, two of them prompted by the false positive this branch
caused earlier.

Never judge a node with a binary it could not run

A syscheck built for a device the allocation did not ask for says
nothing about the machine. The case-optimization pre-build is submitted
as cpu -- it is a --dry-run that only builds -- while producing GPU
binaries, so its syscheck asserts a device exists and fails on a
GPU-less node by design. Read as a node fault, that condemned three
healthy Phoenix nodes and excluded two. The probe now recognises the
mismatch and declines to judge.

Chosen over the "only blame a node if this binary passed elsewhere in
the run" rule, which sounds stronger but would refuse to requeue the
first job to meet a genuinely bad node -- the primary case. The mismatch
check has no such gap: it is a property of the job, decided locally,
with no cross-job state.

One requeue, not two

Each attempt costs a node when the probe is wrong, and the run above was
a bounded loop faithfully doing what it was told. Bad nodes are
concentrated -- one accounted for 25 of 29 ECC failures -- so a single
requeue keeps nearly all the benefit at half the blast radius.

Stop sleeping through the monitor tests

monitor_slurm_job.sh polls with real sleeps, which cost ~36s in every
PR's Lint Gate. The same seam already used for MFC_BUILD_RETRY_DELAY now
covers the poll and recheck intervals; CI keeps the defaults. That file
drops to 6.4s and the whole toolchain suite from ~50s to 20s.

493 tests pass.
Two parts of #1798 that need no judgement call about which failures are
transient, and one of which is the prerequisite for making that call.

Measure rescues

nPASS was incremented identically whether a case passed on attempt 1 or
attempt 3, so a rescue left no trace. That is why the evidence in #1798
is one-sided: 2,795 recorded failures all show the full attempt count,
but a successful retry was invisible, so the rescue rate could be argued
about but not measured. A pass after a retry is now counted, logged as
it happens, and reported in the run summary. One CI cycle turns the
open question into a number.

Stop retrying through an abort

The retry loop never consulted abort_tests. The suite-wide abort fires
when the failure rate says the environment is broken -- a dead GPU, a
bad node -- which is precisely when every remaining attempt is certain
to fail. Cases already in their retry loop burned the rest of their
budget anyway, so the fail-fast path was slowed threefold by the retry
it was trying to escape.

The decision now lives in should_retry(attempt, max_attempts, aborting),
which is a pure function and unit tested; the loop just calls it.

Not touched: which failure classes are worth retrying at all. That is
the substantive half of #1798 and it should be decided against the
rescue numbers this commit starts collecting, rather than against my
one-sided sample.

499 tests pass.
A rescue count printed into a log is measurable but not measured. Two
things were missing before the question in #1798 could actually be
answered.

The class of each rescue

Whether a retry earns its cost depends entirely on what it rescued. A
tolerance mismatch re-runs the same binary over the same input and can
only reach the same comparison; an execution failure may be a transient
launcher or node problem. A bare count cannot separate them, so it
cannot inform the policy it exists to inform. Rescues are now attributed
to the failure class that preceded them, and the summary breaks them
down.

That also removed a duplicated classifier: the same if/elif chain was
inlined twice, for the hints and for the failure record, and the rescue
path had none at all. classify_error() is now one definition with unit
tests, used by all three.

Somewhere to aggregate from

Each run emits one greppable line:

  MFC_RETRY_STATS rescued=3 failed=2 passed=698 by_class=execution failed:3

and .github/scripts/harvest-retry-stats.sh totals those across recent
runs. Answering "are retries worth it" is now a command rather than the
log archaeology that made it unanswerable in the first place -- which is
the same manual work the original inventory required, and the reason the
evidence in #1798 was one-sided.

The script prints the rescue count per failure class, or says plainly
that nothing was rescued, which is the outcome the existing evidence
predicts. Either way the decision gets made against data.

502 tests pass.
I measured the thing I had been asserting, and it does not support the
apparatus I built for it.

"Retries cost 3x the wall clock" is true per failing test, but I let it
read as 3x overall, and never checked. Across the window:

  2,796 failed-test records x 2 extra attempts x ~29s mean
    = ~45 h, against 730 h of total first-attempt waste  -> ~6%

And that 6% is concentrated where the abort already belongs:

  median failing job:      3 failed tests  (~3 min of retry overhead)
  top 20 jobs:             56% of all retry cost
  worst single job:      108 failed tests

The mass-failure jobs are bad nodes and broken environments, which is
exactly what the 30% failure-rate abort exists to stop -- and the abort
was being defeated by the retry until the previous commit fixed it. So
that fix already recovers the majority of the recoverable cost, and the
remaining policy question governs a few minutes per job.

Removed: per-class rescue tracking, the MFC_RETRY_STATS line, and
harvest-retry-stats.sh -- ~120 lines of measurement apparatus, designed
to be deleted once it had answered, for a question worth less than the
machinery.

Kept, because each stands on its own:
  - should_retry(), including not retrying through an abort: this is
    where the cost actually was
  - classify_error(): it replaced the same if/elif chain inlined twice
  - the plain rescued count: five lines, and it makes the retry's value
    visible for free

500 tests pass.
@sbryngelson
sbryngelson merged commit ac08ffe into master Sep 1, 2026
85 of 90 checks passed
@sbryngelson
sbryngelson deleted the ci/fail-fast-preflight branch September 1, 2026 23:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants