blob: 519a7ee8d94b0a81f53a24593538bfcc981943d8 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved
#
# Verify swait's steps-drained push path (REQUEST_STEPS_DRAINED_SUBSCRIBE
# subscribe + SRUN_STEPS_DRAINED push from stepmgr).
############################################################################
import re
import pytest
import atf
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 5),
reason="swait push notifications were added in 26.05",
)
atf.require_version(
(26, 5),
component="sbin/slurmd",
reason="REQUEST_STEPS_DRAINED_SUBSCRIBE relay lives in slurmd 26.05",
)
atf.require_version(
(26, 5),
component="bin/swait",
reason="swait push client was added in 26.05",
)
atf.require_nodes(1)
atf.require_config_parameter_includes("SlurmctldParameters", "enable_stepmgr")
atf.require_config_parameter_includes("PrologFlags", "Contain")
atf.require_slurm_running()
# 26.11 renumbered swait's exit codes: an error moved 2 -> 1 and --timeout
# expiry 1 -> 2. This module runs against clients of both vintages, so name
# the two codes rather than asserting a bare number.
RC_ERROR, RC_TIMEOUT = (1, 2) if atf.get_version("bin/swait") >= (26, 11) else (2, 1)
# Long enough that swait subscribes while the step is still running.
LATENCY_SLEEP_SECS = 6
# Catches latency regressions in the SRUN_STEPS_DRAINED wake path.
LATENCY_CEILING_SECS = LATENCY_SLEEP_SECS + 8
# Catches instant-return regressions and partial-wake regressions where
# swait wakes on an intermediate event before all steps have drained.
# Margin tolerates swait startup + subscribe handshake on loaded runners.
LATENCY_FLOOR_SECS = LATENCY_SLEEP_SECS - 3
def _resolve_array_task_id(master_id, task_offset, timeout=60):
"""Poll until the per-task job id for master_id_task_offset is
observable in squeue. See test_162_1 for the rationale.
"""
# Poll every second rather than atf.timer()'s default of timeout/10.
# Callers race a step that only runs for LATENCY_SLEEP_SECS, so a
# coarse interval spends the whole wait window here and leaves swait
# nothing to block on.
job_id = 0
for _ in atf.timer(timeout=timeout, poll_interval=1, quiet=True, fatal=True):
job_id = atf.get_job_id_from_array_task(master_id, task_offset)
if job_id:
break
return job_id
def _swait_push(swait_args, sleep_secs, time_limit="5:00", xfail=False):
"""Submit a job with one user step, then run swait on the env fast path.
Returns (exit_code, elapsed_seconds, job_id, stdout). Setting SLURM_STEPMGR forces
swait to take the push/fast path instead of the ctld discovery
path; for a 1-node stepmgr job the stepmgr is the batch host.
IN xfail - True for callers that expect swait to exit nonzero
(e.g., --timeout firing).
"""
job_id = atf.submit_job_sbatch(
f"-N1 --time={time_limit} --job-name=test_162_2 "
f"--wrap 'srun -n1 sleep {sleep_secs}'",
fatal=True,
)
atf.wait_for_step(job_id, 0, timeout=60, fatal=True)
stepmgr = atf.get_job_parameter(job_id, "BatchHost")
result = atf.run_command(
f"swait {swait_args} {job_id}",
env_vars=f"SLURM_STEPMGR={stepmgr}",
timeout=180,
xfail=xfail,
)
return result["exit_code"], result["duration"], job_id, result["stdout"]
@pytest.mark.parametrize("quiet", ["", "-Q"])
def test_push_latency_user_step(quiet):
"""swait exits within a few seconds of the user step ending, printing
the whole-set drain line unless -Q suppresses it."""
rc, elapsed, job_id, stdout = _swait_push(quiet, LATENCY_SLEEP_SECS)
assert rc == 0, f"swait exited {rc}, expected 0"
assert (
elapsed >= LATENCY_FLOOR_SECS
), f"swait returned in {elapsed:.1f}s; expected to wait for step end"
assert (
elapsed < LATENCY_CEILING_SECS
), f"swait took {elapsed:.1f}s; push path appears broken"
if quiet:
assert stdout == "", f"-Q must suppress the drain line; stdout: {stdout!r}"
elif atf.get_version("bin/swait") >= (26, 11):
# Before 26.11 swait printed nothing at all, so only the wait
# itself can be asserted there.
assert (
f"JobId={job_id} steps drained" in stdout
), f"missing drain summary line; stdout: {stdout!r}"
def test_timeout_granularity_short():
"""--timeout fires at the user's deadline within conmgr's timer granularity."""
TIMEOUT_SECS = 5
rc, elapsed, _, _ = _swait_push(
f"--timeout {TIMEOUT_SECS}", sleep_secs=120, xfail=True
)
assert rc == RC_TIMEOUT, f"swait exited {rc}, expected {RC_TIMEOUT} for --timeout"
# Ceiling absorbs swait startup + conmgr init + subscribe RPC +
# the timer + shutdown on loaded runners; match LATENCY_CEILING_SECS
# budget (+8) to be consistent with other timing-bound tests.
assert TIMEOUT_SECS - 1 <= elapsed < TIMEOUT_SECS + 8, (
f"swait --timeout={TIMEOUT_SECS} took {elapsed:.1f}s; expected "
"deadline-granularity exit"
)
@pytest.mark.parametrize("follow", ["", "--follow"])
def test_already_drained_returns_zero(follow):
"""Subscribing to a job with no regular steps (only the batch
step) returns ESLURM_STEPS_DRAINED; swait fast-returns 0. ALL mode
(--follow) takes the same whole-set check as DRAIN.
"""
if follow:
atf.require_version(
(26, 11),
component="bin/swait",
reason="Issue 50928: --follow was added in 26.11",
)
# Long-lived: the job must outlast the check, or swait returns because the
# batch step ended rather than because it fast-returned.
job_id = atf.submit_job_sbatch(
"-N1 --time=5:00 --job-name=test_162_2_drained --wrap 'sleep infinity'",
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", timeout=60, fatal=True)
stepmgr = atf.get_job_parameter(job_id, "BatchHost")
result = atf.run_command(
f"swait {follow} {job_id}",
env_vars=f"SLURM_STEPMGR={stepmgr}",
# Fail fast: the stepmgr must fast-return here, so a run that takes
# anywhere near this long has already failed the test.
timeout=30,
)
assert (
result["exit_code"] == 0
), f"swait exited {result['exit_code']}; stderr: {result['stderr']!r}"
assert (
result["duration"] < 10
), f"swait took {result['duration']:.1f}s; expected immediate fast-return"
assert result["stdout"] == "", f"unexpected output: {result['stdout']!r}"
def test_pending_async_step_blocks_swait():
"""A pending async step keeps swait blocked until that step also
drains. A queued sync step blocks it too (see
test_queued_regular_step_blocks).
A regular srun holds the 1 CPU; an async srun is submitted while
the regular step runs and ends up pending in stepmgr. swait should
wait through both steps, exiting only after the async step drains.
"""
REG_SECS = 3
ASYNC_SECS = 4
# Trailing sleep keeps the batch script alive past the async step's
# finish so the async record drains naturally instead of being
# signal-killed during job teardown.
job_id = atf.submit_job_sbatch(
"-N1 --time=5:00 --job-name=test_162_2_async_pending "
f"--wrap 'srun -n1 sleep {REG_SECS} & sleep 0.5; "
f"srun --async -n1 sleep {ASYNC_SECS}; wait; "
f"sleep {ASYNC_SECS + 2}'",
fatal=True,
)
atf.wait_for_step(job_id, 0, timeout=60, fatal=True)
stepmgr = atf.get_job_parameter(job_id, "BatchHost")
result = atf.run_command(
f"swait {job_id}",
env_vars=f"SLURM_STEPMGR={stepmgr}",
timeout=REG_SECS + ASYNC_SECS + 30,
)
assert (
result["exit_code"] == 0
), f"swait exited {result['exit_code']}; stderr: {result['stderr']!r}"
# Floor must stay much smaller than ASYNC_SECS so a swait that wakes
# on regular-step end (missing the pending async) doesn't slip past.
assert result["duration"] >= REG_SECS + ASYNC_SECS - 1, (
f"swait returned in {result['duration']:.1f}s; expected "
f">= {REG_SECS + ASYNC_SECS}s (pending async should keep swait blocked)"
)
@pytest.mark.skipif(
atf.get_version("bin/srun") < (26, 11),
reason="Issue 50938: a queued sync step is only created at wake, and so only"
" blocks a wait, for a 26.11+ srun",
)
def test_queued_regular_step_completion_drains():
"""Mirror of test_queued_regular_step_blocks with a short queued step
and no --timeout: once the queued sync step runs and ends, the whole
step set has drained and swait must return 0 (not linger, not return
early). The step1-done marker is written before step 1 exits, so an
swait that returned before step 1 ended would see it missing.
"""
STEP0_SECS = 10
STEP1_SECS = 10
script = "queued_regular_drain.sh"
started = "drain.step0.started"
done0 = "drain.step0.done"
done1 = "drain.step1.done"
out_file = "queued_regular_drain.out"
atf.make_bash_script(
script,
f"""srun -n"$SLURM_CPUS_ON_NODE" bash -c 'touch {started}; sleep {STEP0_SECS}; touch {done0}' &
until [ -e {started} ]; do sleep 0.1; done
srun -n1 bash -c 'sleep {STEP1_SECS}; touch {done1}' &
for _ in $(seq 1 300); do
scontrol -o show step $SLURM_JOB_ID 2>/dev/null | grep -q 'State=PENDING' && break
sleep 0.2
done
scontrol -o show step $SLURM_JOB_ID 2>/dev/null | grep -q 'State=PENDING' || {{ echo rc=step-not-pending > {out_file}; exit 1; }}
swait
rc=$?
[ -e {done1} ] && step1=done || step1=running
echo "rc=$rc step1=$step1" > {out_file}
""",
)
atf.submit_job_sbatch(
f"-N1 --exclusive --time=5:00 --job-name=test_162_2_reg_drain {script}",
fatal=True,
)
# Poll the content, not just the file: it may show up before the echo
# lands in it. A wedged wait never writes it and fails here.
match = None
for _ in atf.timer(timeout=STEP0_SECS + STEP1_SECS + 180, quiet=True):
content = atf.run_command_output(f"cat {out_file}", quiet=True)
match = re.search(r"rc=(\S+)(?: step1=(\w+))?", content)
if match:
break
assert match, "the batch script should report swait's result, but it did not"
assert match.group(1) != "step-not-pending", (
"step 1 never queued behind the allocation-wide blocker, "
"so the queued-step premise did not hold"
)
rc, step1 = int(match.group(1)), match.group(2)
assert rc == 0, (
f"swait should return 0 once the queued sync step ran and ended, "
f"but returned rc={rc}"
)
assert (
step1 == "done"
), "swait returned before the queued step ended, but the set only drains after it"
@pytest.mark.skipif(
atf.get_version("bin/srun") < (26, 11),
reason="Issue 50938: a queued sync step is only created at wake, and so only"
" blocks a wait, for a 26.11+ srun",
)
def test_queued_regular_step_blocks():
"""Step 0 takes every CPU in an exclusive allocation, so the second
srun queues behind it as a pending placeholder. That placeholder is
created at wake and pushed by the stepmgr, so it is work the job still
owes and swait must block on it: swait returns only after the second
step has also run and ended. The script gates on step 1 reaching
PENDING, so a node shape that lets the two steps run concurrently
fails loudly instead of passing without exercising the queue.
The check is ordering-based, not timing-based, and runs inside the
job on the node that writes the markers, so a loaded runner or a
shared filesystem's caching cannot produce a false failure.
"""
# Step 0 holds every CPU long enough for swait to subscribe while it
# runs, even on a loaded runner.
STEP0_SECS = 10
# Far beyond swait's --timeout: a swait blocked on the queued step times
# out instead of returning 0.
STEP1_SECS = 120
SWAIT_TIMEOUT = STEP0_SECS + 60
script = "queued_regular.sh"
started = "step0.started"
done = "step0.done"
out_file = "queued_regular.out"
# Step 0 creates both markers itself, so "done" exists before step 0
# ends and therefore before swait can return. The second srun is
# launched only once step 0 runs; with every CPU held, it must queue
# behind it, and the PENDING gate proves that it did.
atf.make_bash_script(
script,
f"""srun -n"$SLURM_CPUS_ON_NODE" bash -c 'touch {started}; sleep {STEP0_SECS}; touch {done}' &
until [ -e {started} ]; do sleep 0.1; done
srun -n1 sleep {STEP1_SECS} &
for _ in $(seq 1 300); do
scontrol -o show step $SLURM_JOB_ID 2>/dev/null | grep -q 'State=PENDING' && break
sleep 0.2
done
scontrol -o show step $SLURM_JOB_ID 2>/dev/null | grep -q 'State=PENDING' || {{ echo rc=step-not-pending > {out_file}; exit 1; }}
swait --timeout {SWAIT_TIMEOUT}
rc=$?
[ -e {done} ] && step0=done || step0=running
echo "rc=$rc step0=$step0" > {out_file}
""",
)
atf.submit_job_sbatch(
f"-N1 --exclusive --time=5:00 --job-name=test_162_2_reg_queue {script}",
fatal=True,
)
# Poll the content, not just the file: it may show up before the echo
# lands in it.
match = None
for _ in atf.timer(timeout=SWAIT_TIMEOUT + 60, quiet=True):
content = atf.run_command_output(f"cat {out_file}", quiet=True)
match = re.search(r"rc=(\S+)(?: step0=(\w+))?", content)
if match:
break
assert match, "the batch script should report swait's result, but it did not"
assert match.group(1) != "step-not-pending", (
"step 1 never queued behind the allocation-wide blocker, "
"so the queued-step premise did not hold"
)
rc, step0 = int(match.group(1)), match.group(2)
assert rc == RC_TIMEOUT, (
f"swait should block on the queued step until its own timeout, "
f"but returned rc={rc} (queued sync step should keep swait blocked)"
)
assert (
step0 == "done"
), "step 0 should drain long before swait's timeout, but it was still running"
def test_push_via_sluid_env():
"""swait s<sluid> with SLURM_JOB_SLUID + SLURM_STEPMGR set takes
the SLUID branch of _resolve_stepmgr (sibling to the numeric jobid
fast path in test_push_latency_user_step).
"""
job_id = atf.submit_job_sbatch(
"-N1 --time=5:00 --job-name=test_162_2_sluid "
f"--wrap 'srun -n1 sleep {LATENCY_SLEEP_SECS}'",
fatal=True,
)
atf.wait_for_step(job_id, 0, timeout=60, fatal=True)
sluid = atf.get_job_parameter(job_id, "SLUID")
stepmgr = atf.get_job_parameter(job_id, "BatchHost")
result = atf.run_command(
f"swait {sluid}",
env_vars=f"SLURM_JOB_SLUID={sluid} SLURM_STEPMGR={stepmgr}",
timeout=180,
)
assert (
result["exit_code"] == 0
), f"swait exited {result['exit_code']}; stderr: {result['stderr']!r}"
elapsed = result["duration"]
assert (
elapsed >= LATENCY_FLOOR_SECS
), f"swait returned in {elapsed:.1f}s; expected to wait for step end"
assert (
elapsed < LATENCY_CEILING_SECS
), f"swait took {elapsed:.1f}s; push path appears broken"
def test_array_task_env_fast_path():
"""Array task with SLURM_ARRAY_JOB_ID/TASK_ID + SLURM_JOB_ID +
SLURM_STEPMGR set takes the _array_task_env_stepmgr fast path.
"""
master_id = atf.submit_job_sbatch(
"--array=0-1 -N1 --time=5:00 --job-name=test_162_2_array "
f"--wrap 'srun -n1 sleep {LATENCY_SLEEP_SECS}'",
fatal=True,
)
task0_id = _resolve_array_task_id(master_id, 0)
atf.wait_for_step(f"{master_id}_0", 0, timeout=60, fatal=True)
stepmgr = atf.get_job_parameter(task0_id, "BatchHost")
result = atf.run_command(
f"swait {master_id}_0",
env_vars=(
f"SLURM_ARRAY_JOB_ID={master_id} SLURM_ARRAY_TASK_ID=0 "
f"SLURM_JOB_ID={task0_id} SLURM_STEPMGR={stepmgr}"
),
timeout=180,
)
assert (
result["exit_code"] == 0
), f"swait exited {result['exit_code']}; stderr: {result['stderr']!r}"
assert (
result["duration"] >= LATENCY_FLOOR_SECS
), f"swait returned in {result['duration']:.1f}s; expected to wait"
def test_unreachable_stepmgr_errors():
"""swait exits RC_ERROR when the stepmgr host cannot be resolved/reached.
Exercises the _setup_push failure path: an unresolvable
SLURM_STEPMGR forces slurm_send_recv_node_msg() to fail before any
subscribe RPC is even attempted. RC_TIMEOUT is reserved for --timeout;
any other runtime/network failure exits RC_ERROR.
"""
# Below MAX_VAL (0xfffffff0) so swait's env fast-path is not
# rejected by the parsed-jobid sanity check.
BOGUS_JOBID = 4000000000
BOGUS_HOST = "no-such-host.invalid"
result = atf.run_command(
f"swait {BOGUS_JOBID}",
env_vars=f"SLURM_JOB_ID={BOGUS_JOBID} SLURM_STEPMGR={BOGUS_HOST}",
xfail=True,
# Fail fast: this must be rejected outright, not waited on.
timeout=30,
)
assert (
result["exit_code"] == RC_ERROR
), f"swait exited {result['exit_code']}, expected {RC_ERROR} (stderr: {result['stderr']!r})"
assert (
"subscribe to stepmgr" in result["stderr"]
), f"unexpected stderr: {result['stderr']!r}"
def test_stepmgr_env_mismatch_falls_back_to_ctld():
"""A SLURM_STEPMGR that describes a different job is discarded.
swait(1) uses the env fast path only when the target matches the job the
environment describes; otherwise it looks the stepmgr up through the
controller. Point SLURM_STEPMGR at an unreachable host while naming a
different job, so taking the fast path would exit 1.
"""
BOGUS_JOBID = 4000000000
BOGUS_HOST = "no-such-host.invalid"
job_id = atf.submit_job_sbatch(
# Long-lived: the job must outlast the check, or the ctld lookup
# fails on a job that is already gone rather than on the fallback.
"-N1 --time=5:00 --job-name=test_162_2_mismatch --wrap 'sleep infinity'",
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", timeout=60, fatal=True)
result = atf.run_command(
f"swait {job_id} --timeout 60",
env_vars=f"SLURM_JOB_ID={BOGUS_JOBID} SLURM_STEPMGR={BOGUS_HOST}",
timeout=120,
)
assert result["exit_code"] == 0, (
f"swait exited {result['exit_code']}; a mismatched SLURM_STEPMGR must "
f"be discarded in favour of the ctld lookup. stderr: {result['stderr']!r}"
)
def test_bare_swait_in_batch_script_waits_for_async_steps():
"""The documented idiom: two srun --async steps, then a bare swait.
swait(1) EXAMPLES leads with this and it is the reason the command
exists, but no test in the swait range asserted it. With no positional
argument swait resolves the enclosing job from SLURM_JOB_SLUID or
SLURM_JOB_ID, so it must block until both async steps drain.
"""
SHORT_SECS = 3
LONG_SECS = 12
out_file = "bare_swait.out"
# A single task: this module's fixture only asks for 1 node, so a -n2
# allocation never gets scheduled on the default 1-CPU node. --overlap
# plus --mem=0 lets both async steps share that one CPU.
# Time the call in the script. Reaching COMPLETED proves only that swait
# returned, not that it waited: a fast return would look identical. The
# elapsed line is what makes this test meaningful on every release. Use
# date(1), not bash's SECONDS: the wrap script runs under /bin/sh, which
# need not be bash.
job_id = atf.submit_job_sbatch(
"-N1 --time=5:00 --job-name=test_162_2_bare "
"--wrap '"
f"srun --async -n1 --mem=0 --overlap sleep {SHORT_SECS}; "
f"srun --async -n1 --mem=0 --overlap sleep {LONG_SECS}; "
f"s=$(date +%s); swait > {out_file} 2>&1; rc=$?; "
f'echo "elapsed=$(( $(date +%s) - s )) rc=$rc" >> {out_file}\'',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "COMPLETED", timeout=180, fatal=False
), "the batch script did not complete; bare swait may not have returned"
content = atf.run_command_output(f"cat {out_file}", fatal=True)
match = re.search(r"elapsed=(\d+) rc=(\d+)", content)
assert match, f"bare swait wrote no elapsed line: {content!r}"
elapsed, rc = int(match.group(1)), int(match.group(2))
assert rc == 0, f"bare swait exited {rc}: {content!r}"
# The second async step still has most of LONG_SECS to run when swait
# starts, so a swait that returns immediately fails here.
assert elapsed >= LONG_SECS - SHORT_SECS, (
f"bare swait returned after {elapsed}s; it must block until both "
f"async steps drain: {content!r}"
)
# Before 26.11 swait printed nothing at all, so only the wait itself
# can be asserted there.
if atf.get_version("bin/swait") >= (26, 11):
atf.assert_file_contents(
out_file,
f"JobId={job_id} steps drained",
contains=True,
message="bare swait did not report the whole-set drain",
)