blob: b6d903e256d65c010357a78c8d895b826fec3848 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""scontrol reports a job's Slurm return code, and only with --details.
SlurmRC is printed on the DerivedExitCode line, which scontrol emits only with
--details. The last test checks scontrol and sacct report the same ExitCode
and SlurmRC for one job.
These tests call scontrol directly because atf.get_job_parameter() always
passes -d.
"""
import re
import pytest
import atf
# Every Slurm error code is 1000 or greater; every exit status is below 256.
LOWEST_SLURM_ERROR_CODE = 1000
# 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():
atf.require_slurm_running()
@pytest.fixture
def accounting():
"""Accounting and a 26.11 sacct, for the scontrol/sacct comparison only."""
atf.require_accounting()
atf.require_version(
(26, 11),
"bin/sacct",
reason="Ticket 24195: the SlurmRC field was added in 26.11",
)
requires_rc = pytest.mark.skipif(
atf.get_version("bin/scontrol") < (26, 11),
reason="Ticket 24195: the SlurmRC field was added in 26.11",
)
def _failed_job():
"""Submit a job that Slurm itself fails, and wait for it to finish.
An unusable working directory makes the stepd fail while opening the
job's output file, so the job never runs and Slurm records an error code
for it.
"""
job_id = atf.submit_job_sbatch(
f"--no-requeue --chdir={UNUSABLE_WORK_DIR} --wrap 'exit 0'", fatal=True
)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
return job_id
@requires_rc
def test_rc_shown_only_with_details():
"""Verify scontrol show job prints SlurmRC only with --details"""
job_id = atf.submit_job_sbatch("--no-requeue --wrap 'exit 3'", fatal=True)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
plain = atf.run_command_output(f"scontrol show job {job_id}", fatal=True)
assert re.search(r"\bExitCode=", plain), f"ExitCode missing: {plain}"
assert not re.search(
r"\bSlurmRC=", plain
), f"SlurmRC printed without --details: {plain}"
details = atf.run_command_output(
f"scontrol show job --details {job_id}", fatal=True
)
rcs = re.findall(r"\bSlurmRC=(\d+)", details)
assert rcs == ["0"], f"--details should print SlurmRC=0 once: {details}"
@requires_rc
def test_details_reports_the_error_code_for_a_slurm_failure():
"""Verify SlurmRC carries the Slurm error code when Slurm failed the job"""
job_id = _failed_job()
output = atf.run_command_output(f"scontrol show job --details {job_id}", fatal=True)
rc_match = re.search(r"\bSlurmRC=(\d+)", output)
assert (
rc_match is not None
), f"scontrol --details should print SlurmRC; got {output}"
rc = int(rc_match.group(1))
assert rc >= LOWEST_SLURM_ERROR_CODE, (
f"A job Slurm failed should report a Slurm error code in SlurmRC, got {rc}. "
f"Every Slurm error code is >= {LOWEST_SLURM_ERROR_CODE}, so a smaller "
"value means an exit status was stored in SlurmRC."
)
exit_match = re.search(r"\bExitCode=(\d+:\d+)", output)
assert exit_match is not None, f"scontrol should print ExitCode; got {output}"
assert exit_match.group(1) == "1:0", (
"A job Slurm failed should report ExitCode 1:0. A value of 0:1 means "
"the exit status is being read as a signal; got "
f"{exit_match.group(1)}"
)
@requires_rc
def test_scontrol_and_sacct_agree(accounting):
"""Verify scontrol and sacct report the same ExitCode and SlurmRC
They read the same two values from different sources. Comparing them on
one job is the cheapest guard against them drifting apart.
"""
job_id = _failed_job()
atf.wait_for_job_accounted(job_id, field="End", value=r"\d", fatal=True)
scontrol_out = atf.run_command_output(
f"scontrol show job --details {job_id}", fatal=True
)
exit_match = re.search(r"\bExitCode=(\d+:\d+)", scontrol_out)
assert exit_match is not None, f"scontrol should print ExitCode; got {scontrol_out}"
rc_match = re.search(r"\bSlurmRC=(\d+)", scontrol_out)
assert (
rc_match is not None
), f"scontrol --details should print SlurmRC; got {scontrol_out}"
scontrol_exit = exit_match.group(1)
scontrol_rc = rc_match.group(1)
sacct_out = atf.run_command_output(
f"sacct -j {job_id} -X --noheader -P -o ExitCode,SlurmRC", fatal=True
).strip()
sacct_exit, sacct_rc = sacct_out.split("|")
assert scontrol_exit == sacct_exit, (
f"scontrol and sacct disagree on ExitCode for job {job_id}: "
f"{scontrol_exit} vs {sacct_exit}"
)
assert scontrol_rc == sacct_rc, (
f"scontrol and sacct disagree on SlurmRC for job {job_id}: "
f"{scontrol_rc} vs {sacct_rc}"
)