blob: b3c50221849c68576a4de9c05e95f1a209cd9ca8 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Pending sync step launch under stepmgr (create-at-wake).
When a sync step cannot get its CPUs it pends; once a step completes the
stepmgr (the batch node's slurmstepd) creates the pending step under the
lock and pushes it to the waiting srun (create-at-wake), instead of poking
srun to re-send. Once the whole node is free, an earlier whole-node request
must reserve it before a later one-CPU request. This does not require strict
FIFO when an earlier request still cannot fit, or task completion order.
These tests run with enable_stepmgr so steps are stepmgr-managed. The FIFO
test is the primary acceptance signal: on the old poke-and-re-send model it
flaked (a later 1-CPU step beat an earlier whole-node step ~7/10 runs);
create-at-wake makes it deterministic, so it is asserted across repeated
rounds rather than marked xfail.
test_116_61 covers id-at-submit against the controller's step manager;
test_162_4 covers it under stepmgr. These tests add the stepmgr-managed
ordering contract.
"""
import pytest
import atf
pytestmark = pytest.mark.slow
# Enough CPUs that a 1-CPU step is distinguishable from a whole-node one.
# The scripts size their steps from $SLURM_CPUS_ON_NODE rather than assuming
# this exact width, so a wider node still works.
MIN_NODE_CPUS = 2
# Rounds of the FIFO scenario per job; each round must keep submission order.
FIFO_ROUNDS = 5
@pytest.fixture(scope="module", autouse=True)
def setup():
# Daemons are always >= every client command, so requiring srun >= 26.11
# already implies the stepmgr serving the create-at-wake is new enough.
atf.require_version(
(26, 11),
"bin/srun",
reason="Issue 50935: create-at-wake requires a 26.11 srun",
)
atf.require_nodes(1, [("CPUs", MIN_NODE_CPUS)])
atf.require_config_parameter("SelectType", "select/cons_tres")
# CR_CPU so steps are gated only on CPUs; memory must not confound the
# "resources busy" condition these tests rely on.
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
# Steps are managed by the batch node's slurmstepd (create-at-wake runs
# there); Contain is required for stepmgr.
atf.require_config_parameter_includes("SlurmctldParameters", "enable_stepmgr")
atf.require_config_parameter_includes("PrologFlags", "Contain")
atf.require_slurm_running()
def _run_steps(tag, body, expected):
"""Run body as a batch script owning one whole node.
IN tag - unique basename for this test's script and output files
IN body - bash body that launches srun steps inside the allocation
IN expected - markers that must all appear before the output is read
RET (job_id, lines): the batch job id and the combined stdout/stderr
lines in the order they were written
"""
script = f"{tag}.sh"
out = f"{tag}.out"
atf.make_bash_script(script, body)
job_id = atf.submit_job_sbatch(
f"-J {tag} -t5 -N1 --exclusive --output={out} --error={out} {script}",
fatal=True,
)
# FIFO_ROUNDS rounds of gated step launches exceed the default timeout.
atf.wait_for_job_state(job_id, "DONE", timeout=180, fatal=True)
# DONE is the controller's view; re-read until the stepd has flushed
# everything the assertions depend on.
lines = []
for _ in atf.timer(fatal=True):
lines = atf.run_command_output(f"cat {out}", fatal=True).splitlines()
if all(marker in "\n".join(lines) for marker in expected):
break
# A pending step must never surface as a duplicate: create-at-wake
# reconciles a lost-push re-send against the provisional step.
text = "\n".join(lines)
assert (
"ESLURM_DUPLICATE_STEP_ID" not in text and "Duplicate job step id" not in text
), "a pending step was rejected as a duplicate"
return job_id, lines
# ---------------------------------------------------------------------------
# Characterization: the stable, intended contract.
# ---------------------------------------------------------------------------
def test_blocked_step_launches_after_blocker_frees():
"""A step with no free CPUs pends, then launches once the blocking step
releases its CPUs."""
held = "blocked_held"
release = "blocked_release"
# A holds every CPU until released rather than for a fixed time, so B is
# guaranteed to be pending before A frees.
_, lines = _run_steps(
"blocked",
f"""srun -n"$SLURM_CPUS_ON_NODE" bash -c 'touch "{held}"; until [ -f "{release}" ]; do sleep 0.2; done; if [ "$SLURM_PROCID" -eq 0 ]; then echo A_END; fi' &
until [ -f '{held}' ]; do sleep 0.2; done
srun -n1 bash -c 'echo B_DONE' &
{atf.wait_for_step_count_snippet(2)}
touch '{release}'
wait""",
["A_END", "B_DONE"],
)
assert "A_END" in lines, "blocking step A did not finish"
assert "B_DONE" in lines, "pending step B never launched"
assert lines.index("B_DONE") > lines.index(
"A_END"
), "pending step B launched before its blocker freed CPUs"
def test_configuring_mark_is_cleared_once_srun_launches():
"""A step created at wake is not left marked CONFIGURING after srun
launches it.
A create-at-wake step holds its CPUs and ports before srun has launched
any task, and reports State=CONFIGURING for that window (documented in
job_state_codes.html). The window closes as soon as the push is
delivered, so rather than racing it this pins the invariant that matters:
a step whose tasks are running must no longer carry the provisional mark,
which would otherwise hide the base state and keep the reap paths
eligible to cancel a live step.
"""
held = "launching_held"
release = "launching_release"
running = "launching_b_running"
b_release = "launching_b_release"
steps = "launching_steps"
job_id, lines = _run_steps(
"launching",
f"""srun -n"$SLURM_CPUS_ON_NODE" bash -c 'touch "{held}"; until [ -f "{release}" ]; do sleep 0.2; done' &
until [ -f '{held}' ]; do sleep 0.2; done
srun -n"$SLURM_CPUS_ON_NODE" bash -c 'touch "{running}"; until [ -f "{b_release}" ]; do sleep 0.2; done; echo B_DONE' &
{atf.wait_for_step_count_snippet(2)}
touch '{release}'
# B is created at the wake and pushed to its srun. Once B's own task is
# running, srun has consumed that push, so the provisional mark must be gone
# while B still holds its resources.
until [ -f '{running}' ]; do sleep 0.2; done
scontrol -o show step $SLURM_JOB_ID > '{steps}' 2>&1
touch '{b_release}'
wait""",
["B_DONE"],
)
assert "B_DONE" in lines, "the pending step never launched"
# Re-poll for content before reading: the redirect creates the file empty.
atf.assert_file_contents(steps, "StepId=", contains=True)
steps_text = atf.run_command_output(f"cat {steps}", fatal=True)
assert "State=CONFIGURING" not in steps_text, (
"a step whose tasks are already running must not still be marked "
f"CONFIGURING, got: {steps_text!r}"
)
# The snapshot also lists .batch/.extern, which are RUNNING too, so look
# at B's own line: A took step 0 and B was assigned step 1 at submit.
b_line = next(
(line for line in steps_text.splitlines() if f"StepId={job_id}.1 " in line),
"",
)
assert (
"State=RUNNING" in b_line
), f"the launched step should report State=RUNNING, got: {steps_text!r}"
# ---------------------------------------------------------------------------
# Acceptance: create-at-wake launches pending steps in FIFO order.
# ---------------------------------------------------------------------------
def test_pending_steps_launch_in_fifo_order():
"""An earlier whole-node step gets freed CPUs before a later 1-CPU step.
Repeated across rounds: under create-at-wake the wake allocates the
earlier (larger) step atomically before the later (smaller) one can take
the CPUs, so BIG always precedes SMALL.
"""
held = "fifo_held"
release = "fifo_release"
body = ""
for i in range(FIFO_ROUNDS):
# A holds the whole node until released (not a fixed sleep), so BIG and
# SMALL are guaranteed to both be pending on it before it frees. BIG
# (whole node) is queued before SMALL (1 CPU); when A frees, BIG must
# win its CPUs over the later SMALL. Drain the previous round's steps
# first so the count gates have a clean baseline; .batch/.extern have
# non-numeric ids and never match. Both sentinels are cleared before A
# starts, so a late task from the previous round cannot pre-satisfy
# this round's gate.
body += f"""{atf.wait_for_step_count_snippet(0, op="-eq")}
rm -f '{held}' '{release}'
srun -n"$SLURM_CPUS_ON_NODE" bash -c 'touch "{held}"; until [ -f "{release}" ]; do sleep 0.2; done' &
until [ -f '{held}' ]; do sleep 0.2; done
srun -n"$SLURM_CPUS_ON_NODE" bash -c 'echo BIG_{i}' &
# Wait for BIG to register (A + BIG = 2 steps) before submitting SMALL so
# SMALL is later in step_list, then wait for SMALL too (= 3 steps), so both
# are pending on A before A is released.
{atf.wait_for_step_count_snippet(2)}
srun --exact -n1 bash -c 'echo SMALL_{i}' &
{atf.wait_for_step_count_snippet(3)}
touch '{release}'
wait
"""
_, lines = _run_steps(
"fifo",
body,
[f"BIG_{i}" for i in range(FIFO_ROUNDS)]
+ [f"SMALL_{i}" for i in range(FIFO_ROUNDS)],
)
for i in range(FIFO_ROUNDS):
big, small = f"BIG_{i}", f"SMALL_{i}"
assert big in lines and small in lines, f"round {i}: both steps must run"
assert lines.index(big) < lines.index(
small
), f"round {i}: later 1-CPU step beat the earlier whole-node step"
# ---------------------------------------------------------------------------
# Robustness: a pending step's srun disappearing does not wedge the job.
# ---------------------------------------------------------------------------
def test_pending_srun_killed_does_not_wedge_job():
"""Killing a pending step's srun reclaims its CPUs via the push
connect-failure early-out (the only reclaim for a vanished srun).
The kill closes srun's listener, so the create-at-wake push connect is
refused and the stepmgr reaps the killed step's provisional record at
once, freeing its CPUs for the later step C. The blocker still finishes,
C still runs, and the job completes."""
held = "killed_held"
release = "killed_release"
_, lines = _run_steps(
"killed",
f"""srun -n"$SLURM_CPUS_ON_NODE" bash -c 'touch "{held}"; until [ -f "{release}" ]; do sleep 0.2; done; if [ "$SLURM_PROCID" -eq 0 ]; then echo A_END; fi' &
until [ -f '{held}' ]; do sleep 0.2; done
# B pends on the busy CPUs; kill its srun only once B is a registered
# pending step (A + B = 2 steps), so its provisional record exists when the
# wake pushes to the now-dead listener and fast path B is the only reclaim.
srun -n"$SLURM_CPUS_ON_NODE" bash -c 'echo B_RAN' &
bpid=$!
{atf.wait_for_step_count_snippet(2)}
kill -9 "$bpid" || exit 1
wait "$bpid" || true
# C is submitted after B exits and closes its listener.
# Gate on C being registered before releasing A, so A cannot free while C is
# still racing its own srun startup and C is genuinely pending on it.
srun -n1 bash -c 'echo C_DONE' &
{atf.wait_for_step_count_snippet(3)}
touch '{release}'
wait""",
["A_END", "C_DONE"],
)
assert "A_END" in lines, "blocking step did not finish after srun kill"
assert "C_DONE" in lines, "a later step did not run after a pending srun was killed"
assert "B_RAN" not in lines, "killed step should not have produced output"
def test_immediate_give_up_cancels_queued_step():
"""srun --immediate expiring on a queued step cancels the assigned step.
A queued step holds a placeholder with a real id; when srun stops
waiting it must cancel that id, or the placeholder leaks and the next
wake would hand a real allocation to a step nobody launches. After the
immediate srun fails, only the blocker may remain, and a later step
must still launch once the blocker frees.
"""
held = "imm_held"
release = "imm_release"
immediate_err = "immediate.err"
job_id, lines = _run_steps(
"immediate",
f"""srun -n"$SLURM_CPUS_ON_NODE" bash -c 'touch "{held}"; until [ -f "{release}" ]; do sleep 0.2; done' &
until [ -f '{held}' ]; do sleep 0.2; done
# Whole-node step: pends behind the blocker. --immediate=1 makes srun stop
# waiting after ~1s and cancel the placeholder on the way out.
srun --immediate=1 -n"$SLURM_CPUS_ON_NODE" true 2>'{immediate_err}'
echo IMM_RC=$?
# The give-up must cancel the placeholder: only the blocker may remain, so
# the step count drops back to 1 rather than a placeholder lingering.
{atf.wait_for_step_count_snippet(1, op="-eq")}
touch '{release}'
# Resources were not leaked: a normal step launches immediately after.
srun -n1 bash -c 'echo C_DONE'
wait""",
["IMM_RC=", "C_DONE"],
)
atf.assert_file_contents(immediate_err, f"StepId={job_id}.1 queued", contains=True)
rc_lines = [line for line in lines if line.startswith("IMM_RC=")]
assert rc_lines and rc_lines[0] != "IMM_RC=0", (
"srun --immediate must fail when its step stays queued, " f"got: {rc_lines!r}"
)
assert "C_DONE" in lines, "a later step did not run after an immediate give-up"