blob: 3a78ff905dbfc7406b34ce30ff465de4acc760dd [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""srun --step-dependency: one step of a job waits on other steps of it.
A step submitted with --step-dependency is admitted and queued without any
resources, and runs only once the steps it names have started or finished as
its dependency type requires. A dependency that can never be satisfied -- an
afterok on a step that failed, say -- fails the dependent step instead of
leaving it queued forever.
The four types mirror the job-level ones (see --dependency in srun.1):
after the targets have started
afterany the targets have terminated, however they ended
afterok the targets ran to completion with exit code zero
afternotok the targets terminated in some failed state
As for jobs, "," joins the whole expression with AND and "?" joins it with
OR; the two cannot be mixed.
The tests gate on observed step states and on files the tasks create, never
on sleeps: a dependent that is released early would otherwise be reported as
a pass on a slow machine.
test_162_11 covers pending steps that wait on *resources* (create-at-wake and
its FIFO order); these cover pending steps that wait on *other steps*.
"""
import json
import re
import pytest
import yaml
import atf
# A dependent must be provably blocked by its dependency rather than by a lack
# of CPUs, so the node has to be able to run two one-CPU steps at once.
MIN_NODE_CPUS = 2
# Every step takes exactly one CPU. Without --exact a step takes the whole
# node, so a second step could never run beside it and resource queueing
# would stand in for the dependency under test.
# %t writes stdout on the task node, bypassing independent srun I/O queues.
SRUN = "srun --exact -n1 --open-mode=append " '--output="$SLURM_JOB_NAME.%t.out"'
# Upper bound on srun's queued-step wait floor, see setup().
SLURMCTLD_TIMEOUT = 60
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"bin/srun",
reason="--step-dependency requires a 26.11 srun and allocation",
)
atf.require_version(
(26, 11),
"bin/scontrol",
reason="step dependency reporting requires a 26.11 scontrol",
)
atf.require_version(
(26, 11),
"sbin/slurmd",
reason="step dependencies require a 26.11 stepmgr",
)
atf.require_nodes(1, [("CPUs", MIN_NODE_CPUS)])
atf.require_config_parameter("SelectType", "select/cons_tres")
# CR_CPU so a step is gated only on CPUs: memory must not be able to make a
# step pend for a reason these tests would misread as the dependency.
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
# Dependencies are resolved by the stepmgr on the batch node.
atf.require_config_parameter_includes("SlurmctldParameters", "enable_stepmgr")
atf.require_config_parameter_includes("PrologFlags", "Contain")
# srun's queued-step wait is MAX(60, SlurmctldTimeout) plus up to 9s; pin
# it to that floor so the re-send test forces a re-send in bounded time.
atf.require_config_parameter("SlurmctldTimeout", SLURMCTLD_TIMEOUT, "<=")
atf.require_slurm_running()
def _run_script(
tag, body, expected=(), timeout=120, shape="-N1 --exclusive --mincpus=2"
):
"""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
IN timeout - seconds to wait for the job to finish
IN shape - sbatch options sizing the allocation
RET list of combined stdout/stderr lines in the order they were written
"""
script = f"{tag}.sh"
out = f"{tag}.0.out"
atf.make_bash_script(script, body)
job_id = atf.submit_job_sbatch(
f"-J {tag} -t5 {shape} --open-mode=append "
f"--output={out} --error={out} {script}",
fatal=True,
)
# Each script runs several steps in sequence, and the re-send test also
# waits out srun's queued-step timeout, so the default wait is too short.
atf.wait_for_job_state(job_id, "DONE", timeout=timeout, 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
return lines
def _assert_ordered(lines, first, second):
"""Assert both markers were written, first before second."""
assert first in lines, f"{first} never ran"
assert second in lines, f"{second} never ran"
assert lines.index(first) < lines.index(
second
), f"{second} ran before {first}, so its step dependency was not honored"
def _held_step(started, release, done, exit_code=0):
"""Return a backgrounded step that announces its start and then waits.
Start a second such step only once the first runs: step ids are given in
creation order, and the tests name the targets as .0 and .1.
IN started - file the step creates once it runs
IN release - file whose creation lets the step finish
IN done - marker the step prints as it finishes
IN exit_code - the step's exit code
RET the bash line launching the step
"""
return (
f"""{SRUN} bash -c 'touch "{started}"; until [ -f "{release}" ]; """
f"""do sleep 0.2; done; echo {done}; exit {exit_code}' &"""
)
def _wait_step_gone_snippet(step):
"""Return bash lines that wait until a step of the script's job is gone."""
return (
f'while scontrol -o show step "$SLURM_JOB_ID" 2>/dev/null | '
f'grep -q "StepId=$SLURM_JOB_ID.{step} "; do sleep 0.2; done\n'
)
# ---------------------------------------------------------------------------
# The four dependency types.
# ---------------------------------------------------------------------------
def test_afterok_waits_for_successful_target():
"""afterok releases the dependent once the target exits zero."""
lines = _run_script(
"dep_afterok",
# A is held open until B is known to be queued, so a dependent that
# ignored its dependency would be seen running while A is still alive.
f"""{_held_step("afterok_started", "afterok_release", "A_DONE")}
until [ -f afterok_started ]; do sleep 0.2; done
{SRUN} --step-dependency=afterok:$SLURM_JOB_ID.0 bash -c 'echo B_DONE' &
{atf.wait_for_step_count_snippet(2)}
touch afterok_release
wait""",
["A_DONE", "B_DONE"],
)
_assert_ordered(lines, "A_DONE", "B_DONE")
def test_after_releases_once_target_starts():
"""after releases the dependent as soon as the target starts, without
waiting for it to finish, even when the target was already running."""
lines = _run_script(
"dep_after",
# A announces its start and then stays alive until B has run, so B
# finishing first is only possible if "after" released it at A's start
# rather than at A's end.
f"""{_held_step("after_started", "after_release", "A_DONE")}
until [ -f after_started ]; do sleep 0.2; done
{SRUN} --step-dependency=after:$SLURM_JOB_ID.0 bash -c 'echo B_DONE; touch after_release' &
wait""",
["A_DONE", "B_DONE"],
)
_assert_ordered(lines, "B_DONE", "A_DONE")
def test_after_waits_for_pending_target_to_start():
"""after waits for a queued target to start, not merely become eligible."""
lines = _run_script(
"dep_after_pending",
# B needs both CPUs, so the holder blocks it even after A succeeds.
# C overlaps B to run alongside it once B actually starts.
f"""{_held_step("after_pending_started", "after_pending_release", "A_DONE")}
until [ -f after_pending_started ]; do sleep 0.2; done
{_held_step("after_holder_started", "after_holder_release", "HOLDER_DONE")}
until [ -f after_holder_started ]; do sleep 0.2; done
{SRUN} -c2 --step-dependency=afterok:$SLURM_JOB_ID.0 bash -c 'until [ -f after_pending_release_b ]; do sleep 0.2; done; echo B_DONE' &
{atf.wait_for_step_snippet(2)}{SRUN} --overlap --step-dependency=after:$SLURM_JOB_ID.2 bash -c 'scontrol -o show step "$SLURM_JOB_ID.2" > after_target_running; echo C_DONE; touch after_pending_release_b' &
{atf.wait_for_step_snippet(3)}touch after_pending_release
{_wait_step_gone_snippet(0)}until scontrol -o show step "$SLURM_JOB_ID.2" | grep -q 'Reason=Resources'; do sleep 0.2; done
scontrol -o show step $SLURM_JOB_ID.2 > after_target_pending
scontrol -o show step $SLURM_JOB_ID.3 > after_pending_state
touch after_holder_release
wait""",
["A_DONE", "HOLDER_DONE", "B_DONE", "C_DONE"],
shape="-N1 -n2",
)
atf.assert_file_contents("after_target_pending", "State=PENDING", contains=True)
atf.assert_file_contents("after_target_pending", "Reason=Resources", contains=True)
atf.assert_file_contents("after_pending_state", "State=PENDING", contains=True)
atf.assert_file_contents("after_pending_state", "Reason=Dependency ", contains=True)
atf.assert_file_contents("after_target_running", "State=RUNNING", contains=True)
_assert_ordered(lines, "A_DONE", "C_DONE")
_assert_ordered(lines, "HOLDER_DONE", "C_DONE")
_assert_ordered(lines, "C_DONE", "B_DONE")
def test_afternotok_waits_for_failed_target():
"""afternotok releases the dependent when the target exits non-zero, and
an afterok on the same failed target never runs."""
lines = _run_script(
"dep_afternotok",
f"""{_held_step("afternotok_started", "afternotok_release", "A_FAILED", 1)}
until [ -f afternotok_started ]; do sleep 0.2; done
{SRUN} --step-dependency=afternotok:$SLURM_JOB_ID.0 bash -c 'echo CLEANUP_RAN' &
{{ {SRUN} --step-dependency=afterok:$SLURM_JOB_ID.0 bash -c 'echo SHOULD_NOT_RUN'; echo "AFTEROK_RC=$?"; }} &
{atf.wait_for_step_count_snippet(3)}
touch afternotok_release
wait""",
["A_FAILED", "CLEANUP_RAN", "AFTEROK_RC="],
)
_assert_ordered(lines, "A_FAILED", "CLEANUP_RAN")
assert (
"SHOULD_NOT_RUN" not in lines
), "an afterok dependent ran even though its target failed"
assert (
"AFTEROK_RC=0" not in lines
), "srun reported success for a step that never ran"
@pytest.mark.parametrize("depend_type", ["afterany:", ""], ids=["explicit", "default"])
@pytest.mark.parametrize("target_exit", [0, 1], ids=["success", "failure"])
def test_afterany_runs_after_target(depend_type, target_exit):
"""Explicit and default afterany wait for termination, regardless of success."""
lines = _run_script(
"dep_afterany",
f"""{_held_step("afterany_started", "afterany_release", "A_DONE", target_exit)}
until [ -f afterany_started ]; do sleep 0.2; done
{SRUN} --step-dependency={depend_type}$SLURM_JOB_ID.0 bash -c 'echo B_DONE' &
{atf.wait_for_step_count_snippet(2)}
touch afterany_release
wait""",
["A_DONE", "B_DONE"],
)
_assert_ordered(lines, "A_DONE", "B_DONE")
# ---------------------------------------------------------------------------
# Shared job/step dependency decisions (test37.17's local dependency cases).
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("dependency_scope", ["job", "step"])
@pytest.mark.parametrize(
"dependency_type, target_exit, should_run",
[
("after", 0, True),
("after", 1, True),
("afterany", 0, True),
("afterany", 1, True),
("afterok", 0, True),
("afterok", 1, False),
("afternotok", 0, False),
("afternotok", 1, True),
],
)
def test_job_and_step_dependencies_share_decisions(
dependency_scope, dependency_type, target_exit, should_run
):
"""Jobs and steps share waiting/release rules, not impossible final states."""
atf.make_bash_script(
"parity_target.sh",
"echo STARTED > parity_target_started\n"
"until [ -f parity_target_release ]; do sleep 0.2; done\n"
f"echo TARGET_DONE\nexit {target_exit}",
)
atf.make_bash_script(
"parity_dependent.sh", "echo DEPENDENT_RAN\necho DONE > parity_dependent_done"
)
if dependency_scope == "job":
# One CPU per job leaves room for an after dependent beside its target.
job_options = (
"-N1 -n1 -t2 --output=dep_parity.out --error=dep_parity.out "
"--open-mode=append"
)
target_id = atf.submit_job_sbatch(f"{job_options} parity_target.sh", fatal=True)
atf.assert_file_contents("parity_target_started", "STARTED")
dependent_id = atf.submit_job_sbatch(
f"{job_options} --dependency={dependency_type}:{target_id} "
"--kill-on-invalid-dep=no parity_dependent.sh",
fatal=True,
)
if dependency_type == "after":
atf.assert_file_contents("parity_dependent_done", "DONE")
else:
atf.wait_for_job_state(
dependent_id, "PENDING", desired_reason="Dependency", fatal=True
)
atf.run_command("touch parity_target_release", fatal=True)
atf.wait_for_job_state(target_id, "DONE", fatal=True)
if should_run:
atf.wait_for_job_state(dependent_id, "DONE", fatal=True)
atf.assert_file_contents("dep_parity.out", "DEPENDENT_RAN", contains=True)
else:
atf.wait_for_job_state(
dependent_id,
"PENDING",
desired_reason="DependencyNeverSatisfied",
fatal=True,
)
atf.assert_file_contents("dep_parity.out", "TARGET_DONE", contains=True)
lines = atf.run_command_output("cat dep_parity.out", fatal=True).splitlines()
else:
if dependency_type == "after":
before_release = "until [ -f parity_dependent_done ]; do sleep 0.2; done\n"
else:
before_release = (
atf.wait_for_step_snippet(1)
+ "scontrol -o show step $SLURM_JOB_ID.1 > parity_state\n"
)
lines = _run_script(
"dep_parity",
f"""{SRUN} ./parity_target.sh &
until [ -f parity_target_started ]; do sleep 0.2; done
{{ {SRUN} --step-dependency={dependency_type}:$SLURM_JOB_ID.0 ./parity_dependent.sh; echo "DEPENDENT_RC=$?"; }} &
{before_release}touch parity_target_release
wait""",
[
"TARGET_DONE",
"DEPENDENT_RC=",
"DEPENDENT_RAN" if should_run else "can never be satisfied",
],
)
if dependency_type != "after":
atf.assert_file_contents("parity_state", "State=PENDING", contains=True)
atf.assert_file_contents(
"parity_state", "Reason=Dependency ", contains=True
)
if not should_run:
assert "DEPENDENT_RC=0" not in lines, "an impossible step reported success"
assert any("can never be satisfied" in line for line in lines), (
"an impossible step should report dependency failure, "
f"got: {lines!r}"
)
assert ("DEPENDENT_RAN" in lines) == should_run, (
f"{dependency_scope} {dependency_type} on exit {target_exit}: "
f"expected should_run={should_run}, got: {lines!r}"
)
if should_run:
if dependency_type == "after":
_assert_ordered(lines, "DEPENDENT_RAN", "TARGET_DONE")
else:
_assert_ordered(lines, "TARGET_DONE", "DEPENDENT_RAN")
# ---------------------------------------------------------------------------
# The dependent is blocked by its dependency, not by resources.
# ---------------------------------------------------------------------------
def test_dependent_pends_while_cpus_are_free():
"""A dependent stays PENDING with free CPUs, holding no resources.
This is the property that separates a step dependency from the existing
resource-busy queueing: the node can run both steps at once, so the only
thing keeping the dependent queued is its dependency.
"""
steps = "free_steps"
lines = _run_script(
"dep_free",
# A takes one CPU of at least two, so B could run immediately if it
# were not waiting on A.
f"""{_held_step("free_started", "free_release", "A_DONE")}
until [ -f free_started ]; do sleep 0.2; done
{SRUN} --step-dependency=afterok:$SLURM_JOB_ID.0 bash -c 'echo B_DONE' &
{atf.wait_for_step_count_snippet(2)}
scontrol -o show step $SLURM_JOB_ID > '{steps}' 2>&1
touch free_release
wait""",
["A_DONE", "B_DONE"],
)
_assert_ordered(lines, "A_DONE", "B_DONE")
atf.assert_file_contents(steps, "StepId=", contains=True)
steps_text = atf.run_command_output(f"cat {steps}", fatal=True)
dependent = [line for line in steps_text.splitlines() if "StepDependency=" in line]
assert dependent, (
"the dependent step should report the expression it was submitted "
f"with, got: {steps_text!r}"
)
assert re.search(r"\bState=PENDING\b", dependent[0]), (
"the dependent should still be PENDING while its target runs, got: "
f"{dependent[0]!r}"
)
assert re.search(r"\bReason=Dependency\b", dependent[0]), (
"the dependent should wait on its dependency, not resources, got: "
f"{dependent[0]!r}"
)
assert re.search(
r"\bStepDependency=afterok:\d+\.0\b", dependent[0]
), f"the dependency expression should be reported, got: {dependent[0]!r}"
# ---------------------------------------------------------------------------
# AND / OR across several targets.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"expression, target_exit",
[
("afterok:$SLURM_JOB_ID.0:$SLURM_JOB_ID.1", 0),
("afterok:$SLURM_JOB_ID.0,afterok:$SLURM_JOB_ID.1", 0),
("$SLURM_JOB_ID.0:$SLURM_JOB_ID.1", 1),
("$SLURM_JOB_ID.0,$SLURM_JOB_ID.1", 1),
("afterok:$SLURM_JOB_ID.0,$SLURM_JOB_ID.1", 1),
],
ids=["colon_list", "comma", "default_colon", "default_comma", "mixed_types"],
)
def test_and_waits_for_every_target(expression, target_exit):
"""A ":"-joined target list and a ","-separated expression both wait for
every target."""
state = "and_state"
lines = _run_script(
"dep_and",
# Once A is gone, C must still be PENDING on B, before B is released.
f"""{_held_step("and_started_a", "and_release_a", "A_DONE")}
until [ -f and_started_a ]; do sleep 0.2; done
{_held_step("and_started_b", "and_release_b", "B_DONE", target_exit)}
until [ -f and_started_b ]; do sleep 0.2; done
{SRUN} --step-dependency={expression} bash -c 'echo C_DONE' &
{atf.wait_for_step_count_snippet(3)}
touch and_release_a
{_wait_step_gone_snippet(0)}scontrol -o show step $SLURM_JOB_ID.2 > '{state}' 2>&1
touch and_release_b
wait""",
["A_DONE", "B_DONE", "C_DONE"],
)
_assert_ordered(lines, "A_DONE", "C_DONE")
_assert_ordered(lines, "B_DONE", "C_DONE")
state_text = atf.run_command_output(f"cat {state}", fatal=True)
assert re.search(
r"\bState=PENDING\b", state_text
), f"C should still wait on B once A is gone, got: {state_text!r}"
@pytest.mark.parametrize(
"expression, target_exit",
[
("afterok:$SLURM_JOB_ID.0?afterok:$SLURM_JOB_ID.1", 0),
("$SLURM_JOB_ID.0?$SLURM_JOB_ID.1", 1),
("afterok:$SLURM_JOB_ID.0?$SLURM_JOB_ID.1", 1),
],
ids=["explicit", "default", "mixed_types"],
)
def test_or_releases_on_first_satisfied_target(expression, target_exit):
"""A "?"-separated expression is released by its first satisfied target,
without waiting for the others to resolve."""
lines = _run_script(
"dep_or_first",
# A is held until C has run, so C can only finish first if B alone
# released it.
f"""{_held_step("or_first_started_a", "or_first_release_a", "A_DONE")}
until [ -f or_first_started_a ]; do sleep 0.2; done
{_held_step("or_first_started_b", "or_first_release_b", "B_DONE", target_exit)}
until [ -f or_first_started_b ]; do sleep 0.2; done
{SRUN} --step-dependency={expression} bash -c 'echo C_DONE; touch or_first_release_a' &
{atf.wait_for_step_count_snippet(3)}
touch or_first_release_b
wait""",
["A_DONE", "B_DONE", "C_DONE"],
)
_assert_ordered(lines, "B_DONE", "C_DONE")
_assert_ordered(lines, "C_DONE", "A_DONE")
def test_or_survives_a_failed_edge():
"""A failed edge alone does not condemn a "?"-separated expression while
another target may still succeed."""
lines = _run_script(
"dep_or_failed",
# A fails first, while B is still held; C must then still run once B
# exits zero.
f"""{_held_step("or_failed_started_a", "or_failed_release_a", "A_FAILED", 1)}
until [ -f or_failed_started_a ]; do sleep 0.2; done
{_held_step("or_failed_started_b", "or_failed_release_b", "B_DONE")}
until [ -f or_failed_started_b ]; do sleep 0.2; done
{SRUN} --step-dependency=afterok:$SLURM_JOB_ID.0?afterok:$SLURM_JOB_ID.1 bash -c 'echo C_DONE' &
{atf.wait_for_step_count_snippet(3)}
touch or_failed_release_a
until grep -q A_FAILED "$SLURM_JOB_NAME.0.out" 2>/dev/null; do sleep 0.2; done
{_wait_step_gone_snippet(0)}touch or_failed_release_b
wait""",
["A_FAILED", "B_DONE", "C_DONE"],
)
_assert_ordered(lines, "A_FAILED", "C_DONE")
_assert_ordered(lines, "B_DONE", "C_DONE")
@pytest.mark.parametrize("separator", [",", "?"], ids=["and", "or"])
def test_multi_target_failure_timing(separator):
"""AND fails on the first failed edge; OR waits until all edges fail."""
if separator == ",":
after_first_failure = (
"until [ -f failure_dependent_done ]; do sleep 0.2; done\n"
)
else:
after_first_failure = (
"scontrol -o show step $SLURM_JOB_ID.2 > failure_pending_state\n"
)
lines = _run_script(
"dep_failure_timing",
f"""{_held_step("failure_started_a", "failure_release_a", "A_DONE", 1)}
until [ -f failure_started_a ]; do sleep 0.2; done
{_held_step("failure_started_b", "failure_release_b", "B_DONE", 1)}
until [ -f failure_started_b ]; do sleep 0.2; done
{{ {SRUN} --step-dependency=afterok:$SLURM_JOB_ID.0{separator}afterok:$SLURM_JOB_ID.1 echo SHOULD_NOT_RUN; echo "C_RC=$?"; touch failure_dependent_done; }} &
{atf.wait_for_step_snippet(2)}touch failure_release_a
{_wait_step_gone_snippet(0)}{after_first_failure}touch failure_release_b
wait""",
["A_DONE", "B_DONE", "C_RC=", "can never be satisfied"],
)
assert "SHOULD_NOT_RUN" not in lines, "an impossible dependent ran"
assert "C_RC=0" not in lines, "an impossible dependent reported success"
assert any(
"can never be satisfied" in line for line in lines
), f"the failure should be a dependency failure, got: {lines!r}"
dependent_result = next(line for line in lines if line.startswith("C_RC="))
_assert_ordered(lines, "A_DONE", dependent_result)
if separator == ",":
_assert_ordered(lines, dependent_result, "B_DONE")
else:
atf.assert_file_contents(
"failure_pending_state", "State=PENDING", contains=True
)
atf.assert_file_contents(
"failure_pending_state", "Reason=Dependency ", contains=True
)
_assert_ordered(lines, "B_DONE", dependent_result)
# ---------------------------------------------------------------------------
# A dependency that can never be satisfied.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"depend_type, target_exit",
[("afterok", 1), ("afternotok", 0)],
ids=["afterok_on_failure", "afternotok_on_success"],
)
def test_impossible_dependency_fails_the_step(depend_type, target_exit):
"""A dependency that can never be met fails the dependent rather than
leaving it queued, reports it as FAILED, and releases that dependent's
own afterany successor."""
tag = f"dep_impossible_{depend_type}"
swait_out = f"{tag}.swait"
swait_err = f"{tag}.swait_err"
lines = _run_script(
tag,
# C chains off B, which can never run. B's failure must propagate, or
# C would hang until the job's time limit.
f"""{_held_step(f"{tag}_started", f"{tag}_release", "A_ENDED", target_exit)}
until [ -f {tag}_started ]; do sleep 0.2; done
{{ {SRUN} --step-dependency={depend_type}:$SLURM_JOB_ID.0 bash -c 'echo B_SHOULD_NOT_RUN'; echo "B_RC=$?"; }} &
{atf.wait_for_step_count_snippet(2)}
{SRUN} --step-dependency=afterany:$SLURM_JOB_ID.1 bash -c 'echo C_DONE' &
{atf.wait_for_step_count_snippet(3)}
swait -v --timeout=60 $SLURM_JOB_ID.1 > {swait_out} 2> {swait_err} & SW=$!
{atf.swait_await_subscribe(swait_err, quote='"')}
touch {tag}_release
wait $SW
wait""",
["A_ENDED", "B_RC=", "C_DONE"],
)
assert "B_SHOULD_NOT_RUN" not in lines, "a step whose dependency failed ran"
assert "B_RC=0" not in lines, "srun reported success for a step that never ran"
assert (
"C_DONE" in lines
), "an afterany dependent was not released by its target's dependency failure"
swait_text = atf.run_command_output(f"cat {swait_out}", fatal=True)
assert re.search(
r"StepId=\d+\.1 never-launched reason=FAILED\b", swait_text
), f"the failed dependent should be reported as FAILED, got: {swait_text!r}"
def test_immediate_fails_an_unsatisfied_dependency():
"""With --immediate, an unsatisfied dependency fails at once instead of
queueing the step."""
lines = _run_script(
"dep_immediate",
f"""{_held_step("immediate_started", "immediate_release", "A_DONE")}
until [ -f immediate_started ]; do sleep 0.2; done
{SRUN} -I --step-dependency=afterok:$SLURM_JOB_ID.0 bash -c 'echo SHOULD_NOT_RUN'
echo "IMMEDIATE_RC=$?"
touch immediate_release
wait""",
["IMMEDIATE_RC=", "A_DONE"],
)
assert "SHOULD_NOT_RUN" not in lines, "an --immediate dependent ran"
assert "IMMEDIATE_RC=0" not in lines, "an --immediate dependent did not fail"
rc_line = next(line for line in lines if line.startswith("IMMEDIATE_RC="))
# It failed while A was still held, rather than waiting A out.
_assert_ordered(lines, rc_line, "A_DONE")
assert any(
"Step dependency not yet satisfied" in line for line in lines
), f"srun should say the dependency was not satisfied, got: {lines!r}"
@pytest.mark.parametrize(
"separator, should_run",
[(",", False), ("?", True)],
ids=["and_rejected", "or_accepted"],
)
def test_contradictory_dependency(separator, should_run):
"""afterok and afternotok on one target can never both hold, so ANDed
they fail at submission while the target still runs; ORed they run once
the target ends."""
expression = f"afterok:$SLURM_JOB_ID.0{separator}afternotok:$SLURM_JOB_ID.0"
lines = _run_script(
"dep_contrary",
f"""{_held_step("contrary_started", "contrary_release", "A_DONE")}
until [ -f contrary_started ]; do sleep 0.2; done
{SRUN} --async --step-dependency={expression} bash -c 'echo DEPENDENT_RAN'
echo "SUBMIT_RC=$?"
touch contrary_release
wait
{_wait_step_gone_snippet(1)}""",
["SUBMIT_RC=", "A_DONE"] + (["DEPENDENT_RAN"] if should_run else []),
)
rc_line = next(line for line in lines if line.startswith("SUBMIT_RC="))
# Decided while the target was still held, not when it ended.
_assert_ordered(lines, rc_line, "A_DONE")
if should_run:
assert rc_line == "SUBMIT_RC=0", f"the ORed pair was rejected: {lines!r}"
_assert_ordered(lines, "A_DONE", "DEPENDENT_RAN")
else:
assert rc_line != "SUBMIT_RC=0", "the ANDed pair was accepted"
assert "DEPENDENT_RAN" not in lines, "the ANDed pair ran"
assert any(
"can never be satisfied" in line for line in lines
), f"srun should say the dependency can never be satisfied: {lines!r}"
def test_nested_srun_does_not_inherit_dependency():
"""The dependency is not exported, so an srun inside the dependent step
does not name the (by then finished) target."""
lines = _run_script(
"dep_nested",
f"""{_held_step("nested_started", "nested_release", "A_DONE")}
until [ -f nested_started ]; do sleep 0.2; done
{SRUN} --step-dependency=afterok:$SLURM_JOB_ID.0 bash -c 'srun --overlap -n1 echo NESTED_OK' &
{atf.wait_for_step_count_snippet(2)}
touch nested_release
wait""",
["A_DONE", "NESTED_OK"],
)
_assert_ordered(lines, "A_DONE", "NESTED_OK")
# ---------------------------------------------------------------------------
# Rejected expressions.
# ---------------------------------------------------------------------------
# Each case is exercised from inside a real allocation, against that
# allocation's own job id, so the expression reaches the parser rather than
# being turned away for having no allocation to name steps of. $JOB is
# substituted with the running job id.
@pytest.mark.parametrize(
"case, expression",
[
("missing_target", "afterok:"),
("target_without_step", "afterok:$JOB"),
("trailing_separator", "afterok:$JOB."),
("trailing_junk", "afterok:$JOB.0junk"),
("unsupported_type", "bogus:$JOB.0"),
("unsupported_aftercorr", "aftercorr:$JOB.0"),
("unsupported_afterburstbuffer", "afterburstbuffer:$JOB.0"),
("unsupported_expand", "expand:$JOB.0"),
("unsupported_singleton", "singleton"),
("target_does_not_exist", "afterok:$JOB.999"),
("target_in_another_job", "afterok:99999999.0"),
("mixed_separators", "afterok:$JOB.0,afterany:$JOB.0?afterok:$JOB.0"),
("default_without_step", "$JOB"),
("default_trailing_junk", "$JOB.0junk"),
("default_mixed_separators", "$JOB.0,$JOB.0?$JOB.0"),
("unknown_outcome_in_or", "$JOB.999?afterok:$JOB.998"),
("signed_job_id", "afterok:+$JOB.0"),
("signed_step_id", "afterok:$JOB.+0"),
("negative_zero_step_id", "afterok:$JOB.-0"),
("job_id_whitespace", "afterok: $JOB.0"),
("step_id_whitespace", "afterok:$JOB. 0"),
("job_id_overflow", "afterok:$(($JOB + 4294967296)).0"),
("step_id_overflow", "afterok:$JOB.4294967296"),
("after_delay", "after:$JOB.0+1"),
("het_step_id", "afterok:$JOB.0+0"),
("empty_het_step_component", "afterok:$JOB.0+"),
("array_target", "afterok:$JOB_0.0"),
("het_job_target", "afterok:$JOB+0.0"),
("special_step_target", "afterok:$JOB.batch"),
("extern_step_target", "afterok:$JOB.extern"),
],
)
def test_invalid_expression_is_rejected(case, expression):
"""An expression that cannot be honored is refused at submission rather
than accepted as a step that silently never waits."""
# $JOB becomes the shell's own job id inside the script, so the expression
# names this allocation and reaches the parser.
expanded = expression.replace("$JOB", "${SLURM_JOB_ID}")
# Step .0 is left running so a rejection cannot be blamed on the target
# having already finished; only the malformed cases should be refused.
lines = _run_script(
f"dep_invalid_{case}",
f"""{_held_step(f"dep_invalid_started_{case}", f"dep_invalid_release_{case}", "A_DONE")}
until [ -f dep_invalid_started_{case} ]; do sleep 0.2; done
{SRUN} --step-dependency="{expanded}" true
echo "RC=$?"
touch dep_invalid_release_{case}
wait""",
["RC="],
)
rc_lines = [line for line in lines if line.startswith("RC=")]
assert rc_lines, f"the step submission did not report a result: {lines!r}"
assert (
rc_lines[0] != "RC=0"
), f"--step-dependency={expression} should be rejected, got exit 0"
assert any(
"Job dependency problem" in line for line in lines
), f"the rejection should be a dependency error, got: {lines!r}"
@pytest.mark.parametrize("target_exit", [0, 1], ids=["success", "failure"])
@pytest.mark.parametrize("target_step", [0, 99], ids=["finished", "unknown"])
def test_missing_target_matches_job_dependencies(target_exit, target_step):
"""Missing targets satisfy after/afterany but reject outcome-based types."""
target = f"$SLURM_JOB_ID.{target_step}"
lines = _run_script(
"dep_missing",
f"""{SRUN} bash -c 'exit {target_exit}'
{_wait_step_gone_snippet(0)}{SRUN} --step-dependency=after:{target} echo AFTER_RAN
{SRUN} --step-dependency=afterany:{target} echo AFTERANY_RAN
{SRUN} --step-dependency={target} echo DEFAULT_RAN
{SRUN} --step-dependency=afterok:{target} echo AFTEROK_SHOULD_NOT_RUN
echo "AFTEROK_RC=$?"
{SRUN} --step-dependency=afternotok:{target} echo AFTERNOTOK_SHOULD_NOT_RUN
echo "AFTERNOTOK_RC=$?"
""",
["AFTER_RAN", "AFTERANY_RAN", "DEFAULT_RAN", "AFTEROK_RC=", "AFTERNOTOK_RC="],
)
for marker in ("AFTER_RAN", "AFTERANY_RAN", "DEFAULT_RAN"):
assert (
marker in lines
), f"a dependency on a missing target did not run: {lines!r}"
for depend_type in ("AFTEROK", "AFTERNOTOK"):
assert (
f"{depend_type}_SHOULD_NOT_RUN" not in lines
), f"{depend_type} on a missing target ran"
assert (
f"{depend_type}_RC=0" not in lines
), f"{depend_type} on a missing target was accepted"
assert any(
"Job dependency problem" in line for line in lines
), f"a missing outcome should be a dependency error, got: {lines!r}"
@pytest.mark.parametrize("output_format", ["json", "yaml"])
@pytest.mark.parametrize("complex_values", [False, True], ids=["simple", "complex"])
def test_step_dependency_shows_job_state_reasons(output_format, complex_values):
"""Text and both parser modes use the same state reasons as jobs."""
parser_options = "v0.0.46" + ("+complex" if complex_values else "")
w_start = "$PWD/verdict_wait_started"
w_release = "$PWD/verdict_wait_release"
r_start = "$PWD/verdict_ready_started"
r_release = "$PWD/verdict_ready_release"
dep_done = "$PWD/verdict_dep_done"
dep_release = "$PWD/verdict_dep_release"
lines = _run_script(
"dep_verdict",
f"""rm -f {w_start} {w_release} {r_start} {r_release} {dep_done} {dep_release}
# .0 holds a CPU so .1 can only wait on its dependency, not on resources
{SRUN} --async bash -c 'touch {w_start}; until [ -f {w_release} ]; do sleep 0.2; done' &
until [ -f {w_start} ]; do sleep 0.2; done
{SRUN} --async --step-dependency=afterok:$SLURM_JOB_ID.0 true &
until scontrol -o show step "$SLURM_JOB_ID" | grep "StepId=$SLURM_JOB_ID.1 " | grep -q "Reason=Dependency "; do sleep 0.2; done
echo SAW_WAITING
scontrol --{output_format}={parser_options} show step "$SLURM_JOB_ID" > verdict_waiting.{output_format} || echo VERDICT_DUMP_FAILED
touch {w_release}
{_wait_step_gone_snippet(0)}{_wait_step_gone_snippet(1)}# .2 holds every CPU, so .3 with an already-satisfied dependency waits on
# resources and reports ready
srun --exact -n2 --async bash -c 'touch {r_start}; until [ -f {r_release} ]; do sleep 0.2; done' &
until [ -f {r_start} ]; do sleep 0.2; done
{SRUN} --async --step-dependency=afterany:$SLURM_JOB_ID.0 bash -c 'touch {dep_done}; until [ -f {dep_release} ]; do sleep 0.2; done' &
until scontrol -o show step "$SLURM_JOB_ID" | grep "StepId=$SLURM_JOB_ID.3 " | grep -q "Reason=Resources "; do sleep 0.2; done
echo SAW_READY
scontrol --{output_format}={parser_options} show step "$SLURM_JOB_ID" > verdict_ready.{output_format} || echo VERDICT_DUMP_FAILED
touch {r_release}
until [ -f {dep_done} ]; do sleep 0.2; done
scontrol -o show step "$SLURM_JOB_ID.3"
scontrol --{output_format}={parser_options} show step "$SLURM_JOB_ID.3" > verdict_running.{output_format} || echo VERDICT_DUMP_FAILED
echo DEP_RAN
touch {dep_release}
{_wait_step_gone_snippet(3)}""",
["SAW_WAITING", "SAW_READY", "DEP_RAN"],
shape="-N1 -n2",
)
assert "SAW_WAITING" in lines, "a dependency-blocked step never read waiting"
assert "SAW_READY" in lines, "a resource-blocked step never read ready"
assert "DEP_RAN" in lines, "the satisfied dependent never ran"
assert (
"VERDICT_DUMP_FAILED" not in lines
), f"scontrol structured output failed: {lines!r}"
running = [line for line in lines if "StepDependency=" in line]
assert running, "the running step should retain its dependency expression"
assert all(
"Reason=None " in line for line in running
), f"a running step should have no pending state reason: {running!r}"
parse_output = json.loads if output_format == "json" else yaml.safe_load
for phase, expected_states in (
("waiting", {"0": "None", "1": "Dependency"}),
("ready", {"2": "None", "3": "Resources"}),
("running", {"3": "None"}),
):
text = atf.run_command_output(
f"cat verdict_{phase}.{output_format}", fatal=True
)
steps = {
step["id"].rsplit(".", 1)[1]: step for step in parse_output(text)["steps"]
}
for step_number, expected in expected_states.items():
assert (
"state_reason" in steps[step_number]
), f"{output_format} {phase} step {step_number} omitted state reason"
actual = steps[step_number]["state_reason"]
assert actual == expected, (
f"{output_format} {phase} step {step_number}: "
f"expected {expected!r}, got {actual!r}"
)
if phase == "running":
assert steps["3"][
"step_dependency"
], "running step must retain expression after dependencies are satisfied"
def test_step_dependency_requires_an_allocation():
"""The option names steps of an existing job, so it cannot be used by an
srun that would create its own allocation."""
result = atf.run_command(
"env -u SLURM_JOB_ID -u SLURM_JOBID "
"srun -n1 --step-dependency=afterok:1.0 true",
quiet=True,
)
assert result["exit_code"] != 0, (
"--step-dependency without an existing allocation should be rejected, "
"got exit 0"
)
assert "allocation" in result["stderr"], (
"the error should say an allocation is required, got: " f"{result['stderr']!r}"
)
# srun turns these away while parsing options, before it contacts anything, so
# the job id they name need not exist.
@pytest.mark.parametrize(
"args",
[
"--interactive -n1 {dep} true",
"--external-launcher -n1 {dep} true",
"--het-group=0 -n1 {dep} true",
"-Z -w localhost -n1 {dep} true",
"-n1 {dep} true : -n1 true",
],
ids=["interactive", "external_launcher", "het_group", "no_alloc", "colon_het"],
)
def test_srun_rejects_unsupported_step(args):
"""srun refuses --step-dependency on a step kind it cannot work for."""
dep = "--step-dependency=afterok:1.0"
result = atf.run_command(
f"srun {args.format(dep=dep)}",
env_vars="SLURM_JOB_ID=1",
quiet=True,
)
assert result["exit_code"] != 0, f"srun {args} should be rejected, got exit 0"
assert (
"--step-dependency" in result["stderr"]
), f"the error should name --step-dependency, got: {result['stderr']!r}"
@pytest.mark.parametrize("depend_type", ["after", "afterany", "afterok", "afternotok"])
def test_het_target_is_rejected(depend_type):
"""Reject numeric references to heterogeneous steps in ordinary jobs."""
atf.require_nodes(2, [("CPUs", MIN_NODE_CPUS)])
atf.require_config_parameter_excludes("SchedulerParameters", "disable_hetjob_steps")
atf.require_config_parameter_excludes("SchedulerParameters", "disable_hetero_steps")
lines = _run_script(
"dep_het_target",
# Immediate mode bounds a broken wait; overlap avoids resource rejection.
f"""srun --exact -N1 -n1 --mpi=none bash -c 'touch het_target_started_a; until [ -f het_target_release ]; do sleep 0.2; done' : --exact -N1 -n1 --mpi=none bash -c 'touch het_target_started_b; until [ -f het_target_release ]; do sleep 0.2; done' &
until [ -f het_target_started_a ] && [ -f het_target_started_b ]; do sleep 0.2; done
{SRUN} -N1 -I --overlap --step-dependency={depend_type}:$SLURM_JOB_ID.0 echo SHOULD_NOT_RUN
echo "RC=$?"
touch het_target_release
wait""",
["RC="],
shape="-N2 --exclusive",
)
assert "SHOULD_NOT_RUN" not in lines, "a dependency on a het target ran"
assert "RC=0" not in lines, "a dependency on a het target was accepted"
assert any(
"Job dependency problem" in line for line in lines
), f"the het target should be rejected as an invalid dependency, got: {lines!r}"
def test_server_rejects_het_job():
"""The server refuses a dependency in a het job even when srun sends it.
A plain srun in a het allocation is not a het step, so srun lets it
through; only the server-side check stands between it and running
unconstrained. The target is left running so a parser rejection cannot
stand in for the missing server check."""
# The scheduler places each het component on its own node.
atf.require_nodes(2, [("CPUs", MIN_NODE_CPUS)])
lines = _run_script(
"dep_het_job",
f"""{_held_step("het_started", "het_release", "A_DONE")}
until [ -f het_started ]; do sleep 0.2; done
{SRUN} -I --overlap --step-dependency=after:$SLURM_JOB_ID.0 bash -c 'echo SHOULD_NOT_RUN'
echo "RC=$?"
touch het_release
wait""",
["RC="],
shape="-N1 -n1 : -N1 -n1",
)
assert "SHOULD_NOT_RUN" not in lines, "a dependency in a het job ran"
assert "RC=0" not in lines, "srun reported success for a refused step"
assert any(
"not supported" in line for line in lines
), f"the server should refuse the dependency as unsupported, got: {lines!r}"
# ---------------------------------------------------------------------------
# Cancellation and chains.
# ---------------------------------------------------------------------------
def test_cancelled_target_resolves_every_type():
"""A cancelled target fails afterok and releases afternotok and afterany."""
lines = _run_script(
"dep_cancel",
f"""{SRUN} bash -c 'touch cancel_started; exec sleep infinity' &
until [ -f cancel_started ]; do sleep 0.2; done
{{ {SRUN} --step-dependency=afterok:$SLURM_JOB_ID.0 bash -c 'echo OK_RAN'; echo "OK_RC=$?"; }} &
{atf.wait_for_step_snippet(1)}
{SRUN} --step-dependency=afternotok:$SLURM_JOB_ID.0 bash -c 'echo NOTOK_RAN' &
{atf.wait_for_step_snippet(2)}
{SRUN} --step-dependency=afterany:$SLURM_JOB_ID.0 bash -c 'echo ANY_RAN' &
{atf.wait_for_step_count_snippet(4)}
scancel $SLURM_JOB_ID.0
wait""",
["OK_RC=", "NOTOK_RAN", "ANY_RAN"],
)
assert "OK_RAN" not in lines, "afterok ran after its target was cancelled"
assert "OK_RC=0" not in lines, "srun reported success for a step that never ran"
assert "NOTOK_RAN" in lines, "afternotok was not released by a cancellation"
assert "ANY_RAN" in lines, "afterany was not released by a cancellation"
@pytest.mark.parametrize("dependency_type", ["afterany", "after"])
def test_cancelled_pending_target_releases_dependents(dependency_type):
"""A never-launched cancellation releases terminal and start dependents."""
lines = _run_script(
"dep_cancel_pending",
# B is queued on A and C on B. Once B is cancelled, C must run while A
# is still held, so C can only finish first if B's cancellation
# released it.
f"""{_held_step("cancel_pending_started", "cancel_pending_release", "A_DONE")}
until [ -f cancel_pending_started ]; do sleep 0.2; done
{SRUN} --step-dependency=afterok:$SLURM_JOB_ID.0 bash -c 'echo B_SHOULD_NOT_RUN' &
{atf.wait_for_step_snippet(1)}
{SRUN} --step-dependency={dependency_type}:$SLURM_JOB_ID.1 bash -c 'echo C_DONE; touch cancel_pending_release' &
{atf.wait_for_step_snippet(2)}
scancel $SLURM_JOB_ID.1
wait""",
["A_DONE", "C_DONE"],
)
assert "B_SHOULD_NOT_RUN" not in lines, "the cancelled pending step ran"
_assert_ordered(lines, "C_DONE", "A_DONE")
def test_chain_and_fan_out():
"""A four-deep afterok chain runs in order, and dependents fanned out on
one target are all released by it."""
body = f"""{_held_step("chain_started", "chain_release", "S0_DONE")}
until [ -f chain_started ]; do sleep 0.2; done
"""
# Steps 1-3 chain off their predecessor; 4 and 5 fan out from step 0.
targets = {1: 0, 2: 1, 3: 2, 4: 0, 5: 0}
for step, target in targets.items():
body += (
f"{SRUN} --step-dependency=afterok:$SLURM_JOB_ID.{target} "
f"bash -c 'echo S{step}_DONE' &\n"
f"{atf.wait_for_step_snippet(step)}"
)
body += "touch chain_release\nwait"
markers = [f"S{step}_DONE" for step in range(6)]
lines = _run_script("dep_chain", body, markers)
for step, target in targets.items():
_assert_ordered(lines, f"S{target}_DONE", f"S{step}_DONE")
# ---------------------------------------------------------------------------
# A queued dependent's srun re-sends its request.
# ---------------------------------------------------------------------------
@pytest.mark.slow
@pytest.mark.parametrize(
"blocker_exit, target_dependency, dependency_type",
[
(0, "", "after"),
(1, "--step-dependency=afterok:$SLURM_JOB_ID.0", "afterany"),
],
ids=["target_starts", "target_fails"],
)
def test_resend_keeps_pending_step_order(
blocker_exit, target_dependency, dependency_type
):
"""Re-sending preserves the accepted placeholder and its dependency edges."""
expected = ["BLOCKER_DONE", "RESEND_RETAINED", "DEPENDENT_STARTED", "TARGET_RC="]
if blocker_exit == 0:
expected += ["TARGET_RAN", "TARGET_DONE"]
lines = _run_script(
"dep_reverse",
# The second timeout proves the first re-send received its queued reply.
# Its placeholder must still precede the async dependent.
f"""{_held_step("reverse_blocker_started", "reverse_blocker_release", "BLOCKER_DONE", blocker_exit)}
until [ -f reverse_blocker_started ]; do sleep 0.2; done
{{ {SRUN} -v -c2 {target_dependency} bash -c 'echo TARGET_RAN; until [ -f reverse_target_release ]; do sleep 0.2; done; echo TARGET_DONE'; echo "TARGET_RC=$?"; }} 2> reverse_target.err &
{atf.wait_for_step_snippet(1)}{SRUN} --mpi=none --async --overlap --step-dependency={dependency_type}:$SLURM_JOB_ID.1 --output=reverse_dependent.out --error=reverse_dependent.err bash -c 'touch reverse_dependent_started; until [ -f reverse_dependent_release ]; do sleep 0.2; done; echo DEPENDENT_DONE; touch reverse_target_release'
{atf.wait_for_step_snippet(2)}until [ "$(grep -c 'still pending' reverse_target.err)" -ge 2 ]; do sleep 0.2; done
scontrol -o show step "$SLURM_JOB_ID" | awk -v target="StepId=$SLURM_JOB_ID.1" -v dependent="StepId=$SLURM_JOB_ID.2" '
$1 == target {{ target_line = NR }}
$1 == dependent {{ dependent_line = NR }}
END {{ exit !(target_line && dependent_line && target_line < dependent_line) }}
' || echo RESEND_REORDERED
echo RESEND_RETAINED
touch reverse_blocker_release
until [ -f reverse_dependent_started ]; do sleep 0.2; done
echo DEPENDENT_STARTED
touch reverse_dependent_release
wait
{_wait_step_gone_snippet(2)}""",
expected,
# Wait through two queued-step timeouts to observe a completed re-send.
timeout=2 * SLURMCTLD_TIMEOUT + 120,
shape="-N1 -n2",
)
assert "RESEND_REORDERED" not in lines, "re-send replaced its pending record"
_assert_ordered(lines, "RESEND_RETAINED", "BLOCKER_DONE")
_assert_ordered(lines, "BLOCKER_DONE", "DEPENDENT_STARTED")
atf.assert_file_contents("reverse_dependent.out", "DEPENDENT_DONE", contains=True)
if blocker_exit == 0:
assert "TARGET_RC=0" in lines, f"target failed to start: {lines!r}"
_assert_ordered(lines, "DEPENDENT_STARTED", "TARGET_DONE")
else:
assert "TARGET_RAN" not in lines, "a target with a failed dependency ran"
assert "TARGET_RC=0" not in lines, "the impossible target reported success"
atf.assert_file_contents(
"reverse_target.err", "can never be satisfied", contains=True
)
@pytest.mark.slow
def test_dependency_survives_resend():
"""Accepted edges retain gone targets' outcomes, unlike new requests."""
lines = _run_script(
"dep_resend",
f"""{_held_step("resend_started_a", "resend_release_a", "A_DONE")}
until [ -f resend_started_a ]; do sleep 0.2; done
{_held_step("resend_started_b", "resend_release_b", "B_DONE")}
until [ -f resend_started_b ]; do sleep 0.2; done
{{ {SRUN} -v --step-dependency=afterok:$SLURM_JOB_ID.0,afterok:$SLURM_JOB_ID.1 bash -c 'echo C_DONE'; echo "C_RC=$?"; }} &
{atf.wait_for_step_count_snippet(3)}
touch resend_release_a
until grep -q A_DONE "$SLURM_JOB_NAME.0.out" 2>/dev/null; do sleep 0.2; done
{_wait_step_gone_snippet(0)}{SRUN} --step-dependency=afterok:$SLURM_JOB_ID.0 true 2> resend_late.err
echo "LATE_RC=$?"
for _ in $(seq 1 {(2 * SLURMCTLD_TIMEOUT + 45) * 5}); do
[ "$(grep -c 'still pending' "$SLURM_JOB_NAME.0.out")" -ge 2 ] && break
sleep 0.2
done
[ "$(grep -c 'still pending' "$SLURM_JOB_NAME.0.out")" -ge 2 ] || echo NO_RESEND
touch resend_release_b
wait""",
["A_DONE", "B_DONE", "C_RC=", "LATE_RC="],
# Two timeouts establish a completed re-send after A's record is gone.
timeout=2 * SLURMCTLD_TIMEOUT + 120,
)
assert "NO_RESEND" not in lines, "srun never completed its queued re-send"
assert "LATE_RC=0" not in lines, "a new request reused a gone target's outcome"
atf.assert_file_contents("resend_late.err", "dependency", contains=True)
assert "C_RC=0" in lines, f"the re-sent dependent failed: {lines!r}"
_assert_ordered(lines, "B_DONE", "C_DONE")