blob: 1e46fe67932a91942ae5674a43b9fe62305e1cfa [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify a pending synchronous step is assigned a real step ID at submission.
Issue 50938: Convert synchronous steps to receive a StepId at submit (like asynchronous
steps) instead of at launch. Visible with `scontrol show step` while still
queued (returns a real stepid, not TBD), launches with the same StepId, and
can be cancelled by StepId before it launches.
The feature is gated on the create request's negotiated protocol_version, so
these tests are skipped unless srun is 26.11+. What an older srun does
instead is deliberately not asserted here: it varies by release rather than
being one "pre-26.11" behavior, so pinning it would only encode whichever
older client the run happened to use.
The exception is test_het_step_component_queues_instead_of_failing, which
observes State=PENDING rather than a StepId and so holds on both sides.
"""
import re
from pathlib import Path
import pytest
import atf
# srun's pending-step wait floor is MAX(60, SlurmctldTimeout); setup() pins
# SlurmctldTimeout to this so the retry tests' wait stays at that 60s floor.
SLURMCTLD_TIMEOUT = 60
# A pending synchronous step is given a real id only when both srun and the
# daemon serving the step create are 26.11+ (older peers keep the TBD path).
# That daemon is slurmctld, or the batch host's stepmgr under enable_stepmgr;
# either way daemons are always >= every client command, so gating on srun
# already implies the serving side is new enough too.
ID_AT_SUBMIT = atf.get_version("bin/srun") >= (26, 11)
# Tests that can only assert the new behavior.
requires_id_at_submit = pytest.mark.skipif(
not ID_AT_SUBMIT,
reason="Issue 50938: pending-step id-at-submit requires a 26.11+ srun",
)
# Every test here queues a step behind a hog and polls for a state change, so
# the module runs for minutes rather than seconds.
pytestmark = pytest.mark.slow
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
# Two nodes (each 2 CPUs): the single-node tests use one; the out-of-order
# test needs a second, free node to launch a later step ahead of a pending.
atf.require_nodes(2, [("CPUs", 2)])
# Any value at or below the floor gives the same MAX(60, ...) wait, so
# accept those rather than reconfiguring a site that already satisfies us.
atf.require_config_parameter("SlurmctldTimeout", SLURMCTLD_TIMEOUT, "<=")
atf.require_slurm_running()
def _read_when_contains(path, needle):
"""Return path's contents once needle appears in them.
wait_for_file() only proves the file exists; srun's final lines can still
be in flight, so poll the contents instead of reading once."""
atf.wait_for_file(path, fatal=True)
text = ""
for _ in atf.timer():
text = atf.run_command_output(f"cat {path}", quiet=True, fatal=True)
if needle in text:
return text
assert False, f"expected {needle!r} in {path}, got: {text}"
def _steps_by_state(job_id):
"""Return {state: [step_id, ...]} for job_id's numeric steps via
atf.get_steps(), which renders pending steps with a real StepId (unlike
squeue).
The batch and extern steps are dropped: the batch step is RUNNING for the
whole job, so an unfiltered RUNNING count is always at least 1 and cannot
be used to wait for a step to launch.
"""
result = {}
for sid, info in atf.get_steps(job_id, quiet=True).items():
if not re.fullmatch(rf"{job_id}\.\d+", sid):
continue
result.setdefault(info["State"], []).append(sid)
return result
def _step_num(step_id):
"""The numeric step number from a '<jobid>.<n>' StepId string."""
return int(step_id.rsplit(".", 1)[1])
def _hog_ready_snippet(ready, srun_args="--exclusive -n2"):
"""Bash lines that launch a CPU-hogging background srun and block until it
actually holds the resources (signalled by touching ready), so a
following srun sees a genuinely busy allocation instead of racing srun's
own startup latency.
Aborts the script if the hog never took the CPUs: the busy allocation is
a precondition, so a test that ran anyway would report the missing
contention as a product failure."""
return (
f"srun {srun_args} sh -c 'touch \"{ready}\"; exec sleep infinity' &\n"
f"for _ in $(seq 1 60); do [ -f '{ready}' ] && break; sleep 0.5; done\n"
f"[ -f '{ready}' ] || {{ echo HOG_NOT_READY; exit 1; }}\n"
)
def _step_registered_snippet(step_suffix):
"""Bash lines that block until step $SLURM_JOB_ID.<step_suffix> is visible
to scontrol, so a following srun's submission order isn't racing the
previous srun's own startup latency (config load, auth, controller RTT).
Aborts the script if the step never registered, for the same reason as
_hog_ready_snippet()."""
show = f"scontrol -o show step $SLURM_JOB_ID.{step_suffix} >/dev/null 2>&1"
return (
f"for _ in $(seq 1 60); do {show} && break; sleep 0.5; done\n"
f"{show} || {{ echo STEP_{step_suffix}_NOT_REGISTERED; exit 1; }}\n"
)
def _submit_hog_and_pending():
"""Allocate a 2-CPU node, hog both CPUs with step .0, and queue step .1
which must wait for the step resources. Returns the job id. Neither step
ends on its own, so the test alone controls when resources free."""
script = Path("hog_and_pending.sh")
ready = Path("hog_ready")
atf.make_bash_script(
script,
_hog_ready_snippet(ready)
+ "srun --exclusive -n2 sleep infinity &\n"
+ "wait\n",
)
job_id = atf.submit_job(
"sbatch", "-N1 -n2 -t5", str(script), wrap_job=False, fatal=True
)
assert job_id != 0, "sbatch should submit the job"
# The hog is always step .0 (first submitted); it must be running before
# the second step (.1) can pend on it.
atf.wait_for_step(job_id, 0, fatal=True)
return job_id
def _submit_wide_hog_and_pending():
"""Allocate 2 nodes (4 CPUs), hog all of them with step .0, and queue a
1-task step .1 asking 2 CPUs on one node. The task count (1), the CPUs the
step would hold (2) and the job's CPU count (4) are all distinct, so an
assertion on the queued step's fields can only pass under the documented
reading.
Returns (job_id, step_env), where step_env is the file the queued step
writes its own $SLURM_JOB_ID.$SLURM_STEP_ID into once it launches."""
script = Path("wide_hog_and_pending.sh")
ready = Path("wide_hog_ready")
step_env = Path("wide_step_env")
atf.make_bash_script(
script,
_hog_ready_snippet(ready, srun_args="--exclusive -N2 -n4")
+ "srun --exclusive -N1 -n1 -c2 sh -c "
+ f'\'echo "STEP_ENV=$SLURM_JOB_ID.$SLURM_STEP_ID" > "{step_env}"; '
+ "exec sleep infinity' &\n"
+ "wait\n",
)
job_id = atf.submit_job(
"sbatch", "-N2 -n4 -t5", str(script), wrap_job=False, fatal=True
)
assert job_id != 0, "sbatch should submit the job"
atf.wait_for_step(job_id, 0, fatal=True)
return job_id, step_env
@requires_id_at_submit
def test_pending_sync_step_shows_real_id_and_reuses_it():
"""A synchronous step queued behind a CPU-hogging step shows a real StepId while
PENDING and launches with that same ID once the hog is freed."""
job_id, step_env = _submit_wide_hog_and_pending()
hog_id, pending_id = f"{job_id}.0", f"{job_id}.1"
# The queued step (.1, submitted second) is visible by a real, numeric
# StepId (not TBD).
atf.wait_for_step(job_id, 1, state="PENDING", fatal=True)
# scontrol can address the pending step by that id and reports details for
# just that step (scontrol.1, "show step"), and nothing is TBD.
step_show = atf.run_command(f"scontrol -o show step {pending_id}", quiet=True)
assert (
step_show["exit_code"] == 0
), f"scontrol show step {pending_id} should succeed while pending"
assert (
step_show["stdout"].count("StepId=") == 1
), f"scontrol show step {pending_id} should report exactly one step"
assert f"StepId={pending_id}" in step_show["stdout"], (
f"scontrol show step {pending_id} should describe that step, got: "
f"{step_show['stdout']!r}"
)
assert (
"State=PENDING" in step_show["stdout"]
), f"the queued step should show State=PENDING, got: {step_show['stdout']!r}"
assert "TBD" not in atf.run_command_output(
f"scontrol -o show step {job_id}", quiet=True, fatal=True
), "no step should render as TBD once a real id is assigned at submit"
# scontrol.1 documents what a queued step's fields mean: NodeList is the
# job's whole allocation and CPUs is the task count it requested. Neither
# describes resources held by the step, which has none until it launches.
pending_step = atf.get_steps(job_id, quiet=True, fatal=True)[pending_id]
job_nodes = atf.get_job_parameter(job_id, "NodeList", fatal=True)
assert pending_step["NodeList"] == job_nodes, (
f"a queued step's NodeList should be the job's allocation "
f"{job_nodes!r}, got {pending_step['NodeList']!r}"
)
assert str(pending_step["CPUs"]) == "1", (
f"a queued step's CPUs should be the 1 task it requested, not the 2 "
f"CPUs it would hold nor the job's 4, got {pending_step['CPUs']!r}"
)
# Name and StartTime are unset while queued. The rendering of "unset" is
# not documented, so assert both change at launch rather than pinning a
# token scontrol is free to spell differently.
pending_name = pending_step["Name"]
pending_start = pending_step["StartTime"]
assert (
not pending_name or "sleep" not in pending_name
), f"a queued step's Name should not yet be its command, got {pending_name!r}"
assert not re.match(
r"\d{4}-\d{2}-\d{2}T\d{2}:", str(pending_start)
), f"a queued step's StartTime should not be a timestamp, got {pending_start!r}"
# Free the resources by cancelling the hog; the pending step launches with
# the SAME id (PENDING -> RUNNING under the same StepId).
atf.run_command_exit(f"scancel {hog_id}", quiet=True, fatal=True)
atf.wait_for_step(job_id, 1, fatal=True)
launched_step = atf.get_steps(job_id, quiet=True, fatal=True)[pending_id]
assert launched_step["Name"] != pending_name, (
f"the step's Name should be set once it launches, still " f"{pending_name!r}"
)
assert launched_step["StartTime"] != pending_start, (
f"the step's StartTime should be set once it launches, still "
f"{pending_start!r}"
)
# Every check above reads the id back from the controller that assigned
# it. The task's own environment is the independent view: it shows the id
# the step actually runs under, not the one the controller recorded.
atf.assert_file_contents(step_env, f"STEP_ENV={pending_id}", contains=True)
@requires_id_at_submit
def test_pending_sync_step_logs_id_to_stderr():
"""srun prints the assigned StepId to stderr once, when the step first
goes PENDING; it does not repeat the message on later retries of the
same step, and never prints it to stdout. It prints a matching started
notice under the same id when the step launches.
The streams are kept apart because the notice's stream is part of the
contract: srun.1 puts this one on stderr, while --async's own submit
notice goes to stdout."""
out = Path("log_id.out")
err = Path("log_id.err")
ready = Path("log_id_ready")
script = Path("log_id.sh")
atf.make_bash_script(
script,
_hog_ready_snippet(ready)
+ "srun --exclusive -n2 sleep 5\n"
+ "echo STDOUT_ALIVE\n",
)
job_id = atf.submit_job(
"sbatch",
f"-N1 -n2 -t2 --output={out} --error={err}",
str(script),
wrap_job=False,
fatal=True,
)
assert job_id != 0, "sbatch should submit the job"
hog_id, pending_id = f"{job_id}.0", f"{job_id}.1"
atf.wait_for_step(job_id, 0, fatal=True)
atf.wait_for_step(job_id, 1, state="PENDING", fatal=True)
# Free the hog; the pending step launches and the job finishes.
atf.run_command_exit(f"scancel {hog_id}", quiet=True, fatal=True)
assert atf.wait_for_job_state(
job_id, "DONE", timeout=60
), f"job {job_id} should reach DONE once the hog is cancelled"
queued_notice = f"StepId={pending_id} queued"
started_notice = f"StepId={pending_id} started"
text = _read_when_contains(err, started_notice)
assert text.count(queued_notice) == 1, (
f"expected exactly one queued-notice for StepId={pending_id} in "
f"srun's stderr (not zero, not repeated on retry), got: {text}"
)
assert text.count(started_notice) == 1, (
f"expected exactly one started-notice for StepId={pending_id} in "
f"srun's stderr once the step launches, got: {text}"
)
# The launch notice carries the id the step was queued under, so the
# pair also shows the id survived the wait rather than being renumbered.
assert text.index(queued_notice) < text.index(started_notice), (
f"srun should announce StepId={pending_id} as queued before it "
f"announces it as started, got: {text}"
)
# Anchor on stdout first: without it an absent or unwritten file would
# satisfy the negative below just as well as a correctly-quiet stdout.
out_text = _read_when_contains(out, "STDOUT_ALIVE")
assert queued_notice not in out_text and started_notice not in out_text, (
f"the queued/started notices belong on stderr; one reached stdout "
f"instead, got: {out_text}"
)
@requires_id_at_submit
def test_step_that_starts_immediately_logs_no_queued_or_started_notice():
"""The queued notice describes a step that had to wait, so a step which
starts right away never prints it. Without this the notice could regress
into per-step noise on every srun and every positive assertion would still
pass.
The started notice is deliberately not asserted absent: srun logs it after
any retry, including ones this test cannot rule out (a prolog still
running, a controller RPC timeout), so its absence is not a property of a
step that started promptly."""
out = Path("no_notice.out")
err = Path("no_notice.err")
script = Path("no_notice.sh")
atf.make_bash_script(
script, "srun --exclusive -n2 true\n" + "echo STDERR_ALIVE >&2\n"
)
job_id = atf.submit_job(
"sbatch",
f"-N1 -n2 -t2 --output={out} --error={err}",
str(script),
wrap_job=False,
fatal=True,
)
assert job_id != 0, "sbatch should submit the job"
assert atf.wait_for_job_state(
job_id, "DONE", timeout=60
), f"job {job_id} should reach DONE"
# Anchor on the stream first: an absent or unwritten file would satisfy
# the negative below just as well as a correctly-quiet srun.
text = _read_when_contains(err, "STDERR_ALIVE")
notice = re.search(r"StepId=\S+ queued", text)
assert not notice, (
f"a step with resources free should not announce itself as queued, "
f"got: {text}"
)
@requires_id_at_submit
@pytest.mark.parametrize(
"signal_args",
["", "--ctld --signal=INT", "--ctld --signal=TERM", "--ctld --signal=KILL"],
)
def test_scancel_pending_step_by_id(signal_args):
"""scancel <jobid>.<stepid> cancels a queued synchronous step before it launches;
the hogging step keeps running.
scancel.1 names INT, TERM and KILL as the terminating signals that cancel a
queued step, so each is exercised alongside the default (no --signal) form.
All go through the controller, which is the only route to a step that has
no tasks yet."""
job_id = _submit_hog_and_pending()
hog_id, pending_id = f"{job_id}.0", f"{job_id}.1"
atf.wait_for_step(job_id, 1, state="PENDING", fatal=True)
# Cancel just the pending step by id.
cmd = f"scancel {signal_args} {pending_id}".replace(" ", " ")
assert (
atf.run_command_exit(cmd, quiet=True) == 0
), f"{cmd} should succeed against a pending step"
# The pending step is reaped outright, not just moved out of PENDING: a
# regression that renumbered or relaunched it under another state would
# otherwise pass.
for _ in atf.timer():
states = _steps_by_state(job_id)
if not any(pending_id in ids for ids in states.values()):
break
else:
assert False, f"pending step {pending_id} should be gone after {cmd}"
assert hog_id in _steps_by_state(job_id).get(
"RUNNING", []
), f"the hogging step {hog_id} should still be running after the step cancel"
@requires_id_at_submit
def test_cancelled_pending_step_makes_srun_exit_nonzero():
"""Cancelling a queued synchronous step by id pushes the cancel to the waiting
srun, which aborts with a non-zero exit and logs the cancellation
(ESLURM_STEP_CANCELLED) instead of hanging or exiting 0."""
out = Path("cancel_exit.out")
ready = Path("cancel_exit_ready")
script = Path("cancel_exit.sh")
# Hog both CPUs, then run the pending srun in the foreground so its exit
# status and stderr are captured in the job output. Once it aborts, the
# script ends and the job terminates the backgrounded hog.
atf.make_bash_script(
script,
_hog_ready_snippet(ready)
+ "srun --exclusive -n2 sleep infinity\n"
+ 'echo "PENDING_SRUN_RC=$?"\n',
)
job_id = atf.submit_job(
"sbatch",
f"-N1 -n2 -t2 --output={out} --error={out}",
str(script),
wrap_job=False,
fatal=True,
)
assert job_id != 0, "sbatch should submit the job"
pending_id = f"{job_id}.1"
atf.wait_for_step(job_id, 1, state="PENDING", fatal=True)
# Cancel just the pending step; the waiting srun should be signalled.
atf.run_command_exit(f"scancel {pending_id}", quiet=True, fatal=True)
assert atf.wait_for_job_state(
job_id, "DONE", timeout=60
), f"job {job_id} should finish once the pending srun aborts"
text = _read_when_contains(out, "PENDING_SRUN_RC=")
assert (
"PENDING_SRUN_RC=0" not in text
), f"the cancelled pending srun should exit non-zero, got: {text}"
assert (
"Pending job step cancelled" in text
), f"srun should log the pending-step cancellation, got: {text}"
@requires_id_at_submit
def test_nonfatal_signal_leaves_pending_step():
"""A non-terminating signal delivered to a queued synchronous step through the
controller (scancel --ctld) is a no-op: the placeholder keeps its id and
the waiting srun still launches it once resources free. Only a terminating
signal cancels a pending step."""
job_id = _submit_hog_and_pending()
hog_id, pending_id = f"{job_id}.0", f"{job_id}.1"
atf.wait_for_step(job_id, 1, state="PENDING", fatal=True)
# A non-fatal signal reaches no tasks and must not reap the placeholder.
# --ctld routes the signal through the controller (the queued step has no
# slurmd tasks to signal directly); the controller treats it as a no-op.
assert (
atf.run_command_exit(f"scancel --ctld --signal=USR1 {pending_id}", quiet=True)
== 0
), f"scancel --ctld --signal=USR1 {pending_id} should be a no-op success"
# Give a regressed reap time to land before asserting survival. Under
# enable_stepmgr the controller forwards the signal to the stepmgr node
# asynchronously, so scancel returns before the stepmgr has acted and an
# immediate check would pass even if the placeholder were being reaped.
# Reaching the timeout is the pass condition here, so xfail keeps atf from
# logging a spurious "Timer should not timeout" warning on success.
for _ in atf.timer(timeout=10, xfail=True):
assert pending_id in _steps_by_state(job_id).get(
"PENDING", []
), f"pending step {pending_id} should survive a non-fatal signal"
# The waiting srun also survived: freeing the hog launches the same id.
atf.run_command_exit(f"scancel {hog_id}", quiet=True, fatal=True)
atf.wait_for_step(job_id, 1, fatal=True)
@requires_id_at_submit
def test_signal_without_ctld_fails_against_pending_step():
"""scancel.1 requires the controller route for a queued step, so an
explicit --signal without --ctld must fail rather than misbehave silently.
Such a signal is sent to the step's nodes directly; a still-PENDING
placeholder has no slurmstepd on any node yet, so scancel fails with
"Invalid job id specified" and the placeholder is untouched -- the same
failure mode as a pending asynchronous step (test_116_55)."""
job_id = _submit_hog_and_pending()
hog_id, pending_id = f"{job_id}.0", f"{job_id}.1"
atf.wait_for_step(job_id, 1, state="PENDING", fatal=True)
assert (
atf.run_command_exit(f"scancel --signal=TERM {pending_id}", quiet=True) != 0
), f"scancel --signal=TERM {pending_id} (no --ctld) unexpectedly succeeded"
# The queued step is untouched: the failed signal never reached it.
assert pending_id in _steps_by_state(job_id).get(
"PENDING", []
), f"pending step {pending_id} should survive a failed no-ctld signal"
# The waiting srun also survived: freeing the hog launches the same id.
atf.run_command_exit(f"scancel {hog_id}", quiet=True, fatal=True)
atf.wait_for_step(job_id, 1, fatal=True)
@requires_id_at_submit
def test_step_ids_track_submission_order_without_gaps():
"""A step pending on a busy node keeps the id matching its submission
order (not its launch order), and the job's three steps occupy a
contiguous id range with no gaps."""
# Whole-node (--exclusive) two-node allocation, and non-exact steps that
# each grab all of a node's CPUs regardless of the node's actual CPU
# count. Step .0 hogs node0; step .1 (submitted next) targets the busy
# node0 and must pend; step .2 (submitted last) targets the free node1
# and runs immediately -- so .2 launches before .1.
script = Path("submit_order_ids.sh")
atf.make_bash_script(
script,
"nodes=($(scontrol show hostnames $SLURM_JOB_NODELIST))\n"
"srun -N1 -w ${nodes[0]} sleep infinity &\n"
+ _step_registered_snippet(0)
+ "srun -N1 -w ${nodes[0]} sleep infinity &\n"
+ _step_registered_snippet(1)
+ "srun -N1 -w ${nodes[1]} sleep infinity &\n"
+ "wait\n",
)
job_id = atf.submit_job(
"sbatch", "-N2 --exclusive -t2", str(script), wrap_job=False, fatal=True
)
assert job_id != 0, "sbatch should submit the job"
# Out-of-order window: by this fixed 3-step topology, exactly one step
# (.1) is PENDING and exactly two (.0, .2) are RUNNING once .2 has
# launched ahead of the still-queued .1.
for _ in atf.timer():
st = _steps_by_state(job_id)
pending, running = st.get("PENDING", []), st.get("RUNNING", [])
if len(pending) == 1 and len(running) == 2:
break
else:
assert False, (
"expected exactly one PENDING step and two RUNNING steps in the "
"out-of-order window"
)
all_nums = sorted(map(_step_num, pending + running))
assert _step_num(pending[0]) == 1, (
f"the middle-submitted step should keep id .1 (its submission order), "
f"got pending step {pending[0]}"
)
assert all_nums == [0, 1, 2], (
f"the job's three steps should have contiguous ids 0, 1, 2 with no "
f"gaps, got {all_nums}"
)
@requires_id_at_submit
def test_pending_sync_step_survives_timeout_retry_and_reuses_id():
"""A queued synchronous step whose srun retries on the timeout (no wake poke)
re-sends carrying its assigned id: the controller purges the lingering
placeholder and rebuilds one under the SAME id instead of rejecting the
re-send as a duplicate step id. It stays pending under that id across the
timeout and still launches with it once resources free.
Distinct from the reuse test above, which cancels the hog: a step
completion there wakes/pokes srun and reaps the placeholder, so the re-send
finds nothing to purge. Here the hog runs across the whole srun timeout
window, so the re-send is timeout-driven and the placeholder is still
present -- the only path that exercises the _purge_duplicate_steps delete.
Also confirms the stderr queued-notice (test_pending_sync_step_logs_id_to_stderr's
"once, not repeated" claim) still holds across a real timeout re-send,
which that test's own hog is freed too quickly to exercise.
"""
# The hog holds the node well past one srun timeout window, so no step
# completes (no wake) before the queued step's srun re-sends on the timeout.
out = Path("timeout_retry.out")
ready = Path("timeout_retry_ready")
script = Path("timeout_retry.sh")
atf.make_bash_script(
script,
_hog_ready_snippet(ready)
# -v so the queued step's srun surfaces its "still pending" notice,
# which it logs only when its pending-step wait times out.
+ "srun -v --exclusive -n2 sleep 5 &\n" + "wait\n",
)
job_id = atf.submit_job(
"sbatch",
f"-N1 -n2 -t5 --output={out} --error={out}",
str(script),
wrap_job=False,
fatal=True,
)
assert job_id != 0, "sbatch should submit the job"
hog_id, pending_id = f"{job_id}.0", f"{job_id}.1"
atf.wait_for_step(job_id, 0, fatal=True)
# The queued step shows a real id while pending.
atf.wait_for_step(job_id, 1, state="PENDING", fatal=True)
# Wait for positive evidence that srun's wait actually timed out and it
# re-sent the create: with -v, srun logs the "still pending" notice only on
# the timeout path. The wait floor is MAX(60, SlurmctldTimeout) plus up to
# 9s of pid jitter. The hog (sleep infinity, job -t5) cannot end or hit its
# time limit inside this window, so no step completes and no wake fires: the
# re-send here is necessarily timeout-driven, not a poke.
retry_notice = f"StepId={pending_id} still pending"
for _ in atf.timer(timeout=SLURMCTLD_TIMEOUT + 30):
if retry_notice in atf.run_command_output(f"cat {out} 2>/dev/null", quiet=True):
break
else:
assert False, (
f"srun should log {retry_notice!r} once its pending-step wait "
f"times out and it re-sends the create"
)
# The step is still PENDING under the same id: the re-send did not queue a
# second placeholder, and the srun is still alive (a regressed purge would
# reject the re-send with ESLURM_DUPLICATE_STEP_ID).
assert pending_id in _steps_by_state(job_id).get("PENDING", []), (
f"after the timeout re-send step {pending_id} should still be PENDING "
f"under the same id"
)
# Free the node; the timed-out-and-retried srun launches under the same id.
atf.run_command_exit(f"scancel {hog_id}", quiet=True, fatal=True)
atf.wait_for_step(job_id, 1, fatal=True)
# The queued-notice was printed exactly once, even though this srun
# actually re-sent the create on a timeout (not just a hog-cancel poke).
assert atf.wait_for_job_state(
job_id, "DONE", timeout=60
), f"job {job_id} should reach DONE once the hog is cancelled"
text = _read_when_contains(out, f"StepId={pending_id} queued")
assert text.count(f"StepId={pending_id} queued") == 1, (
f"expected exactly one queued-notice for StepId={pending_id} across "
f"the timeout re-send, got: {text}"
)
@requires_id_at_submit
def test_still_pending_notice_requires_verbose():
"""The per-retry "still pending" notice is -v-only. It replaced a line
that used to print on every retry regardless of verbosity, so staying
quiet without -v is the contract; the queued notice is not gated and must
still appear."""
out = Path("quiet_retry.out")
err = Path("quiet_retry.err")
ready = Path("quiet_retry_ready")
script = Path("quiet_retry.sh")
atf.make_bash_script(
script,
_hog_ready_snippet(ready) + "srun --exclusive -n2 sleep 5 &\n" + "wait\n",
)
job_id = atf.submit_job(
"sbatch",
f"-N1 -n2 -t5 --output={out} --error={err}",
str(script),
wrap_job=False,
fatal=True,
)
assert job_id != 0, "sbatch should submit the job"
hog_id, pending_id = f"{job_id}.0", f"{job_id}.1"
atf.wait_for_step(job_id, 0, fatal=True)
atf.wait_for_step(job_id, 1, state="PENDING", fatal=True)
# Hold the node past one whole pending-step wait so this srun times out
# and re-sends at least once with no -v in play. Reaching the timeout is
# the pass condition, so xfail keeps atf from warning about it.
for _ in atf.timer(timeout=SLURMCTLD_TIMEOUT + 20, xfail=True):
assert pending_id in _steps_by_state(job_id).get(
"PENDING", []
), f"step {pending_id} should stay PENDING across the timeout re-send"
text = atf.run_command_output(f"cat {err} 2>/dev/null", quiet=True)
assert (
"still pending" not in text
), f"the still-pending retry notice should need -v, got: {text}"
assert f"StepId={pending_id} queued" in text, (
f"the queued notice is not -v-gated and should appear without it, "
f"got: {text}"
)
atf.run_command_exit(f"scancel {hog_id}", quiet=True, fatal=True)
atf.wait_for_step(job_id, 1, fatal=True)
@requires_id_at_submit
def test_het_independent_components_each_get_own_real_id():
"""Independent (non-spanning) steps on different components of a het job
each pend on their own component's busy resources and each get their own
real, distinct step id while PENDING.
Each component is a separate job record, so `scontrol show steps
<leader_job_id>` only sees the leader's steps. Both pending ids are read
instead from the stderr announcement each queued step prints for itself."""
ready0 = Path("het_ready0")
ready1 = Path("het_ready1")
pend0_err = Path("het_pend0.err")
pend1_err = Path("het_pend1.err")
done0 = Path("het_done0")
done1 = Path("het_done1")
out = Path("het_independent.out")
script = Path("het_independent.sh")
# Each hog execs its sleep so the sleep is the task itself rather than a
# child of the wrapping sh; otherwise TERM leaves the sleep running, the
# hog never frees and the job never completes.
atf.make_bash_script(
script,
f"""srun --het-group=0 --exclusive -n2 sh -c 'touch "{ready0}"; exec sleep infinity' &
srun --het-group=1 --exclusive -n2 sh -c 'touch "{ready1}"; exec sleep infinity' &
for k in $(seq 1 60); do
[ -f '{ready0}' ] && [ -f '{ready1}' ] && break
sleep 0.5
done
{{ [ -f '{ready0}' ] && [ -f '{ready1}' ]; }} || {{ echo HOG_NOT_READY; exit 1; }}
srun --het-group=0 --exclusive -n2 sh -c 'touch "{done0}"' 2>'{pend0_err}' &
pend0=$!
srun --het-group=1 --exclusive -n2 sh -c 'touch "{done1}"' 2>'{pend1_err}' &
pend1=$!
wait "$pend0" "$pend1"
echo DONE
""",
)
job_id = atf.submit_job_sbatch(
f"-N1 -n2 -t10 --output={out} --error={out} : -N1 -n2 -t10 {script}",
fatal=True,
)
assert job_id != 0, "het sbatch should submit the job"
# Each component of a het job is a separate job record; the second
# component's id is the leader's + 1 (same assumption test_107_3.py's
# local submit_het_job() helper relies on -- there is no env var or
# atf helper that reports a sibling component's job id directly).
comp1_job_id = job_id + 1
def _queued(path):
return "queued" in atf.run_command_output(f"cat {path} 2>/dev/null", quiet=True)
for _ in atf.timer(timeout=60):
if _queued(pend0_err) and _queued(pend1_err):
break
else:
assert False, (
"both het components should show a queued pending step; job "
f"output: {atf.run_command_output(f'cat {out} 2>/dev/null', quiet=True)!r}"
)
# Each component's hog is its own RUNNING step, and the queued check above
# already confirms both hogs are genuinely busy. Discover the ids instead
# of assuming .0: a het job's step ids come from a counter shared across
# its components (_set_step_id() draws from het_job->next_step_id), so the
# components interleave rather than each numbering from zero.
def _hog_step(component_job_id, tag):
running = _steps_by_state(component_job_id).get("RUNNING", [])
assert (
len(running) == 1
), f"{tag} should have exactly one RUNNING step (its hog), got {running}"
return running[0]
hog0_id = _hog_step(job_id, "component 0")
hog1_id = _hog_step(comp1_job_id, "component 1")
# TERM, not the default SIGKILL: a SIGKILL carrying a real step id against
# a het leader is re-targeted at every component's whole job, which would
# tear down the sibling pending placeholder along with the hog.
atf.run_command_exit(f"scancel --signal=TERM {hog0_id}", quiet=True, fatal=True)
atf.run_command_exit(f"scancel --signal=TERM {hog1_id}", quiet=True, fatal=True)
assert atf.wait_for_job_state(
job_id, "DONE", timeout=150
), f"het job {job_id} should reach DONE"
atf.assert_file_contents(out, "DONE", contains=True)
for path, tag in ((pend0_err, "component 0"), (pend1_err, "component 1")):
assert atf.wait_for_file(path), f"{tag}'s pending step never announced an id"
pend0_text = atf.run_command_output(f"cat {pend0_err}", fatal=True)
pend1_text = atf.run_command_output(f"cat {pend1_err}", fatal=True)
id0 = re.search(r"StepId=(\S+) queued", pend0_text)
id1 = re.search(r"StepId=(\S+) queued", pend1_text)
assert id0 and id1, (
"both het components should announce a real StepId while pending, "
f"got: component 0={pend0_text!r} component 1={pend1_text!r}"
)
# Each queued step must announce a real id belonging to its own component
# job, and the two must differ: a component that inherited its sibling's
# id, or announced against the wrong component job, fails here. The step
# numbers themselves are not pinned because het step ids come from a
# counter shared across the components, so they interleave.
assert re.fullmatch(rf"{job_id}\.\d+", id0.group(1)), (
f"component 0's pending step should announce a real id under job "
f"{job_id}, got {id0.group(1)!r}"
)
assert re.fullmatch(rf"{comp1_job_id}\.\d+", id1.group(1)), (
f"component 1's pending step should announce a real id under job "
f"{comp1_job_id}, got {id1.group(1)!r}"
)
assert id0.group(1) != id1.group(1), (
f"the two het components' pending steps should have distinct ids, "
f"both got {id0.group(1)!r}"
)
assert atf.wait_for_file(done0) and atf.wait_for_file(done1), (
"both het-component pending steps should launch once their hog frees "
"its component's resources"
)
def test_het_step_component_queues_instead_of_failing():
"""A het step whose second component must queue behind a CPU hog keeps the
busy-retry path instead of failing outright.
The second het component inherits the first's step id, so its create
request carries a non-NO_VAL step id. The controller must not mistake that
carried-in id for one it assigned at submit and reply
JOB_PENDING/ESLURM_STEP_QUEUED to an srun that cannot retry it: below the
26.11 gate that errno is not a launch retry errno, so an old srun would
give up and the whole het step would fail.
Runs on both sides of the gate. The queued component is observed by
State=PENDING rather than by its StepId, which renders as a real id above
the gate and as TBD below it.
"""
ready = Path("het_hog_ready")
ok = Path("het_step_ok")
fail = Path("het_step_fail")
script = Path("het_queue.sh")
atf.make_bash_script(
script,
# Hog het-group 1 and signal once its task actually holds the CPUs, so
# the het step is guaranteed to find group 1 busy when its second
# component is created (the precondition the regression needs).
f"srun --het-group=1 --exclusive -n2 sh -c 'touch {ready}; sleep infinity' &\n"
"hog=$!\n"
f"for _ in $(seq 1 60); do [ -f {ready} ] && break; sleep 0.5; done\n"
# Fail loudly if the hog never grabbed group 1 (no contention to test).
f"[ -f {ready} ] || {{ touch {fail}; exit 1; }}\n"
# Het step: group 0 is free, group 1 is busy. Its second component
# inherits the first's step id and hits busy. Launch it, wait for
# group 1's component to actually register as a pending placeholder --
# confirming contention was hit, not merely assumed -- then free group
# 1 so a correctly-retrying step can launch (a buggy controller has
# already aborted it by now).
"srun --het-group=0,1 --exclusive true &\n"
"step=$!\n"
"comp1=$((SLURM_JOB_ID + 1))\n"
"comp1_pending=0\n"
"for _ in $(seq 1 60); do\n"
" if scontrol -o show step $comp1 2>/dev/null "
"| grep -q 'State=PENDING'; then\n"
" comp1_pending=1\n"
" break\n"
" fi\n"
" sleep 0.5\n"
"done\n"
f'[ "$comp1_pending" = 1 ] || {{ touch {fail}; exit 1; }}\n'
'kill "$hog" 2>/dev/null\n'
f"if wait $step; then touch {ok}; else touch {fail}; fi\n",
)
job_id = atf.submit_job(
"sbatch",
"-N1 -n2 -t2 : -N1 -n2 -t2",
str(script),
wrap_job=False,
fatal=True,
)
assert job_id != 0, "het sbatch should submit the job"
# Above the script's own worst case (two 30s waits) plus job startup, so a
# slow runner reads as a slow runner rather than a product failure.
for _ in atf.timer(timeout=120):
if atf.wait_for_file(ok, timeout=1) or atf.wait_for_file(fail, timeout=1):
break
else:
assert (
False
), "the het step should resolve (launch or fail) within the time limit"
assert ok.exists() and not fail.exists(), (
"a queued het component must retry and launch, not be told "
"ESLURM_STEP_QUEUED (which it cannot retry) and abort the het step"
)
@requires_id_at_submit
def test_sigint_to_queued_srun_reports_cancellation():
"""SIGINT to an srun whose step is still queued reports the cancellation
and nothing else.
Pins srun.1's EXAMPLES 10 transcript: the interrupt is answered with
"Cancelled pending job step with signal 2" and no longer trailed by the
"Unable to create step ... Job/step already completing or completed"
error, which the ESLURM_STEP_CANCELLED early return now skips."""
perr = Path("sigint_pending.err")
out = Path("sigint_pending.out")
ready = Path("sigint_pending_ready")
script = Path("sigint_pending.sh")
atf.make_bash_script(
script,
_hog_ready_snippet(ready)
+ f"""srun --exclusive -n2 sleep infinity 2>'{perr}' &
pend=$!
for k in $(seq 1 120); do
grep -q queued '{perr}' 2>/dev/null && break
sleep 0.5
done
grep -q queued '{perr}' 2>/dev/null || {{ echo STEP_NEVER_QUEUED; exit 1; }}
kill -INT "$pend"
wait "$pend"
echo "PENDING_SRUN_RC=$?"
""",
)
job_id = atf.submit_job(
"sbatch",
f"-N1 -n2 -t5 --output={out} --error={out}",
str(script),
wrap_job=False,
fatal=True,
)
assert job_id != 0, "sbatch should submit the job"
# The script drives the whole scenario and exits on its own, so don't
# sample the short PENDING window or race its kill -INT with a scancel.
assert atf.wait_for_job_state(
job_id, "DONE", timeout=120
), f"job {job_id} should finish once the interrupted srun exits"
# Assert the precondition ahead of the message checks, which would
# otherwise report a step that never queued as a missing srun message.
assert "STEP_NEVER_QUEUED" not in atf.run_command_output(
f"cat {out} 2>/dev/null", quiet=True
), "the second step never queued, so the interrupt exercised nothing"
text = _read_when_contains(perr, "Cancelled pending job step")
assert (
"Cancelled pending job step with signal 2" in text
), f"srun should report the interrupt of its queued step, got: {text}"
assert (
"Unable to create step" not in text
), f"the interrupted srun should not also report a create failure, got: {text}"
# srun.1 RETURN VALUE covers both ways a queued step ends: cancelled by
# scancel, or interrupted here. The scancel half is asserted by
# test_cancelled_pending_step_makes_srun_exit_nonzero.
rc_text = _read_when_contains(out, "PENDING_SRUN_RC=")
assert (
"PENDING_SRUN_RC=0" not in rc_text
), f"the interrupted pending srun should exit non-zero, got: {rc_text}"