Skip to content

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

Open
sbryngelson wants to merge 4 commits into
masterfrom
ci/fail-fast-preflight
Open

ci: fail fast on bad nodes and cluster outages#1797
sbryngelson wants to merge 4 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 (a46e55f).

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.

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