diff --git a/.github/scripts/reclaim_slurm_jobs.sh b/.github/scripts/reclaim_slurm_jobs.sh new file mode 100755 index 00000000..8f07181c --- /dev/null +++ b/.github/scripts/reclaim_slurm_jobs.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Copyright (c) 2026, Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# Cancel the SLURM jobs one CI job dispatched, and keep at it until the queue +# confirms they are gone. +# reclaim_slurm_jobs.sh +# +# The five inline copies this replaces discarded squeue's stderr, which made a +# FAILED query indistinguishable from an EMPTY one: a transient controller error +# read as "nothing to reclaim" and broke the retry loop on its first pass -- that +# error being the only reason the loop existed. On 2026-08-06 four jobs sitting in +# the queue when reclaim ran survived it and held 3 of the reservation's 4 nodes +# until their time limit expired. +set -uo pipefail + +prefix="${1:?usage: $0 }" +suffix="${2:?usage: $0 }" +budget="${RECLAIM_TIMEOUT:-120}" +interval="${RECLAIM_INTERVAL:-5}" +me="$(id -un)" +deadline=$(( SECONDS + budget )) + +echo "reclaiming SLURM jobs named ${prefix}*${suffix}" + +while :; do + # Exit code, not emptiness, is what separates an unreachable controller from a + # clean queue; stderr is folded in so the CI log names the failure. + if ! queue=$(squeue -h -u "$me" -o '%i %j' 2>&1); then + echo "squeue failed, retrying (this is NOT an empty queue): $queue" + else + ids=$(printf '%s\n' "$queue" | awk -v p="$prefix" -v s="$suffix" ' + index($2, p) == 1 && length($2) >= length(s) && + substr($2, length($2) - length(s) + 1) == s { print $1 }') + [ -z "$ids" ] && { echo "confirmed: no ${prefix}*${suffix} jobs left"; exit 0; } + echo "cancelling: $ids" + scancel $ids 2>&1 || echo "scancel returned non-zero, retrying" + fi + if [ "$SECONDS" -ge "$deadline" ]; then + # A leaked job holds a reserved GPU node until its time limit, so this has to + # be findable in the log rather than inferred later from a reservation that + # looks idle and is not. + echo "::error::could not confirm reclaim of ${prefix}*${suffix} within ${budget}s; check for leaked SLURM jobs" + squeue -u "$me" -o '%.10i %.44j %.2t %.10M %R' 2>&1 || true + exit 1 + fi + sleep "$interval" +done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c820eb8..10710ba0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,19 @@ name: CI on: + # One event per state of the code: the PR is the pre-merge gate, main is the + # post-merge check. Firing push on every branch ran both for the same commit, + # so the jobs were split by hand -- unit on push, GPU on the PR -- which left + # the PR's own check list reading "skipped" for tests that ran in a push run + # it does not link to, and two check runs named `unit` on one commit. + # A branch with no PR now runs nothing; open a draft to get CI. push: - branches: ["**"] # e2e only for code reaching main untested (see e2e_gate) + branches: [main] pull_request: - branches: [main] # e2e runs here, pre-merge — the usual path + branches: [main] + # ready_for_review so marking a draft ready re-runs and picks up the GPU + # tiers that drafts skip (see e2e_gate). + types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: # manual "Run workflow" button (Actions tab) inputs: run_e2e_mixed: @@ -17,12 +26,15 @@ on: default: false concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + # PR number, not branch name: head_ref carries no repository, so two forks + # that both call a branch `main` or `fix-ci` would share a group and cancel + # each other's runs -- which reads as "my CI vanished" and is near impossible + # to trace back. Falls back to the ref for pushes to main. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: contents: read - actions: read jobs: # Classify the change: does it touch package code, or only docs/examples? A @@ -39,6 +51,16 @@ jobs: fetch-depth: 0 - id: f run: | + # Someone pressed "Run workflow": run everything, whatever the last + # commit happened to touch. There is no diff base on this event, so the + # logic below would fall back to HEAD~1 and skip the whole run off a + # docs-only commit -- and since a branch push no longer starts CI, this + # button is the only way to force one. + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "manual run — treating as a code change" + echo "code=true" >> "$GITHUB_OUTPUT" + exit 0 + fi if [ "${{ github.event_name }}" = "pull_request" ]; then range="${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" else @@ -63,77 +85,38 @@ jobs: echo "=> code=$code" echo "code=$code" >> "$GITHUB_OUTPUT" - # Should the e2e tiers run for THIS event? Every PR into main is tested - # pre-merge, so landing that same code must not test it twice — but "same" has - # to mean the CONTENT, not the PR: if main moved on while the PR sat open, what - # lands is a combination no e2e ever saw. Compare git TREES, which are exactly - # the content, and are equal iff the merge changed nothing versus the PR head. + # Should the GPU tiers run for THIS event? Every PR into main, and every merge + # into main. Re-running on main is deliberate duplication: two PRs can each + # pass alone and break together, and only the merged result shows that. + # Drafts are the exception — iterating on one must not cost a GPU run per + # push, so they get lint and unit only until they are marked ready. e2e_gate: runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read outputs: run: ${{ steps.decide.outputs.run }} steps: - id: decide env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - SHA: ${{ github.sha }} + EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} + DRAFT: ${{ github.event.pull_request.draft }} run: | - tree_of() { gh api "repos/$REPO/commits/$1" --jq .commit.tree.sha 2>/dev/null; } - run=false - if [ "${{ github.event_name }}" = "pull_request" ]; then - run=true - echo "pull_request into main — e2e runs pre-merge" - elif [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then - # Squash and merge commits both report the PR they came from. - head=$(gh api "repos/$REPO/commits/$SHA/pulls" --jq '.[0].head.sha // empty' 2>/dev/null) - landed=$(tree_of "$SHA") - tested=""; [ -n "$head" ] && tested=$(tree_of "$head") - if [ -z "$head" ]; then - run=true; echo "no PR behind this commit — its code was never e2e'd" - elif [ -z "$landed" ] || [ -z "$tested" ]; then - # Never infer "already tested" from a failed lookup: re-testing costs - # GPU minutes, shipping untested code costs more. - run=true; echo "could not read both trees — running e2e to be safe" - elif [ "$landed" = "$tested" ]; then - echo "tree $landed is what PR head $head already e2e'd — skipping" - else - run=true - echo "tree $landed != PR head $head's $tested — main moved under the PR" - fi + if [ "$EVENT" = pull_request ] && [ "$DRAFT" = true ]; then + echo "draft pull request — lint and unit only until it is marked ready" + elif [ "$EVENT" = pull_request ]; then + run=true; echo "pull request into main — GPU tiers run pre-merge" + elif [ "$EVENT" = push ] && [ "$REF" = refs/heads/main ]; then + run=true; echo "merged into main — GPU tiers run again on the result" else - echo "not a PR into main, and not a push to main — no e2e" + echo "neither a pull request into main nor a push to main — no GPU tiers" fi echo "=> run_e2e=$run" echo "run=$run" >> "$GITHUB_OUTPUT" - pre_check: - runs-on: ubuntu-latest - outputs: - should_skip: ${{ steps.skip.outputs.should_skip }} - steps: - - id: skip - uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5 - with: - skip_after_successful_duplicate: "true" - concurrent_skipping: "never" - - # Sign-off gate for the GPU tiers below — `needs:` cannot reach a job in - # another workflow, so dco.yml is called here as one. Skipped off a PR (there - # is nothing to check), which the tiers below read as "did not fail". - dco: - if: github.event_name == 'pull_request' - uses: ./.github/workflows/dco.yml - lint: - needs: [pre_check, changes] - if: >- - needs.pre_check.outputs.should_skip != 'true' && - needs.changes.outputs.code == 'true' + needs: [changes] + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -145,14 +128,9 @@ jobs: run: pre-commit run --all-files --show-diff-on-failure unit: - needs: [lint, pre_check, changes] - # Skip on docs-only changes, and on same-repo PRs (already covered by the - # branch push event); still run on push and on fork PRs. - if: >- - needs.pre_check.outputs.should_skip != 'true' && - needs.changes.outputs.code == 'true' && - (github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name != github.repository) + needs: [lint, changes] + # Skip on docs-only changes. + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -168,14 +146,9 @@ jobs: # Rust router: clippy + unit/integration tests. GH-hosted ubuntu-latest # ships a stable toolchain (with clippy), so no toolchain setup is needed. rust: - needs: [lint, pre_check, changes] - # Skip on docs-only changes, and on same-repo PRs (already covered by the - # branch push event); still run on push and on fork PRs. - if: >- - needs.pre_check.outputs.should_skip != 'true' && - needs.changes.outputs.code == 'true' && - (github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name != github.repository) + needs: [lint, changes] + # Skip on docs-only changes. + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest defaults: run: @@ -190,16 +163,13 @@ jobs: run: cargo test # Engine GPU tests. Same schedule as the e2e tiers (see e2e_gate): every PR - # into main, and any push that lands untested code on main — never on a plain - # branch push, which is where these GPU minutes used to go. + # into main once it is out of draft, and every merge into main. engine: - needs: [lint, pre_check, changes, dco, e2e_gate] + needs: [lint, changes, e2e_gate] if: >- !cancelled() && - needs.pre_check.outputs.should_skip != 'true' && needs.changes.outputs.code == 'true' && needs.lint.result != 'failure' && needs.lint.result != 'cancelled' && - needs.dco.result != 'failure' && needs.dco.result != 'cancelled' && (needs.e2e_gate.outputs.run == 'true' || github.event_name == 'workflow_dispatch') runs-on: [self-hosted, crusoe] timeout-minutes: 60 @@ -220,32 +190,21 @@ jobs: run: exec bash tests/run_tests.sh engine - name: reclaim this job's SLURM jobs (on cancel/failure) if: always() && (cancelled() || failure()) - run: | - suf="-${{ github.run_id }}-engine" - for i in 1 2 3 4 5; do - ids=$(squeue -h -u "$(id -un)" -o '%i %j' 2>/dev/null \ - | awk -v suf="$suf" '$2 ~ /^infera-ci-/ && substr($2, length($2)-length(suf)+1)==suf {print $1}') - [ -z "$ids" ] && { echo "no (more) SLURM jobs to reclaim"; break; } - echo "reclaiming SLURM job(s): $ids (try $i)"; scancel $ids 2>&1 || true - sleep 5 - done + run: .github/scripts/reclaim_slurm_jobs.sh infera-ci- "-${{ github.run_id }}-engine" - # Full PD-mixed e2e (per engine, parallel). When it runs is e2e_gate's call: - # every PR into main, plus a push that lands untested code on main. + # Full PD-mixed e2e (per engine, parallel). When it runs is e2e_gate's call. e2e-mixed: - # Skipped for docs-only changes. Gated behind lint and dco: run only if neither - # failed. `!cancelled()` + result checks (instead of a plain success dependency) - # is needed so e2e still runs when lint is *skipped* as a duplicate (pre_check) - # or dco is skipped off a PR, but is held back when either fails. NOT - # `always()`: on cancel the server re-evaluates job-level `if`, and `always()` - # evaluates true, so the job is never cancelled — it keeps (or even starts) - # burning GPU nodes after "Cancel workflow". - needs: [lint, changes, dco, e2e_gate] + # Skipped for docs-only changes, and held back if lint failed. Checking + # lint's *result* rather than depending on its success keeps `!cancelled()` + # meaningful; `always()` would not work here, because on cancel the server + # re-evaluates job-level `if` and `always()` is true, so the job would never + # be cancelled — it would keep (or even start) burning GPU nodes after + # "Cancel workflow". + needs: [lint, changes, e2e_gate] if: >- !cancelled() && needs.changes.outputs.code == 'true' && needs.lint.result != 'failure' && needs.lint.result != 'cancelled' && - needs.dco.result != 'failure' && needs.dco.result != 'cancelled' && (needs.e2e_gate.outputs.run == 'true' || (github.event_name == 'workflow_dispatch' && inputs.run_e2e_mixed)) strategy: @@ -272,26 +231,16 @@ jobs: run: exec bash tests/run_tests.sh e2e ${{ matrix.engine }} mixed - name: reclaim this job's SLURM jobs (on cancel/failure) if: always() && (cancelled() || failure()) - run: | - # Retry: a single scancel can hit a transient Spur controller error. - suf="-${{ github.run_id }}-${{ matrix.engine }}" - for i in 1 2 3 4 5; do - ids=$(squeue -h -u "$(id -un)" -o '%i %j' 2>/dev/null \ - | awk -v suf="$suf" '$2 ~ /^infera-ci-/ && substr($2, length($2)-length(suf)+1)==suf {print $1}') - [ -z "$ids" ] && { echo "no (more) SLURM jobs to reclaim"; break; } - echo "reclaiming SLURM job(s): $ids (try $i)"; scancel $ids 2>&1 || true - sleep 5 - done + run: .github/scripts/reclaim_slurm_jobs.sh infera-ci- "-${{ github.run_id }}-${{ matrix.engine }}" e2e-disag: # Gates mirror e2e-mixed, `!cancelled()` included: `always()` would keep this # holding a two-node pair after "Cancel workflow". - needs: [lint, changes, dco, e2e_gate] + needs: [lint, changes, e2e_gate] if: >- !cancelled() && needs.changes.outputs.code == 'true' && needs.lint.result != 'failure' && needs.lint.result != 'cancelled' && - needs.dco.result != 'failure' && needs.dco.result != 'cancelled' && (needs.e2e_gate.outputs.run == 'true' || (github.event_name == 'workflow_dispatch' && inputs.run_e2e_disag)) strategy: @@ -328,32 +277,18 @@ jobs: run: exec bash tests/run_tests.sh e2e ${{ matrix.engine }} disag - name: reclaim this job's SLURM jobs (on cancel/failure) if: always() && (cancelled() || failure()) - run: | - # Catches the infera-ci-hold-* pair holder too: it is a -N2 --gres=gpu:8 - # batch job, so a leaked one keeps TWO reserved nodes out of the pool. - # Retry: a single scancel can hit a transient Spur controller error. - suf="-${{ github.run_id }}-${{ matrix.engine }}-disag" - for i in 1 2 3 4 5; do - ids=$(squeue -h -u "$(id -un)" -o '%i %j' 2>/dev/null \ - | awk -v suf="$suf" '$2 ~ /^infera-ci-/ && substr($2, length($2)-length(suf)+1)==suf {print $1}') - [ -z "$ids" ] && { echo "no (more) SLURM jobs to reclaim"; break; } - echo "reclaiming SLURM job(s): $ids (try $i)"; scancel $ids 2>&1 || true - sleep 5 - done + # Catches the infera-ci-hold-* pair holder too: it is a -N2 --gres=gpu:8 + # batch job, so a leaked one keeps TWO reserved nodes out of the pool. + run: .github/scripts/reclaim_slurm_jobs.sh infera-ci- "-${{ github.run_id }}-${{ matrix.engine }}-disag" unit-torch-cpu: - needs: [lint, pre_check, changes] - # Skip on docs-only changes, and on same-repo PRs (already covered by the - # branch push event); still run on push and on fork PRs. - if: >- - needs.pre_check.outputs.should_skip != 'true' && - needs.changes.outputs.code == 'true' && - (github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name != github.repository) + needs: [lint, changes] + # Skip on docs-only changes. + if: needs.changes.outputs.code == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.10" - run: pip install -e ".[dev]" diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml index ded8c652..a8e0b863 100644 --- a/.github/workflows/dco.yml +++ b/.github/workflows/dco.yml @@ -5,8 +5,10 @@ name: DCO # See CONTRIBUTING.md > Developer Certificate of Origin. on: - # Standalone on a PR into any branch; ci.yml additionally calls this one as a - # job, which is what lets its GPU tiers gate on the sign-off via `needs:`. + # One check, on a PR into any branch. ci.yml used to call this as a job too so + # its GPU tiers could gate on the sign-off, which ran it twice per PR under two + # different check names; lint already holds those tiers back, so the second + # copy bought nothing. workflow_call stays for any future caller. pull_request: branches: ["**"] workflow_call: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c456dd15..35ccd8d1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -166,15 +166,7 @@ jobs: - name: reclaim this job's SLURM job (on cancel/failure) if: always() && (cancelled() || failure()) - run: | - suf="-${{ github.run_id }}-${{ matrix.engine }}" - for i in 1 2 3 4 5; do - ids=$(squeue -h -u "$(id -un)" -o '%i %j' 2>/dev/null \ - | awk -v suf="$suf" '$2 ~ /^infera-build-/ && substr($2, length($2)-length(suf)+1)==suf {print $1}') - [ -z "$ids" ] && { echo "no (more) SLURM jobs to reclaim"; break; } - echo "reclaiming SLURM job(s): $ids (try $i)"; scancel $ids 2>&1 || true - sleep 5 - done + run: .github/scripts/reclaim_slurm_jobs.sh infera-build- "-${{ github.run_id }}-${{ matrix.engine }}" # The base-agnostic overlay payload (deploy/overlay/). Unlike the engine # images this one is not independent: it builds its Python trees inside the @@ -236,15 +228,7 @@ jobs: - name: reclaim this job's SLURM job (on cancel/failure) if: always() && (cancelled() || failure()) - run: | - suf="-${{ github.run_id }}-overlay" - for i in 1 2 3 4 5; do - ids=$(squeue -h -u "$(id -un)" -o '%i %j' 2>/dev/null \ - | awk -v suf="$suf" '$2 ~ /^infera-build-/ && substr($2, length($2)-length(suf)+1)==suf {print $1}') - [ -z "$ids" ] && { echo "no (more) SLURM jobs to reclaim"; break; } - echo "reclaiming SLURM job(s): $ids (try $i)"; scancel $ids 2>&1 || true - sleep 5 - done + run: .github/scripts/reclaim_slurm_jobs.sh infera-build- "-${{ github.run_id }}-overlay" # Build the /manual Sphinx site alongside the images. Always uploads the HTML # as a workflow artifact; on a tag (release) it also attaches a tarball to the @@ -337,7 +321,7 @@ jobs: run: pip install -r manual/sphinx/requirements.txt - name: Build manual (warnings = errors) run: make -C manual html - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: infera-manual-html path: manual/_build/html diff --git a/tests/run_tests.sh b/tests/run_tests.sh index 52da8d3d..3fec8b08 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -100,14 +100,21 @@ _cleanup_scratch() { # has to scancel it: job id from $_CUR_DISPATCH_OUT, else from the job tag. _CUR_DISPATCH_OUT="" _cancel_dispatched() { - local jids="" i suf csv + local jids="" i suf csv left queue if [ -n "$_CUR_DISPATCH_OUT" ] && [ -f "$_CUR_DISPATCH_OUT" ]; then jids=$(grep -oE 'srun: job [0-9]+' "$_CUR_DISPATCH_OUT" 2>/dev/null \ | grep -oE '[0-9]+' | sort -u | tr '\n' ' ') fi if [ -z "$jids" ] && [ -n "${INFERA_E2E_JOB_TAG:-}" ]; then suf="-${INFERA_E2E_JOB_TAG}" - jids=$(squeue -h -u "$(id -un)" -o '%i %j' 2>/dev/null \ + # A failed lookup is not an empty queue. Capture before parsing: awk would + # filter the error away, leaving a bare "squeue failed" that has to be + # reproduced to diagnose. Then hand off to ci.yml's reclaim step. + if ! queue=$(squeue -h -u "$(id -un)" -o '%i %j' 2>&1); then + echo "[cleanup] squeue failed, leaving this run's jobs to the workflow's reclaim step: $queue" >&2 + return 1 + fi + jids=$(printf '%s\n' "$queue" \ | awk -v suf="$suf" '$2 ~ /^infera-ci-/ && substr($2, length($2)-length(suf)+1)==suf {print $1}' \ | tr '\n' ' ') fi @@ -118,8 +125,13 @@ _cancel_dispatched() { for i in 1 2 3 4 5; do scancel $jids >/dev/null 2>&1 || true sleep 2 - [ -z "$(squeue -h -j "$csv" -o '%i' 2>/dev/null)" ] && return 0 + # Only a query that answered may confirm the cancel: on Spur a gone job still + # exits 0 with no output. (Stock SLURM errors on an invalid id, so there this + # never confirms and the warning below is a false alarm -- the workflow's + # reclaim step is the backstop either way.) + left=$(squeue -h -j "$csv" -o '%i' 2>&1) && [ -z "$left" ] && return 0 done + echo "[cleanup] could not confirm the cancel of $jids: ${left:-no output}" >&2 } # Nodes the running PD-disagg attempt placed containers on. A killed run skips # pytest's teardown, so without this a cancel leaves prefill+decode on the GPUs. @@ -195,11 +207,37 @@ QOS_WAIT="${INFERA_E2E_QOS_WAIT:-30}" # than a single-node srun, and giving up early only churns the pair-hold race. HOLD_WAIT="${INFERA_E2E_HOLD_WAIT:-60}" +# A tier that could not run is not a tier that passed: returning 0 here is how a +# runner whose python3 lacked pytest turned every e2e-disag leg green in 7s. Fail +# and name the cause; a dev box that really has no SLURM opts out explicitly. +# $1=label $2=what is wrong $3=how to fix it +_SKIPPED_TIERS="" +_skip_or_fail() { + local label="$1" why="$2" fix="$3" + if [ "${INFERA_E2E_ALLOW_SKIP:-}" = 1 ]; then + _SKIPPED_TIERS="${_SKIPPED_TIERS:+$_SKIPPED_TIERS, }$label" + echo "[$label] SKIPPED (INFERA_E2E_ALLOW_SKIP=1): $why" >&2 + return 0 + fi + echo "[$label] FATAL: $why" >&2 + echo "[$label] fix: $fix" >&2 + echo "[$label] (or INFERA_E2E_ALLOW_SKIP=1 to skip this tier instead of failing)" >&2 + return 1 +} + _have_slurm() { command -v srun >/dev/null 2>&1; } -# The nodes reservation $1 covers, one per line ('' if it is gone/expired). -# Spur ignores the NAME arg and dumps all reservations; match the exact block. +# The nodes reservation $1 covers, one per line. Non-zero means the QUERY failed; +# exit 0 with no output means the reservation genuinely is not there. Spur keeps +# the two separable: it ignores the NAME arg and dumps every reservation, so a +# missing name still exits 0 and the awk below simply matches nothing. +# Capture before parsing: under pipefail a later stage's status would otherwise +# masquerade as a failed query, and callers now act on that distinction. _reservation_nodes() { - scontrol show reservation "$1" 2>/dev/null | awk -v r="ReservationName=$1" ' + local out + # Forward scontrol's own words: callers can only say "cannot reach the + # scheduler", which is not enough to act on. + out=$(scontrol show reservation "$1" 2>&1) || { printf '%s\n' "$out" >&2; return 1; } + printf '%s\n' "$out" | awk -v r="ReservationName=$1" ' BEGIN{RS="";FS="\n"} $1==r { for(i=1;i<=NF;i++) if($i ~ /Nodes=/){ n=$i; sub(/.*Nodes=/,"",n); sub(/[[:space:]].*/,"",n); print n; exit } }' \ | tr ',' '\n' | sed '/^$/d' @@ -217,7 +255,11 @@ _node_free() { # partition's idle ones. _candidate_nodes() { local n nodes="" - [ -n "${INFERA_E2E_RESERVATION:-}" ] && nodes="$(_reservation_nodes "$INFERA_E2E_RESERVATION")" + if [ -n "${INFERA_E2E_RESERVATION:-}" ]; then + # Query failed: offer nothing and let the caller keep waiting. Falling + # through would hand the PD pair unreserved nodes off the open partition. + nodes=$(_reservation_nodes "$INFERA_E2E_RESERVATION") || return 0 + fi if [ -z "$nodes" ]; then sinfo -h -N -p "$SLURM_PART" -t idle -o '%n' 2>/dev/null | awk 'NF && !seen[$0]++' return @@ -263,6 +305,9 @@ _rival_holder() { # Hold both PD nodes' GPUs for the whole run: disagg's per-step sruns leave them # idle in between, so SLURM would hand one out and the fixed ports (etcd 2379, # router 8000, ...) collide. Our own no-gres steps co-schedule. Sets _HOLDER_JID. +# 0 = held, 1 = another holder won the pair, 2 = SLURM never placed the hold. +# The caller reports 1 and 2 differently: they used to read alike, so a refused +# sbatch was announced as a lost race and pointed triage away from the scheduler. _hold_pair() { local pair="$1" script="$SCRATCH/hold.sh" jid st rs waited qos=() i other # A real script file, not --wrap: on Spur --wrap always NODE_FAILs at -N2. @@ -295,7 +340,7 @@ _hold_pair() { scancel "$jid" >/dev/null 2>&1 echo "[e2e disagg] hold attempt $i on $pair not started (${st:-?}/${rs:-?}) — retrying" >&2 done - return 1 + return 2 } # One renderD* per GPU; PCI vendor 0x1002 == AMD. _amd_gpu_count() { @@ -310,11 +355,11 @@ _amd_gpu_count() { _local_eligible() { [ "$(_amd_gpu_count)" -ge 8 ] && command -v docker >/dev/null 2>&1; } # Spill helper (Spur has no srun --immediate): free count, -1 if the reservation -# is gone/expired, -2 if scontrol is unavailable. +# is gone/expired, -2 if scontrol is unavailable, -3 if the query itself failed. _reservation_free() { local rname="$1" nodes n free=0 command -v scontrol >/dev/null 2>&1 || { echo -2; return; } - nodes=$(_reservation_nodes "$rname") + nodes=$(_reservation_nodes "$rname") || { echo -3; return; } [ -n "$nodes" ] || { echo -1; return; } for n in $nodes; do _node_free "$n" && free=$((free + 1)) @@ -322,8 +367,12 @@ _reservation_free() { echo "$free" } # Caps borrowed nodes at INFERA_E2E_SPILL_MAX; concurrent dispatchers can race it. +# Non-zero if the query failed, so the caller does not read that as "none in +# flight" and borrow past the cap exactly when the scheduler is already unwell. _spill_inflight() { - squeue -h -u "$(id -un)" -o '%j' 2>/dev/null | grep -c -- 'spill' || true + local out + out=$(squeue -h -u "$(id -un)" -o '%j' 2>&1) || { printf '%s\n' "$out" >&2; return 1; } + printf '%s\n' "$out" | grep -c -- 'spill' || true } # Report why the dispatch is still queued (a waiting job prints NOTHING, so a CI @@ -358,8 +407,10 @@ _watch_job() { _dispatch_slurm() { local label="$1"; shift if ! _have_slurm; then - echo "[$label] WARNING: no SLURM (srun) — skipping" >&2 - return 0 + _skip_or_fail "$label" \ + "no SLURM: srun is not on PATH, so this tier cannot be dispatched to a GPU node" \ + "expose the SLURM client on this host, or run where docker + >=8 AMD GPUs are present" + return $? fi # srun's own client banners/errors (job id, "running on ", ...). local out="$SCRATCH/.dispatch-$label.out" @@ -393,14 +444,16 @@ _dispatch_slurm() { rfree=$(_reservation_free "$INFERA_E2E_RESERVATION") smax="${INFERA_E2E_SPILL_MAX:-2}" if [ "$rfree" = "-1" ]; then - echo "[$label] WARNING: reservation '$INFERA_E2E_RESERVATION' not found — falling back to open partition '$SLURM_PART'" >&2 + echo "[$label] WARNING: reservation '$INFERA_E2E_RESERVATION' does not exist — falling back to open partition '$SLURM_PART'" >&2 mode="resv-gone->open" elif [ "$rfree" != "0" ]; then - # free>0, or -2 (no scontrol): use the reservation. + # free>0, or -2/-3 (cannot tell): keep the reservation. Only a query that + # answered may drop it -- reading a controller blink as "gone" is what + # sent a whole run to the open partition on 2026-08-06. resv=(--reservation="$INFERA_E2E_RESERVATION"); mode="resv" else - inflight=$(_spill_inflight) - if [ "$smax" -gt 0 ] && [ "$inflight" -lt "$smax" ]; then + # A failed count must not authorise a spill: queue on the reservation. + if inflight=$(_spill_inflight) && [ "$smax" -gt 0 ] && [ "$inflight" -lt "$smax" ]; then # spill marker sits before the run_id-engine suffix so ci.yml reclaim matches. jobname="infera-ci-${label}-spill${INFERA_E2E_JOB_TAG:+-$INFERA_E2E_JOB_TAG}" mode="spill($((inflight + 1))/$smax)" @@ -515,7 +568,15 @@ run_engine() { cd /workspace PYT="python3 -m pytest -p no:cacheprovider -o addopts= -q -rfE" rc=0 - for f in $(find "$INFERA_TEST_SCOPE" -name "test_*.py" | sort); do + # A scope that matches nothing iterates zero times and exits 0, so a rename + # or a typo in engine_tier would report PASS having tested nothing. Capture + # before sorting: through a pipe, find's own exit code would be sort's 0. + if ! files=$(find "$INFERA_TEST_SCOPE" -name "test_*.py") || [ -z "$files" ]; then + echo "[engine $INFERA_TEST_SCOPE] FATAL: scope unreadable or holds no test_*.py" >&2 + echo "[engine $INFERA_TEST_SCOPE] scope unreadable or holds no test_*.py" >> /scratch/failures.txt + exit 1 + fi + for f in $(printf "%s\n" "$files" | sort); do echo "----- pytest $f -----" # tee: stream live for CI, keep a copy for the classification below. $PYT "$f" 2>&1 | stdbuf -oL tee /scratch/.engine_f.out; code=${PIPESTATUS[0]} @@ -524,7 +585,11 @@ run_engine() { 139|134|137) line="CRASH(exit=$code)"; rc=1 echo "[engine $INFERA_TEST_SCOPE] CRASH(exit=$code) $f" >> /scratch/failures.txt ;; 0) line=$(printf "%s" "$out" | grep -E "passed|failed|skipped|no tests ran" | tail -1) ;; - 5) line="no tests ran (whole file skipped — not a failure)" ;; + # Nothing collected. Each guarded file importorskips a module its own + # image ships, so a 5 means the image is broken — exactly when this must + # go red. Say so plainly: pytest words it "1 skipped", which reads benign. + 5) line="FAIL: no tests collected (exit=5)"; rc=1 + echo "[engine $INFERA_TEST_SCOPE] $f (exit=5, no tests collected)" >> /scratch/failures.txt ;; *) line=$(printf "%s" "$out" | grep -E "passed|failed|error|skipped" | tail -1) [ -z "$line" ] && line="(exit=$code)"; rc=1 fails=$(printf "%s\n" "$out" | grep -aE "^(FAILED|ERROR) ") @@ -603,26 +668,41 @@ run_e2e_disagg() { local engines=("$@") echo "===== e2e PD-disaggregated (cross-node, 2 nodes): ${engines[*]} =====" if ! _have_slurm; then - echo "[e2e disagg] WARNING: no SLURM (srun) — skipping PD-disaggregated tests" >&2 - return 0 + _skip_or_fail "e2e disagg" \ + "no SLURM: srun is not on PATH, so the PD-disaggregated tests cannot run" \ + "expose the SLURM client on this host" + return $? + fi + # Name the interpreter actually consulted and quote its ImportError: "missing + # host deps" is true of every python3 on the box, and sent the last triage wrong. + local deps_err + if ! deps_err=$(python3 -c "import pytest, pytest_asyncio, httpx" 2>&1); then + _skip_or_fail "e2e disagg" \ + "the disagg orchestrator runs pytest on THIS host, and $(command -v python3 || echo 'python3 (not on PATH)') cannot import its deps: ${deps_err##*$'\n'}" \ + "pip install pytest pytest-asyncio httpx" + return $? fi - python3 -c "import pytest, pytest_asyncio, httpx" >/dev/null 2>&1 \ - || { echo "[e2e disagg] WARNING: missing host deps (pytest/pytest-asyncio/httpx) — skipping" >&2; return 0; } if [ -n "$SHARED_LOG_DIR" ]; then exec > >(stdbuf -oL tee -a "$SHARED_LOG_DIR/dispatch-disag-$$.log") 2>&1 fi # An expired reservation is worse than none — every step's `srun --reservation` - # would fail. Drop it, as _dispatch_slurm does for the mixed tier. - if [ -n "${INFERA_E2E_RESERVATION:-}" ] && [ -z "$(_reservation_nodes "$INFERA_E2E_RESERVATION")" ]; then - echo "[e2e disagg] WARNING: reservation '$INFERA_E2E_RESERVATION' not found — falling back to open partition '$SLURM_PART'" >&2 - unset INFERA_E2E_RESERVATION + # would fail. Drop it, as _dispatch_slurm does for the mixed tier, but only on + # a query that answered: a failed one says nothing about the pool. + local resv_nodes + if [ -n "${INFERA_E2E_RESERVATION:-}" ]; then + if ! resv_nodes=$(_reservation_nodes "$INFERA_E2E_RESERVATION"); then + echo "[e2e disagg] WARNING: cannot reach the scheduler to check reservation '$INFERA_E2E_RESERVATION' — keeping it" >&2 + elif [ -z "$resv_nodes" ]; then + echo "[e2e disagg] WARNING: reservation '$INFERA_E2E_RESERVATION' does not exist — falling back to open partition '$SLURM_PART'" >&2 + unset INFERA_E2E_RESERVATION + fi fi local rc=0 e prc out="$SCRATCH/.e2e-disag.out" local max_attempts=3 attempt exclude n1 n2 nodes ok - local races max_races="${INFERA_E2E_HOLD_RACE_MAX:-10}" + local races max_races="${INFERA_E2E_HOLD_RACE_MAX:-10}" hold_rc for e in "${engines[@]}"; do echo "----- e2e disagg — tests/e2e/pd_disag/$e -----" attempt=0; ok=0; exclude=""; races=0 @@ -643,13 +723,19 @@ run_e2e_disagg() { # Losing the race is not a node fault, so the pair must NOT join $exclude: # with a small pool the engine would exclude every node and then starve on # an idle cluster. Bounded so a pathological loser fails loudly instead. - if ! _hold_pair "$n1,$n2"; then + # Not `if ! _hold_pair`: inside that, $? is the negation's, not the call's. + _hold_pair "$n1,$n2"; hold_rc=$? + if [ "$hold_rc" -ne 0 ]; then races=$((races + 1)) if [ "$races" -ge "$max_races" ]; then - echo "[e2e disagg] lost the node-hold race $races times — giving up on $e" >&2 + if [ "$hold_rc" -eq 2 ]; then + echo "[e2e disagg] SLURM never placed a node hold in $races attempts — giving up on $e" >&2 + else + echo "[e2e disagg] lost the node-hold race $races times — giving up on $e" >&2 + fi break fi - echo "[e2e disagg] could not hold $n1,$n2 (race $races/$max_races) — re-picking in 30s" >&2 + echo "[e2e disagg] could not hold $n1,$n2 (attempt $races/$max_races) — re-picking in 30s" >&2 attempt=$((attempt - 1)); sleep 30; continue fi races=0 @@ -684,10 +770,15 @@ run_e2e_disagg() { # Report-only: both tiers can still run (degraded) without a reservation or with # a nearly full /home, and a hard exit here would cost a whole CI run to find out. _e2e_preflight() { - local avail - if [ -n "${INFERA_E2E_RESERVATION:-}" ] && command -v scontrol >/dev/null 2>&1 \ - && [ -z "$(_reservation_nodes "$INFERA_E2E_RESERVATION")" ]; then - echo "[e2e] ERROR: reservation '$INFERA_E2E_RESERVATION' does not exist (gone or expired)" >&2 + local avail resv_nodes + if [ -n "${INFERA_E2E_RESERVATION:-}" ] && command -v scontrol >/dev/null 2>&1; then + if ! resv_nodes=$(_reservation_nodes "$INFERA_E2E_RESERVATION"); then + # This line used to say "does not exist" for an unreachable controller too, + # which sent triage looking for a deleted reservation. + echo "[e2e] ERROR: cannot reach the scheduler to check reservation '$INFERA_E2E_RESERVATION' (scontrol failed)" >&2 + elif [ -z "$resv_nodes" ]; then + echo "[e2e] ERROR: reservation '$INFERA_E2E_RESERVATION' does not exist (gone or expired)" >&2 + fi fi avail=$(df -Pk /home 2>/dev/null | awk 'NR==2{print $4}') case "$avail" in @@ -777,5 +868,11 @@ if [ -d "$E2E_LOG_DIR" ]; then ls -1 "$E2E_LOG_DIR"/*.log 2>/dev/null | sed 's|^| |' || true fi -[ "$rc" -eq 0 ] && echo "RESULT: PASS" || echo "RESULT: FAIL" +if [ "$rc" -ne 0 ]; then + echo "RESULT: FAIL" +elif [ -n "$_SKIPPED_TIERS" ]; then + echo "RESULT: PASS (SKIPPED: $_SKIPPED_TIERS)" +else + echo "RESULT: PASS" +fi exit "$rc"