blob: fc9d0793eed5d74babb5a32675a4b2af125b88c1 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Tests for srun --async (asynchronous job step submission).
`srun --async` submits a step and exits 0 once the controller accepts the
create request, without waiting for the step to launch or finish. The step
is supervised by the job's step manager (stepmgr) running inside the batch
slurmstepd, so all tests must keep the batch step alive long enough for the
async step to complete.
This file also covers the related --parsable output: the submit message
moved from stderr info() to stdout printf so the step id can be captured
with $(...). --parsable strips the prefix to mirror sbatch --parsable, and
on srun it requires --async.
srun has no IO connection to an async step, but --label, --unbuffered, and
--input are propagated through launch_params to the slurmstepd, which honors
them when writing the output file (or opening stdin) on the compute node.
"""
import re
import pytest
import atf
PARSABLE_RE = re.compile(r"^(\d+)\.(\d+)$", re.MULTILINE)
pytestmark = pytest.mark.slow
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 5),
"bin/srun",
reason="Issue 50739: srun --async was added in 26.05",
)
# stepmgr is enabled when both SlurmctldParameters=enable_stepmgr
# and PrologFlags=Contain are set.
atf.require_config_parameter_includes("SlurmctldParameters", "enable_stepmgr")
atf.require_config_parameter_includes("PrologFlags", "Contain")
atf.require_accounting()
# test_async_rejected_in_hetjob submits a 2-component heterogeneous job.
atf.require_nodes(2, [("CPUs", 2)])
atf.require_slurm_running()
def _run_in_alloc(srun_cmd):
"""Run an srun command inside a sbatch allocation and return stdout/stderr."""
out_file = "srun_out.txt"
err_file = "srun_err.txt"
job_id = atf.submit_job_sbatch(
f"-N1 -o /dev/null --wrap '"
f"{srun_cmd} >{out_file} 2>{err_file}; "
f"echo srun_rc=$? >>{out_file}; sync'",
fatal=True,
)
atf.wait_for_job_state(job_id, "COMPLETED", fatal=True)
return (
atf.run_command_output(f"cat {out_file}", fatal=True),
atf.run_command_output(f"cat {err_file}", fatal=True),
)
def test_async_stdout():
"""srun --async writes "Submitted step <jobid>.<stepid>" to stdout."""
stdout, stderr = _run_in_alloc("srun --async true")
assert re.search(
r"^Submitted step \d+\.\d+$", stdout, re.MULTILINE
), f"expected 'Submitted step <jobid>.<stepid>' in stdout, got: {stdout!r}"
assert (
"Submitted step" not in stderr
), f"submit message should not appear on stderr, got: {stderr!r}"
assert "srun_rc=0" in stdout, f"srun should exit 0, got stdout: {stdout!r}"
def test_srun_parsable_async():
"""srun --async --parsable writes bare "<jobid>.<stepid>" to stdout."""
stdout, stderr = _run_in_alloc("srun --async --parsable true")
match = re.search(r"^(\d+\.\d+)$", stdout, re.MULTILINE)
assert match, f"expected bare '<jobid>.<stepid>' in stdout, got: {stdout!r}"
assert (
"Submitted step" not in stdout
), f"--parsable should strip the prefix, got: {stdout!r}"
assert (
"Submitted step" not in stderr
), f"submit message should not appear on stderr, got: {stderr!r}"
assert "srun_rc=0" in stdout, f"srun should exit 0, got stdout: {stdout!r}"
def test_srun_parsable_without_async():
"""srun --parsable without --async errors at option-validation."""
result = atf.run_command("srun --parsable -N1 true", xfail=True)
assert result["exit_code"] != 0, "--parsable without --async should fail"
assert (
"--parsable requires --async" in result["stderr"]
), f"expected validation error, got stderr: {result['stderr']!r}"
def test_async_submit_and_complete():
"""srun --async submits a step, exits 0 with stepid, step runs to completion."""
out_file = "async.out"
script = "submit_and_complete.sh"
atf.make_bash_script(
script,
f"srun --async -o {out_file} bash -c 'echo hello-from-async'\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t1 {script}", fatal=True)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.assert_file_contents(out_file, "hello-from-async")
@pytest.mark.parametrize(
"step_cmd, expected_rc",
[("true", 0), ("false", 1)],
)
def test_async_exit_code_in_sacct(step_cmd, expected_rc):
"""srun --async always returns 0; the step's real rc surfaces in sacct."""
out_file = f"exit_{step_cmd}.out"
job_id = atf.submit_job_sbatch(
f"-N1 -n1 -t1 --wrap 'srun --async -o {out_file} {step_cmd}; swait'",
fatal=True,
)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
# sacct ExitCode is "<rc>:<signal>"; check the rc half.
output = atf.run_command_output(
f"sacct -j {job_id}.0 --noheader -P -o ExitCode",
fatal=True,
).strip()
assert output, f"sacct returned no ExitCode for step {job_id}.0"
rc = int(output.splitlines()[0].split(":")[0])
assert (
rc == expected_rc
), f"Expected step ExitCode rc={expected_rc} for `{step_cmd}`, got {output!r}"
@pytest.mark.parametrize(
"conflict_arg, error_re",
[
("--pty", r"--async and --pty are mutually exclusive"),
("--immediate", r"--immediate and --async are mutually exclusive"),
],
)
def test_async_mutex_with_other_options(conflict_arg, error_re):
"""srun rejects --async + --pty / --immediate before submission."""
result = atf.run_command(f"srun --async {conflict_arg} hostname", xfail=True)
assert (
result["exit_code"] != 0
), f"`srun --async {conflict_arg}` should fail validation"
assert re.search(
error_re, result["stderr"]
), f"Expected stderr matching {error_re!r}; got {result['stderr']!r}"
def test_async_rejected_outside_existing_allocation():
"""`srun --async` is rejected when srun is being used for job submission.
--async needs a stepmgr running in the job's allocation; for an
interactive submission (srun creating its own alloc) there is no
batch slurmstepd to host stepmgr, and srun exits before any
follow-up step could start one. The option verifier rejects --async
at parse time when neither SLURM_JOB_ID nor --jobid is set.
"""
result = atf.run_command("srun --async -t1 hostname", xfail=True)
assert (
result["exit_code"] != 0
), f"srun --async outside an existing allocation should fail: {result!r}"
assert (
"--async is only valid for steps submitted within an existing job allocation"
in result["stderr"]
), (
"Expected stderr with the outside-allocation rejection message; "
f"got stderr={result['stderr']!r}"
)
def test_async_queued_when_busy():
"""A second `--async --exclusive` step queues until the first completes."""
out1 = "queued1.out"
out2 = "queued2.out"
marker = "queued_marker"
script = "queued_submit.sh"
atf.make_bash_script(
script,
f"""srun --async --parsable --exclusive -o {out1} sleep 10 > {marker} 2>&1
srun --async --parsable --exclusive -o {out2} sleep 1 >> {marker} 2>&1
swait
""",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t2 {script}", fatal=True)
atf.wait_for_file(marker, fatal=True)
text = ""
for _t in atf.timer():
text = atf.run_command_output(f"cat {marker}", quiet=True)
if len(PARSABLE_RE.findall(text)) >= 2:
break
else:
pytest.fail(f"Expected two 'Submitted step' lines, got: {text!r}")
matches = PARSABLE_RE.findall(text)
step_ids = [int(s) for _j, s in matches[:2]]
second_key = f"{job_id}.{step_ids[1]}"
saw_pending = False
# Poll fast: the queued step may be PENDING only briefly.
for _t in atf.timer(poll_interval=0.5, quiet=True):
steps = atf.get_steps(job_id, quiet=True)
if second_key in steps and steps[second_key].get("State") == "PENDING":
saw_pending = True
break
assert saw_pending, (
f"Second --async --exclusive step {second_key} should queue in "
f"PENDING while the first step holds the resource"
)
for s in step_ids:
assert atf.wait_for_step_accounted(
job_id, s, fatal=True
), f"async step {job_id}.{s} should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
def test_async_scancel():
"""`scancel <jobid>.<stepid>` ends a running async step in CANCELLED state."""
out_file = "scancel.out"
job_id = atf.submit_job_sbatch(
f"-N1 -n1 -t1 --wrap 'srun --async -o {out_file} sleep 60; sleep 30'",
fatal=True,
)
assert atf.wait_for_step(
job_id, 0, fatal=True
), f"async step {job_id}.0 should appear in the step list"
atf.run_command(f"scancel {job_id}.0", fatal=True)
# sacct State may appear as "CANCELLED" or "CANCELLED by <uid>".
state_text = ""
for _t in atf.timer():
state_text = atf.run_command_output(
f"sacct -j {job_id}.0 --noheader -P -o State",
quiet=True,
).strip()
if state_text and state_text.splitlines()[0].startswith("CANCELLED"):
break
else:
pytest.fail(
f"Async step {job_id}.0 did not transition to CANCELLED: " f"{state_text!r}"
)
def test_async_rejected_in_hetjob():
"""srun --async fails inside a heterogeneous job.
Hetjobs cannot use stepmgr, so the create request is rejected.
"""
marker = "het_marker"
out_file = "het.out"
script = "het.sh"
atf.make_bash_script(
script,
f"""#SBATCH --ntasks=1 --cpus-per-task=1 -t1
#SBATCH hetjob
#SBATCH --ntasks=1 --cpus-per-task=1 -t1
srun --async --parsable -o {out_file} hostname > {marker} 2>&1
echo "srun_rc=$?" >> {marker}
""",
)
job_id = atf.submit_job_sbatch(f"-t1 {script}", fatal=True)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.wait_for_file(marker, fatal=True)
text = atf.run_command_output(f"cat {marker}", fatal=True)
assert re.search(
r"srun_rc=[1-9]\d*", text
), f"srun --async should fail in a hetjob but succeeded: {text!r}"
assert not PARSABLE_RE.search(
text
), f"srun --async in a hetjob should not return a step id: {text!r}"
def test_async_split_output_error():
"""--output and --error route stdout/stderr to separate compute-node files."""
out_file = "split.out"
err_file = "split.err"
script = "split_output.sh"
atf.make_bash_script(
script,
f"srun --async -o {out_file} -e {err_file} bash -c 'echo to-stdout; echo to-stderr >&2'\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t1 {script}", fatal=True)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.wait_for_file(out_file, fatal=True)
atf.wait_for_file(err_file, fatal=True)
out_text = atf.run_command_output(f"cat {out_file}", fatal=True)
err_text = atf.run_command_output(f"cat {err_file}", fatal=True)
assert (
"to-stdout" in out_text and "to-stderr" not in out_text
), f"--output should contain only stdout: {out_text!r}"
assert (
"to-stderr" in err_text and "to-stdout" not in err_text
), f"--error should contain only stderr: {err_text!r}"
def test_async_default_output_filename():
"""Without --output, an async step writes to `slurm-<jobid>.<stepid>.out`.
A NULL output_filename for an async step expands to "slurm-%J.out"
(where %J is jobid.stepid), relative to the task working directory.
"""
script = "default_submit.sh"
atf.make_bash_script(
script,
"srun --async hostname\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t1 {script}", fatal=True)
step_id = 0
assert atf.wait_for_step_accounted(
job_id, step_id, fatal=True
), f"async step {job_id}.{step_id} should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
expected_file = f"slurm-{job_id}.{step_id}.out"
atf.wait_for_file(expected_file, fatal=True)
assert atf.run_command_output(
f"cat {expected_file}", fatal=True
).strip(), f"Default output file {expected_file} should contain step stdout"
def test_async_concurrent_steps_fan_out():
"""Several --async steps fired in succession all run and complete.
Uses --exact -n1 so each step occupies a single CPU and they do not
take whole-node exclusivity; the -N2 -n4 allocation provides 4 CPU
slots so all four async steps can run concurrently.
"""
num_steps = 4
out_files = [f"concurrent_{i}.out" for i in range(num_steps)]
marker = "concurrent_marker"
script = "concurrent_submit.sh"
srun_lines = "\n".join(
f"srun --async --parsable --exact -n1 -o {out_files[i]} "
f"bash -c 'sleep 1; echo done-{i}' >> {marker} 2>&1"
for i in range(num_steps)
)
atf.make_bash_script(
script,
f"""{srun_lines}
swait
""",
)
job_id = atf.submit_job_sbatch(
f"-N2 -n{num_steps} -t1 {script}",
fatal=True,
)
atf.wait_for_file(marker, fatal=True)
text = ""
for _t in atf.timer():
text = atf.run_command_output(f"cat {marker}", quiet=True)
if len(PARSABLE_RE.findall(text)) >= num_steps:
break
else:
pytest.fail(f"Expected {num_steps} 'Submitted step' lines, got: {text!r}")
matches = PARSABLE_RE.findall(text)
step_ids = [int(s) for _j, s in matches[:num_steps]]
assert (
len(set(step_ids)) == num_steps
), f"Each async step should get a distinct step id, got {step_ids!r}"
for sid in step_ids:
assert atf.wait_for_step_accounted(
job_id, sid, fatal=True
), f"async step {job_id}.{sid} should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
for i, out in enumerate(out_files):
atf.assert_file_contents(out, f"done-{i}")
def test_async_sattach_does_not_succeed():
"""sattach against a running --async step must not return success.
Async steps don't open the IO listener that sattach relies on, so the
expected outcomes are an explicit error or a timeout. A successful
(exit 0) sattach would indicate a regression where a non-IO step is
accidentally exposing an IO connection.
"""
out_file = "sattach.out"
job_id = atf.submit_job_sbatch(
f"-N1 -n1 -t1 --wrap 'srun --async -o {out_file} sleep 30; sleep 30'",
fatal=True,
)
assert atf.wait_for_step(
job_id, 0, fatal=True
), f"async step {job_id}.0 should appear in the step list"
# Short timeout: sattach is expected to hang on an async step.
result = atf.run_command(f"sattach {job_id}.0", timeout=10, quiet=True)
assert (
result["exit_code"] != 0
), f"sattach should not succeed against an --async step: {result!r}"
atf.run_command(f"scancel {job_id}.0", quiet=True)
def test_async_output_filename_per_task_substitution():
"""`-o foo-%t.out` produces a separate output file per task for an async step.
Filename pattern substitution is performed on the compute node when
the task starts, independent of whether srun is sync or async. With
`-n3 -O` and `-o tasks-%t.out`, three distinct files (one per task)
must be written, each containing only that task's output.
"""
num_tasks = 3
out_pattern = "tasks-%t.out"
expected_files = [f"tasks-{i}.out" for i in range(num_tasks)]
script = "per_task_subst.sh"
atf.make_bash_script(
script,
f"srun --async -n{num_tasks} -O -o {out_pattern} bash -c 'echo task-$SLURM_PROCID'\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N1 -n{num_tasks} -O -t1 {script}", fatal=True)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
for i, f in enumerate(expected_files):
atf.assert_file_contents(f, f"task-{i}")
def test_async_step_killed_when_alloc_ends():
"""An async step is terminated when its enclosing allocation tears down.
Stepmgr lives in the batch slurmstepd, so when the batch script ends,
the step manager ends with it; any in-flight async steps must be
cancelled rather than orphaned. Submits a `sleep 120` async step then
immediately exits the batch script (no keepalive). The step cannot
have COMPLETED naturally; sacct must show a non-COMPLETED terminal
state.
"""
out_file = "teardown.out"
job_id = atf.submit_job_sbatch(
f"-N1 -n1 -t1 --wrap 'srun --async -o {out_file} sleep 120'",
fatal=True,
)
atf.wait_for_step_accounted(job_id, 0, fatal=True)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
state = (
atf.run_command_output(
f"sacct -j {job_id}.0 --noheader -P -o State",
fatal=True,
)
.strip()
.splitlines()[0]
)
assert state and not state.startswith("COMPLETED"), (
f"Async step should reach a non-COMPLETED terminal state when its "
f"allocation ends; got State={state!r}"
)
def test_async_step_time_limit():
"""Step-level --time on srun --async is enforced after srun has exited.
A 1-minute step --time on a 5-minute sleep must end the step well
before the sleep would naturally complete, with sacct State=TIMEOUT.
"""
out_file = "step_time.out"
job_id = atf.submit_job_sbatch(
f"-N1 -n1 -t5 --wrap 'srun --async --time=1 -o {out_file} sleep 300; swait'",
fatal=True,
)
atf.wait_for_step_accounted(job_id, 0, timeout=90, fatal=True)
state = ""
# Step --time=1 trips TIMEOUT near 60s, beyond the 45s default.
for _t in atf.timer(90, quiet=True):
state = (
atf.run_command_output(
f"sacct -j {job_id}.0 --noheader -P -o State",
quiet=True,
)
.strip()
.splitlines()[0]
)
if state and not state.startswith(("RUNNING", "PENDING")):
break
atf.wait_for_job_state(job_id, "DONE", timeout=120, fatal=True)
assert state.startswith("TIMEOUT"), (
f"Async step with --time=1 should end as TIMEOUT well before its "
f"5-minute sleep finishes; got State={state!r}"
)
def test_async_multi_node_single_step():
"""A single async step distributed across multiple nodes runs tasks on each.
Exercises the step-layout / task-distribution path for an async step
that spans nodes, separately from the concurrent-fan-out test where
each step is single-task. Uses `-o foo-%n.out` so each node's task
writes to its own file, and emits `$SLURMD_NODENAME` (rather than
`hostname(1)`) so test environments where multiple Slurm nodes share
one OS hostname still see distinct values.
"""
out_pattern = "multinode-%n.out"
expected_files = [f"multinode-{i}.out" for i in range(2)]
script = "multinode.sh"
atf.make_bash_script(
script,
f"srun --async -N2 -n2 -o {out_pattern} bash -c 'echo $SLURMD_NODENAME'\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N2 -n2 -t1 {script}", fatal=True)
atf.wait_for_step_accounted(job_id, 0, fatal=True)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
node_names = []
for f in expected_files:
atf.wait_for_file(f, fatal=True)
text = atf.run_command_output(f"cat {f}", fatal=True).strip()
assert text, f"Per-node output {f} should contain a node name; got empty"
node_names.append(text)
assert len(set(node_names)) == 2, (
f"A 2-node async step should run tasks on two distinct Slurm nodes; "
f"got {node_names!r}"
)
def test_async_scancel_pending():
"""`scancel <jobid>.<stepid>` cancels a PENDING (queued) async step.
Exercises the async-only signal path: the pending step is removed
and a jobacct completion record is written so sacct shows CANCELLED.
"""
marker = "pending_cancel_marker"
script = "pending_cancel_submit.sh"
atf.make_bash_script(
script,
f"""srun --async --parsable --exclusive -o /dev/null sleep 10 > {marker} 2>&1
srun --async --parsable --exclusive -o /dev/null sleep 5 >> {marker} 2>&1
swait
""",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t3 {script}", fatal=True)
atf.wait_for_file(marker, fatal=True)
text = ""
for _t in atf.timer():
text = atf.run_command_output(f"cat {marker}", quiet=True)
if len(PARSABLE_RE.findall(text)) >= 2:
break
else:
pytest.fail(f"Expected two 'Submitted step' lines, got: {text!r}")
matches = PARSABLE_RE.findall(text)
queued_step = int(matches[1][1])
queued_key = f"{job_id}.{queued_step}"
saw_pending = False
# Poll fast: the queued step may be PENDING only briefly.
for _t in atf.timer(poll_interval=0.5, quiet=True):
steps = atf.get_steps(job_id, quiet=True)
if steps.get(queued_key, {}).get("State") == "PENDING":
saw_pending = True
break
assert saw_pending, (
f"{queued_key} never reached PENDING; cannot exercise the "
f"pending-async signal path"
)
res = atf.run_command(f"scancel {job_id}.{queued_step}", fatal=True)
assert (
res["exit_code"] == 0
), f"scancel of pending step {job_id}.{queued_step} should succeed: {res!r}"
gone = False
for _t in atf.timer(quiet=True):
steps = atf.get_steps(job_id, quiet=True)
if queued_key not in steps:
gone = True
break
assert gone, f"{queued_key} should be gone from scontrol after scancel"
assert atf.wait_for_step_accounted(
job_id, queued_step, fatal=True
), f"queued async step {job_id}.{queued_step} should be recorded in accounting"
state = (
atf.run_command_output(
f"sacct -j {job_id}.{queued_step} --noheader -P -o State",
quiet=True,
)
.strip()
.splitlines()[0]
)
assert state.startswith(
"CANCELLED"
), f"Queued async step should be CANCELLED in sacct; got {state!r}"
atf.wait_for_job_state(job_id, "DONE", timeout=120, fatal=True)
@pytest.fixture(scope="function")
def _mpi_ports_minimal():
"""Configure MpiParams with the minimum port range the job needs.
A 3-port range lets one step reserve 2 ports so the next async step
hits ESLURM_PORTS_BUSY on the remaining single port.
"""
atf.add_config_parameter_value("MpiParams", "ports=60000-60002")
atf.restart_slurm()
yield
atf.remove_config_parameter_value("MpiParams", "ports=60000-60002")
atf.restart_slurm()
def test_async_queued_on_resv_port_exhaustion(_mpi_ports_minimal):
"""`srun --async` queues with ESLURM_STEP_QUEUED when resv ports busy.
When `resv_port_step_alloc` returns ESLURM_PORTS_BUSY, an async step
is added to the pending queue and srun reports success; a non-async
step would return the error instead.
"""
marker = "ports_marker"
ready = "ports_ready"
script = "ports_submit.sh"
# --overlap lets both steps share the single CPU so the second step
# reaches the port-busy gate rather than queueing on CPU contention.
# The ready marker ensures step 1's ports are reserved before the
# async step runs its port-alloc.
atf.make_bash_script(
script,
f"""srun --overlap -N1 -n1 bash -c 'echo ready > {ready}; sleep 10' &
while [ ! -f {ready} ]; do sleep 0.2; done
srun --async --parsable --overlap -N1 -n1 sleep 1 > {marker} 2>&1
wait
""",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t2 {script}", fatal=True)
atf.wait_for_file(marker, fatal=True)
text = ""
for _t in atf.timer():
text = atf.run_command_output(f"cat {marker}", quiet=True)
if PARSABLE_RE.search(text):
break
m = PARSABLE_RE.search(text)
assert m, f"--async submission should succeed (queued); got {text!r}"
queued_step = int(m.group(2))
queued_key = f"{job_id}.{queued_step}"
saw_pending = False
# Poll fast: the queued step may be PENDING only briefly.
for _t in atf.timer(poll_interval=0.5, quiet=True):
steps = atf.get_steps(job_id, quiet=True)
if steps.get(queued_key, {}).get("State") == "PENDING":
saw_pending = True
break
assert saw_pending, (
f"--async on port-busy should queue PENDING, not error; "
f"got steps={atf.get_steps(job_id, quiet=True)!r}"
)
assert atf.wait_for_step_accounted(
job_id, queued_step, fatal=True
), f"queued async step {job_id}.{queued_step} should be recorded in accounting"
atf.run_command(f"scancel {job_id}", quiet=True)
atf.wait_for_job_state(job_id, "DONE", timeout=120, fatal=True)
# ---------------------------------------------------------------------------
# Output filename substitution patterns
# ---------------------------------------------------------------------------
def test_async_output_filename_pattern_j():
"""`-o foo-%j.out` embeds the numeric job id in the output filename.
%j is a common pattern in user scripts to distinguish per-job output
files; it must expand to the same job id that sbatch reported.
"""
out_pattern = "pat_j-%j.out"
script = "pat_j_submit.sh"
atf.make_bash_script(
script,
f"srun --async -o {out_pattern} bash -c 'echo jobid-$SLURM_JOB_ID'\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t1 {script}", fatal=True)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
expected = f"pat_j-{job_id}.out"
atf.assert_file_contents(expected, f"jobid-{job_id}")
def test_async_output_filename_pattern_J():
"""`-o foo-%J.out` embeds the job.step id in the output filename.
%J expands to `<jobid>.<stepid>`, giving a file that is unique per step.
"""
out_pattern = "pat_J-%J.out"
script = "pat_J_submit.sh"
atf.make_bash_script(
script,
f"srun --async -o {out_pattern} bash -c 'echo hello'\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t1 {script}", fatal=True)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
expected = f"pat_J-{job_id}.0.out"
atf.assert_file_contents(expected, "hello")
def test_async_output_filename_pattern_s():
"""`-o foo-%s.out` embeds the step id in the output filename.
Two sequential async steps produce distinct %s-expanded filenames.
"""
out_pattern = "pat_s-%s.out"
script = "pat_s_submit.sh"
marker = "pat_s_marker"
atf.make_bash_script(
script,
f"""srun --async --parsable -o {out_pattern} bash -c 'echo step-$SLURM_STEPID' >> {marker} 2>&1
srun --async --parsable -o {out_pattern} bash -c 'echo step-$SLURM_STEPID' >> {marker} 2>&1
swait
""",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t1 {script}", fatal=True)
text = ""
for _t in atf.timer():
text = atf.run_command_output(f"cat {marker}", quiet=True)
if len(PARSABLE_RE.findall(text)) >= 2:
break
else:
pytest.fail(f"Expected two parsable step-id lines, got: {text!r}")
matches = PARSABLE_RE.findall(text)
step_ids = [int(sid) for _jid, sid in matches[:2]]
for sid in step_ids:
assert atf.wait_for_step_accounted(
job_id, sid, fatal=True
), f"async step {job_id}.{sid} should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
for sid in step_ids:
expected = f"pat_s-{sid}.out"
atf.assert_file_contents(expected, f"step-{sid}")
# ---------------------------------------------------------------------------
# Resource request options
# ---------------------------------------------------------------------------
def test_async_ntasks_request():
"""An async step with -n N launches exactly N tasks.
Sync srun with -n N reliably launches N tasks; async must too.
Each task writes its SLURM_PROCID to a shared output file so we can
count the distinct task ids.
"""
num_tasks = 4
out_file = "ntasks.out"
script = "ntasks_submit.sh"
atf.make_bash_script(
script,
f"srun --async -n{num_tasks} -O -o {out_file} bash -c 'echo $SLURM_PROCID'\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N1 -n{num_tasks} -O -t1 {script}", fatal=True)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
atf.wait_for_file(out_file, fatal=True)
expected_procids = {str(i) for i in range(num_tasks)}
procids = set()
for _ in atf.timer():
procids = set(atf.run_command_output(f"cat {out_file}", fatal=True).split())
if procids == expected_procids:
break
else:
assert False, f"Expected task procids {expected_procids}, got {procids!r}"
ntasks_str = (
atf.run_command_output(
f"sacct -j {job_id}.0 --noheader -P -o NTasks", fatal=True
)
.strip()
.splitlines()[0]
)
assert ntasks_str == str(
num_tasks
), f"sacct NTasks should be {num_tasks}; got {ntasks_str!r}"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
def test_async_cpus_per_task():
"""An async step with -c N allocates N CPUs per task.
SLURM_CPUS_PER_TASK is set by the slurmstepd from the step allocation;
an async step must see the same value as a sync step.
"""
cpus = 2
out_file = "cpt.out"
script = "cpt_submit.sh"
atf.make_bash_script(
script,
f"srun --async -n1 -c{cpus} -o {out_file} bash -c 'echo $SLURM_CPUS_PER_TASK'\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -c{cpus} -t1 {script}", fatal=True)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.assert_file_contents(
out_file,
str(cpus),
message=f"SLURM_CPUS_PER_TASK should be {cpus} inside async step",
)
# ---------------------------------------------------------------------------
# SLURM environment variables
# ---------------------------------------------------------------------------
def test_async_slurm_env_vars():
"""An async step receives the standard SLURM_* environment variables.
Verifies the variables most likely to be read by user scripts:
SLURM_JOB_ID, SLURM_STEPID, SLURM_NNODES, SLURM_NTASKS,
SLURM_PROCID. All are set by slurmstepd at task launch; the async
path must not bypass that setup.
"""
num_tasks = 2
out_pattern = "envvars-%t.out"
expected_files = [f"envvars-{i}.out" for i in range(num_tasks)]
script = "envvars_submit.sh"
atf.make_bash_script(
script,
f"srun --async -N2 -n{num_tasks} -o {out_pattern} "
f"bash -c 'echo $SLURM_JOB_ID $SLURM_STEPID $SLURM_NNODES "
f"$SLURM_NTASKS $SLURM_PROCID'\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N2 -n2 -t1 {script}", fatal=True)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
lines = []
for f in expected_files:
atf.wait_for_file(f, fatal=True)
lines.append(atf.run_command_output(f"cat {f}", fatal=True).strip())
assert (
len(lines) == num_tasks
), f"Expected {num_tasks} per-task output files; got {lines!r}"
procids = set()
for line in lines:
parts = line.split()
assert len(parts) == 5, f"Expected 5 env var values per line, got: {line!r}"
jid_val, sid_val, nnodes_val, ntasks_val, procid_val = parts
assert jid_val == str(job_id), f"SLURM_JOB_ID wrong: {jid_val!r}"
assert sid_val == "0", f"SLURM_STEPID wrong: {sid_val!r}"
assert nnodes_val == "2", f"SLURM_NNODES wrong: {nnodes_val!r}"
assert ntasks_val == str(num_tasks), f"SLURM_NTASKS wrong: {ntasks_val!r}"
procids.add(procid_val)
assert procids == {
"0",
"1",
}, f"SLURM_PROCID values should be {{0,1}}; got {procids!r}"
def test_async_export_none():
"""--export=NONE suppresses user environment variables in an async step.
A variable set in the batch script's environment must not appear in the
async step's environment when --export=NONE is used.
"""
sentinel = "ASYNC_EXPORT_TEST_VAR"
out_file = "export_none.out"
script = "export_none.sh"
atf.make_bash_script(
script,
f"""export {sentinel}=should_not_appear
srun --async --export=NONE -o {out_file} \\
/bin/bash -c 'echo val=${{{sentinel}:-unset}}'
swait
""",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t1 {script}", fatal=True)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.assert_file_contents(
out_file,
"val=unset",
message=f"--export=NONE should suppress {sentinel}",
)
def test_async_export_specific_var():
"""--export=VAR=val passes a specific variable into an async step.
Covers the targeted export form: the named variable must appear in
the step environment even if it was not in the batch script's env.
"""
sentinel = "ASYNC_EXPORT_SPECIFIC_VAR"
value = "hello_from_export"
out_file = "export_specific.out"
script = "export_specific.sh"
atf.make_bash_script(
script,
f"srun --async --export={sentinel}={value} -o {out_file} "
f"/bin/bash -c 'echo ${{{sentinel}:-unset}}'\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t1 {script}", fatal=True)
assert atf.wait_for_step_accounted(
job_id, 0, fatal=True
), f"async step {job_id}.0 should be recorded in accounting"
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.assert_file_contents(
out_file,
value,
message=f"--export={sentinel}={value} should set the variable",
)
# ---------------------------------------------------------------------------
# Compute-node I/O options (--label, --unbuffered, --input)
# ---------------------------------------------------------------------------
def _submit_async_under_sbatch(
srun_args, batch_args="-N1 -n1 -t1", marker_name="srun_marker"
):
"""Submit `srun --async --parsable <srun_args>` from an sbatch script.
Returns (job_id, marker_path); the marker captures srun's stdout/stderr
(the bare "<jid>.<sid>" line). The script ends with `swait` so it exits
once the async step drains.
"""
marker = marker_name
script = f"{marker_name}.sh"
atf.make_bash_script(
script,
f"""srun --async --parsable {srun_args} > {marker} 2>&1
swait
""",
)
job_id = atf.submit_job_sbatch(f"{batch_args} {script}", fatal=True)
return job_id, marker
def _read_step_id(marker_path):
"""Wait for the parsable submit line (<jid>.<sid>) and return (jid, sid)."""
atf.wait_for_file(marker_path, fatal=True)
for _ in atf.timer(fatal=True):
text = atf.run_command_output(f"cat {marker_path}")
for line in text.splitlines():
match = PARSABLE_RE.match(line.strip())
if match:
return int(match.group(1)), int(match.group(2))
def test_async_label_applied_on_compute_node():
"""`--label` prefixes each output line with "<taskid>: ".
The labelio flag is honored by the slurmstepd that writes the output
file, so a single-task async step produces "0: <line>".
"""
out_file = "label.out"
job_id, marker = _submit_async_under_sbatch(
f"--label -o {out_file} bash -c 'echo plain'",
marker_name="label_marker",
)
_jid, step_id = _read_step_id(marker)
atf.wait_for_step_accounted(job_id, step_id, fatal=True)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.assert_file_contents(
out_file,
"0: plain",
message="--label should prefix each line with '<taskid>: '",
)
def test_async_unbuffered_accepted():
"""`--unbuffered` is accepted with `--async` and the line is written intact.
For a single-line `echo`, buffered and unbuffered output are
indistinguishable, so this only pins that the flag is accepted.
"""
out_file = "unbuffered.out"
job_id, marker = _submit_async_under_sbatch(
f"--unbuffered -o {out_file} bash -c 'echo plain'",
marker_name="unbuffered_marker",
)
_jid, step_id = _read_step_id(marker)
atf.wait_for_step_accounted(job_id, step_id, fatal=True)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.assert_file_contents(
out_file,
"plain",
message="--unbuffered output should be 'plain'",
)
def test_async_file_input_supported():
"""`--input <file>` feeds the named file into the task's stdin.
slurmstepd opens the file as the task's stdin, so file-based redirection
works for --async even though srun has no IO connection: `cat` echoes the
file's contents to the output.
"""
in_file = "input.txt"
sentinel = "INPUT_REACHES_TASK"
atf.run_command(f"echo {sentinel} > {in_file}", fatal=True)
out_file = "input_test.out"
job_id, marker = _submit_async_under_sbatch(
f"--input {in_file} -o {out_file} cat",
marker_name="input_marker",
)
_jid, step_id = _read_step_id(marker)
atf.wait_for_step_accounted(job_id, step_id, fatal=True)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.assert_file_contents(
out_file,
sentinel,
message=("--input file content should reach the task's stdin"),
)
@pytest.mark.parametrize("input_form", ["all", "0"])
def test_async_terminal_input_not_forwarded(input_form):
"""`--input=all` / `--input=<taskid>` do not forward srun's own stdin.
srun has no IO connection to an async step, so these terminal-forwarding
forms cannot deliver srun's stdin to the task. A sentinel piped into srun
must therefore never reach the output file (unlike --input=<file>).
"""
sentinel = "SHOULD_NOT_REACH_TASK"
out_file = f"no_forward_{input_form}.out"
marker = f"no_forward_{input_form}_marker"
script = f"no_forward_{input_form}.sh"
# A plain `cat` would block forever: the taskid form leaves the task's
# stdin an open pipe that detached srun never feeds, hanging the step until
# the time limit. Read with a timeout and echo back whatever, if anything,
# actually arrived.
task = """bash -c 'read -t 3 -r line; printf %s "$line"'"""
atf.make_bash_script(
script,
f"echo {sentinel} | srun --async --parsable --input={input_form}"
f" -o {out_file} {task} > {marker} 2>&1\nswait\n",
)
job_id = atf.submit_job_sbatch(f"-N1 -n1 -t1 {script}", fatal=True)
# The parsable id prints at submit time, so reaching it proves srun
# accepted --input=<form> rather than rejecting it at validation.
_jid, step_id = _read_step_id(marker)
# Step accounting proves the task actually ran, so an empty (or, for
# --input=all, absent) output file reflects stdin not being forwarded
# rather than a step that never started.
atf.wait_for_step_accounted(job_id, step_id, fatal=True)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
# Output may be empty or absent; the piped sentinel must never appear.
text = atf.run_command_output(f"cat {out_file}")
assert sentinel not in text, (
f"--input={input_form} must not forward srun's stdin to an async task; "
f"sentinel unexpectedly reached the output: {text!r}"
)