blob: 89d1467fbcf43d4d520c266b8042940cfea4a3ee [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""RequeueExit and RequeueExitHold match an exit status or a Slurm return code.
A job ends carrying two values: the wait(2) status its own processes exited
with, and a Slurm error code when Slurm itself failed the job. These options
match against whichever of the two applies -- the return code if the job has
one, otherwise the exit status -- so a single list can name both
(_set_job_requeue_exit_value(), src/slurmctld/job_mgr.c).
Test mechanics worth knowing:
- Jobs are submitted with --requeue. Without it the matcher returns before
looking at anything, so every test here would pass vacuously.
- Jobs that must requeue and then finish key off SLURM_RESTART_COUNT so the
second run succeeds; a job that keeps failing keeps requeueing.
- The Slurm-failure cases use a working directory that cannot be used. The
stepd fails while opening the job's output file. A failing Prolog is not
usable here: slurmctld requeues and holds a launch failure whether or not
a rule matches, so the rule would prove nothing.
- A requeueing job passes through FAILED before it returns to PENDING, and
wait_for_job_state() gives up as soon as it sees a final state. The
restart count, or the settled state, is polled instead.
- The three exit-status tests are deliberately not version gated. Matching
on an exit status, on a range, and the held variant all work before 26.11,
so they are regression coverage for behaviour that should hold on every
supported version. Only the return-code tests need 26.11.
"""
import os
import pytest
import atf
# Every Slurm error code is 1000 or greater; every exit status is below 256.
LOWEST_SLURM_ERROR_CODE = 1000
# Waiting for a requeued job to run a second time has to outlast the delay
# before it becomes eligible again. setup() asks for requeue_delay=0, but
# SchedulerParameters gained that option in 25.11, so on older versions the
# job still waits out AuthInfo=cred_expire, 120 seconds by default. This is
# only ever an upper bound: where requeue_delay=0 is honored the job reruns
# within seconds.
RERUN_TIMEOUT = 180
# A working directory the stepd cannot open a job's output file in, which is
# how these tests make Slurm fail a job before its script ever runs. Two
# properties are load-bearing:
# - The job must use the DEFAULT output file. expand_stdio_fields() joins
# this directory onto slurm-%j.out when the step record is built, so the
# stepd opens an absolute path under it. An explicit --output would
# disarm the primitive and the job would simply run.
# - The directory must be one the stepd cannot CREATE, not merely one that
# is absent: on ENOENT it calls mkdirpath() and retries the open, so a
# missing directory under a writable parent is created and the job runs.
# Nesting it under /dev/null gives ENOTDIR instead, on every node and
# whichever user the job runs as.
UNUSABLE_WORK_DIR = "/dev/null/slurm_test_workdir"
@pytest.fixture(scope="module", autouse=True)
def setup():
# No accounting requirement: everything here is read through scontrol,
# so the tests do not need a database.
#
# A requeued job is not eligible to run again until requeue_delay has
# passed, and that defaults to AuthInfo=cred_expire, i.e. 120 seconds.
# The tests that let a requeued job rerun would otherwise sit waiting for
# two minutes each.
#
# SchedulerParameters gained requeue_delay in 25.11. On older versions it
# is ignored and the full delay applies, which is why the waits that span
# a rerun use RERUN_TIMEOUT rather than the default.
atf.require_config_parameter_includes("SchedulerParameters", ("requeue_delay", 0))
# Without this the rerun waits for the next scheduling cycle, which is a
# minute by default.
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
atf.require_slurm_running()
@pytest.fixture
def requeue_policy():
"""Set RequeueExit/RequeueExitHold for one test, restoring both afterwards.
Both are always written, so a value left by one test cannot leak into the
next. init_requeue_policy() re-reads them on reconfigure, which is what
set_config_parameter triggers, so no restart is needed.
"""
# live=False parses slurm.conf rather than `scontrol show config`. The
# live view renders an unset RequeueExit as the string "(null)", which
# would then be written back into slurm.conf on teardown and rejected by
# the parser on every later reconfigure. The file view simply omits an
# absent parameter, so default=None is reached and the teardown unsets it.
names = ("RequeueExit", "RequeueExitHold")
saved = {
name: atf.get_config_parameter(name, default=None, live=False) for name in names
}
def apply(requeue_exit=None, requeue_exit_hold=None):
atf.set_config_parameter("RequeueExit", requeue_exit)
atf.set_config_parameter("RequeueExitHold", requeue_exit_hold)
yield apply
for name, value in saved.items():
atf.set_config_parameter(name, value)
def _fail_once_script(name, exit_status):
"""Return a job script that exits exit_status once, then succeeds.
A matching rule requeues the job every time it fails, so the body has to
stop failing or the job never finishes. SLURM_RESTART_COUNT is what
distinguishes the runs: a flag file would have to be visible from
whichever node the requeued run lands on, and this needs no filesystem
at all.
"""
script = os.path.abspath(f"job_{name}.sh")
atf.make_bash_script(
script,
f"""if [ "${{SLURM_RESTART_COUNT:-0}}" -gt 0 ]; then
exit 0
fi
exit {exit_status}
""",
)
return script
def _restarts(job_id):
# quiet=True because these are polled in a loop; default= covers a job
# whose Restarts is not yet set, not a job that has left the queue.
return int(atf.get_job_parameter(job_id, "Restarts", default=0, quiet=True))
def _state(job_id):
"""Return the job's state, or NOT_FOUND once it has left scontrol.
get_job_parameter()'s default= only covers a missing parameter; it
pytest.fail()s when the job itself is gone. These states are read from
assert messages, which evaluate only on failure - exactly when the job's
presence is least certain - so relying on it would replace a real
diagnostic with "job not found".
"""
jobs = atf.get_jobs(quiet=True)
if int(job_id) not in jobs:
return "NOT_FOUND"
return jobs[int(job_id)].get("JobState", "NOT_FOUND")
def _wait_for_requeue(job_id):
"""Wait until the job has been requeued at least once.
The timer is deliberately not fatal: returning False lets each caller
fail with a message about what it was waiting for, which is more useful
than a generic timeout.
"""
for _ in atf.timer():
if _restarts(job_id) >= 1:
return True
return False
def _wait_for_state(job_id, state, timeout=atf.default_polling_timeout):
"""Poll for a settled job state, tolerating the states passed through.
timeout is only raised for waits that span a job rerun; see RERUN_TIMEOUT.
"""
for _ in atf.timer(timeout=timeout):
if _state(job_id) == state:
return True
return False
def _submit_failed_by_slurm():
"""Submit a job Slurm fails before it runs, and return the job id."""
return atf.submit_job_sbatch(
f"--requeue --chdir={UNUSABLE_WORK_DIR} --wrap 'exit 0'", fatal=True
)
def _discover_slurm_error_code(requeue_policy):
"""Run one job Slurm fails with no rule set, and return the SlurmRC it reported.
This also pins the premise every return-code test below depends on: the
job carries a Slurm error code AND an exit status of 1 at the same time.
If it ever reported 0:0 instead, a test asserting that RequeueExit=1 does
not match it would pass for the wrong reason.
"""
requeue_policy()
probe = _submit_failed_by_slurm()
atf.wait_for_job_state(probe, "DONE", fatal=True)
rc = int(atf.get_job_parameter(probe, "SlurmRC", fatal=True))
assert (
rc >= LOWEST_SLURM_ERROR_CODE
), f"A job Slurm failed should carry a Slurm error code, got SlurmRC={rc}"
exit_code = atf.get_job_parameter(probe, "ExitCode", fatal=True)
assert exit_code == "1:0", (
f"A job Slurm failed should report ExitCode 1:0 alongside SlurmRC={rc}; "
f"got {exit_code}. The return-code tests below rely on both halves "
"being set at once."
)
return rc
# Gated on scontrol rather than slurmctld: these tests read the job's SlurmRC
# through scontrol, and a client may be older than the controller under
# Slurm's supported version ordering (slurmdbd >= slurmctld >= slurmd >=
# clients). A new enough scontrol therefore implies a new enough slurmctld,
# but not the other way round.
requires_rc_matching = pytest.mark.skipif(
atf.get_version("bin/scontrol") < (26, 11),
reason="Ticket 24195: RequeueExit matched only exit statuses, and only "
"up to 255, before 26.11",
)
# ---------------------------------------------------------------------------
# Matching on the job's own exit status
# ---------------------------------------------------------------------------
def test_requeue_on_exit_status(requeue_policy):
"""Verify a job whose exit status is listed is requeued and rerun"""
requeue_policy(requeue_exit="3")
job_id = atf.submit_job_sbatch(
f"--requeue {_fail_once_script('exit3', 3)}", fatal=True
)
assert _wait_for_requeue(
job_id
), "A job exiting 3 under RequeueExit=3 should be requeued"
assert _wait_for_state(job_id, "COMPLETED", timeout=RERUN_TIMEOUT), (
"The requeued run succeeds, so the job should end COMPLETED; it is in "
f"state {_state(job_id)}"
)
assert _restarts(job_id) == 1, "The job should have been requeued exactly once"
def test_requeue_range_matches_only_listed_values(requeue_policy):
"""Verify a numeric range requeues inside it and not outside it"""
requeue_policy(requeue_exit="1-9")
inside = atf.submit_job_sbatch(
f"--requeue {_fail_once_script('range_in', 3)}", fatal=True
)
assert _wait_for_requeue(inside), "Exit status 3 is inside 1-9 and should requeue"
assert _wait_for_state(
inside, "COMPLETED", timeout=RERUN_TIMEOUT
), f"The requeued run should succeed; job is {_state(inside)}"
outside = atf.submit_job_sbatch("--requeue --wrap 'exit 20'", fatal=True)
atf.wait_for_job_state(outside, "DONE", fatal=True)
assert (
_state(outside) == "FAILED"
), "Exit status 20 is outside 1-9 and should fail terminally"
assert _restarts(outside) == 0, "Exit status 20 is outside 1-9 and must not requeue"
def test_requeue_exit_hold_on_exit_status(requeue_policy):
"""Verify RequeueExitHold requeues the job and leaves it held"""
requeue_policy(requeue_exit_hold="3")
# No _fail_once_script: the job is held rather than rerun, so it cannot
# loop, and a plain `exit 3` is enough.
job_id = atf.submit_job_sbatch("--requeue --wrap 'exit 3'", fatal=True)
assert _wait_for_state(job_id, "SPECIAL_EXIT"), (
"A job exiting 3 under RequeueExitHold=3 should land in SPECIAL_EXIT; "
f"it is in state {_state(job_id)}"
)
assert _restarts(job_id) == 1, "A held requeue still counts as a restart"
assert (
int(atf.get_job_parameter(job_id, "Priority", fatal=True)) == 0
), "A job held by RequeueExitHold should have priority 0"
# ---------------------------------------------------------------------------
# Matching on a Slurm return code -- the case that needs a value above 255
# ---------------------------------------------------------------------------
@requires_rc_matching
def test_slurm_failure_does_not_requeue_without_a_rule(requeue_policy):
"""Verify a job Slurm failed fails terminally when no rule names its code
This is the counterfactual for the tests below. If a Slurm-failed job
requeued on its own, a rule-driven test could not tell whether the rule
did anything.
"""
requeue_policy()
job_id = _submit_failed_by_slurm()
atf.wait_for_job_state(job_id, "DONE", fatal=True)
assert (
_state(job_id) == "FAILED"
), "A job Slurm failed should fail terminally when no rule matches"
assert (
_restarts(job_id) == 0
), "No RequeueExit rule is set, so nothing should requeue this job"
rc = int(atf.get_job_parameter(job_id, "SlurmRC", fatal=True))
assert (
rc >= LOWEST_SLURM_ERROR_CODE
), f"A job Slurm failed should carry a Slurm error code, got SlurmRC={rc}"
@requires_rc_matching
def test_requeue_on_slurm_return_code(requeue_policy):
"""Verify RequeueExit matches a Slurm return code above 255"""
# Learn the error code from a job rather than hardcoding one, so the test
# does not break if this failure starts reporting a different code.
rc = _discover_slurm_error_code(requeue_policy)
assert rc > 255, (
"This test exists to prove values above 255 can be matched; the "
f"failure reported SlurmRC={rc}, which is inside the old range"
)
requeue_policy(requeue_exit=str(rc))
job_id = _submit_failed_by_slurm()
# The working directory is still unusable, so every run fails and the
# job would requeue forever. Watch for the first requeue, then stop it.
requeued = _wait_for_requeue(job_id)
atf.run_command(f"scancel {job_id}", fatal=True)
assert requeued, (
f"A job Slurm failed with SlurmRC={rc} should be requeued by "
f"RequeueExit={rc}. Not requeueing means the return code is not being "
"matched, or the value is being rejected for exceeding 255."
)
@requires_rc_matching
def test_return_code_excludes_the_exit_status(requeue_policy):
"""Verify a job with a return code is NOT matched on its exit status
slurm.conf.5 states the match is a strict precedence: a job with a
non-zero return code is matched against that code, OTHERWISE against its
exit status. The tests above prove both positive directions; this proves
the exclusion, which a matcher written as "return code in the list OR
exit status in the list" would fail while passing every other test here.
A job Slurm failed carries SlurmRC >= 1000 and ExitCode=1:0 at the same time,
so RequeueExit=1 names its exit status and not its return code. It must
not requeue.
"""
rc = _discover_slurm_error_code(requeue_policy)
requeue_policy(requeue_exit="1")
job_id = _submit_failed_by_slurm()
# If the precedence is broken this job matches on its exit status and
# requeues forever, so the wait has to be the bounded one and the job has
# to be cancelled either way. Capture everything before the scancel.
settled = _wait_for_state(job_id, "FAILED")
restarts = _restarts(job_id)
state = _state(job_id)
exit_code = atf.get_job_parameter(job_id, "ExitCode", default="", quiet=True)
atf.run_command(f"scancel {job_id}", fatal=True)
assert settled, (
f"A job Slurm failed with SlurmRC={rc} should fail terminally under "
f"RequeueExit=1, which names its exit status and not its return "
f"code; it is in state {state} with {restarts} restart(s)"
)
assert restarts == 0, (
f"RequeueExit=1 requeued a job whose SlurmRC is {rc} and whose ExitCode "
f"is {exit_code}. Matching it means the exit status is being "
"consulted for a job that has a return code, so the documented "
"precedence is not being applied."
)
@requires_rc_matching
@pytest.mark.skipif(
atf.get_version("bin/squeue") < (26, 11),
reason="Ticket 24195: the squeue SlurmRC format field was added in 26.11",
)
def test_requeue_exit_hold_on_slurm_return_code(requeue_policy):
"""Verify RequeueExitHold matches a Slurm return code and holds the job"""
rc = _discover_slurm_error_code(requeue_policy)
requeue_policy(requeue_exit_hold=str(rc))
# Being held stops the job re-running, so this one cannot loop.
job_id = _submit_failed_by_slurm()
assert _wait_for_state(job_id, "SPECIAL_EXIT"), (
f"A job Slurm failed with SlurmRC={rc} should land in SPECIAL_EXIT under "
f"RequeueExitHold={rc}; it is in state {_state(job_id)}"
)
assert (
int(atf.get_job_parameter(job_id, "Priority", fatal=True)) == 0
), "A job held by RequeueExitHold should have priority 0"
# A held job stays pending and plainly visible to squeue, so this can
# show squeue rendering a NON-ZERO rc. test_114_7 pins that the field is
# registered and wired; here it can be told apart from a literal 0.
squeue_rc = atf.run_command_output(
f"squeue --noheader -O SlurmRC -j {job_id}", fatal=True
).strip()
assert squeue_rc == str(rc), (
f"squeue -O SlurmRC should render the job's own return code {rc}; got "
f"{squeue_rc!r}. A value of 0 means the field renders a constant "
"rather than reading job_exit_rc.rc."
)
@requires_rc_matching
def test_one_list_holds_both_value_spaces(requeue_policy):
"""Verify an exit status and a return code can share one RequeueExit list
The two are told apart by whether the job has a return code, not by the
value, so both entries have to work from the same list.
"""
rc = _discover_slurm_error_code(requeue_policy)
requeue_policy(requeue_exit=f"3,{rc}")
by_status = atf.submit_job_sbatch(
f"--requeue {_fail_once_script('mixed', 3)}", fatal=True
)
assert _wait_for_requeue(by_status), "The exit-status entry should still match"
assert _wait_for_state(
by_status, "COMPLETED", timeout=RERUN_TIMEOUT
), f"The requeued run should succeed; job is {_state(by_status)}"
by_rc = _submit_failed_by_slurm()
matched = _wait_for_requeue(by_rc)
atf.run_command(f"scancel {by_rc}", fatal=True)
assert matched, "The return-code entry should match from the same list"
@requires_rc_matching
def test_requeue_exit_accepts_the_documented_maximum(requeue_policy):
"""Verify RequeueExit and RequeueExitHold accept 65535
slurm.conf.5 documents values up to 65535, well past the 255 an exit
status can reach, so the whole return-code range can be listed.
"""
requeue_policy(requeue_exit="65535", requeue_exit_hold="65535")
for name in ("RequeueExit", "RequeueExitHold"):
value = atf.get_config_parameter(name, live=True)
assert value == "65535", f"{name}=65535 should be accepted; got {value!r}"