blob: 68e3c9b13071ab27d9dfbcf0e788c4f76ea6c2b7 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test switch/lua handling of in-flight hooks across a slurmctld restart."""
import os
import pathlib
import time
import pytest
import atf
NODE_COUNT = 2
# Short, so a stuck teardown does not starve later tests of nodes.
SWITCH_TIMEOUT = 10
# Switch deadlines and retries are acted on every PERIODIC_TIMEOUT.
SWITCH_TICK = 30
MIN_JOB_AGE = 10
# A retrying teardown must still be retrying when slurmctld is killed.
SWITCH_RETRIES = 5
SHARED_DIR = None
START_GATE_FILE = None
COMPLETE_GATE_FILE = None
MARKER_DIR = None
DIRECTIVE_PREFIX = "switchlua:"
# A --network value that has the job set up on the default path.
MANAGED = "managed"
switch_lua = f"""
local START_GATE = "@START_GATE_FILE@"
local COMPLETE_GATE = "@COMPLETE_GATE_FILE@"
local MARKER_DIR = "@MARKER_DIR@"
local function exists(path)
local f = io.open(path, "r")
if f == nil then
return false
end
f:close()
return true
end
-- Block while the gate file exists, capped in case a test fails.
local function hold(path)
local deadline = os.time() + 120
while os.time() < deadline and exists(path) do
for _ = 1, 200000 do end
end
end
local function note(name, line)
local f = io.open(MARKER_DIR .. "/" .. name, "a")
if f ~= nil then
f:write(line .. "\\n")
f:close()
end
end
local function directive(network)
if network == nil then
return ""
end
return string.match(network, "{DIRECTIVE_PREFIX}([%w_]+)") or ""
end
-- Hold the nodes until the teardown reports (not the default).
function slurm_switch_block_job_complete()
return true
end
function slurm_switch_job_start(job)
local mode = directive(job.network)
note(string.format("start.%d", job.job_id), tostring(job.sluid))
if mode == "start_hold" then
hold(START_GATE)
end
if mode == "decline" then
return slurm.SUCCESS, nil
end
return slurm.SUCCESS, {{ mode = mode }}
end
function slurm_switch_job_complete(job, jobinfo)
local mode = directive(job.network)
-- Before the sluid, which tests wait for.
note(
string.format("jobinfo.%d", job.job_id),
jobinfo == nil and "nil" or "mode=" .. tostring(jobinfo.mode)
)
note(string.format("complete.%d", job.job_id), tostring(job.sluid))
if mode == "complete_hold" then
hold(COMPLETE_GATE)
end
-- Ask to be retried while the gate file exists.
if mode == "complete_retry" and exists(COMPLETE_GATE) then
return slurm.EAGAIN
end
return slurm.SUCCESS
end
return slurm.SUCCESS
"""
# Setup
@pytest.fixture(scope="module", autouse=True)
def setup():
global SHARED_DIR, START_GATE_FILE, COMPLETE_GATE_FILE, MARKER_DIR
SHARED_DIR = f"{atf.module_tmp_path}/shared"
START_GATE_FILE = f"{SHARED_DIR}/start_gate"
COMPLETE_GATE_FILE = f"{SHARED_DIR}/complete_gate"
MARKER_DIR = f"{SHARED_DIR}/markers"
atf.require_version(
(26, 11), "sbin/slurmd", reason="Issue 51019: switch/lua was added in 26.11"
)
atf.require_build_config(
"HAVE_LUA",
reason="Issue 51019: Slurm was built without lua, so switch/lua is absent",
)
atf.require_nodes(NODE_COUNT)
atf.require_config_parameter("SwitchType", "switch/lua")
atf.require_config_parameter("MinJobAge", MIN_JOB_AGE)
atf.require_config_parameter_includes("DebugFlags", "Switch")
atf.require_config_parameter_includes(
"SlurmctldParameters", ("switch_timeout", SWITCH_TIMEOUT)
)
atf.require_config_parameter_includes(
"SlurmctldParameters", ("switch_retries", SWITCH_RETRIES)
)
atf.require_config_parameter_includes(
"SchedulerParameters", ("batch_sched_delay", 0)
)
atf.require_config_file(
"switch.lua",
switch_lua.replace("@START_GATE_FILE@", START_GATE_FILE)
.replace("@COMPLETE_GATE_FILE@", COMPLETE_GATE_FILE)
.replace("@MARKER_DIR@", MARKER_DIR),
)
os.makedirs(MARKER_DIR)
os.chmod(SHARED_DIR, 0o777)
os.chmod(MARKER_DIR, 0o777)
atf.require_slurm_running()
@pytest.fixture(scope="function", autouse=True)
def clear_jobs():
"""Cancel all jobs and wait for the nodes to be idle again."""
yield
atf.cancel_all_jobs(quiet=True)
assert atf.wait_for_node_state(
sorted(atf.nodes), "IDLE", operator=atf.SetMatch.EQUAL
), f"Nodes did not come back after this test: {atf.get_nodes(quiet=True)}"
@pytest.fixture(scope="function")
def resume_drained_nodes():
"""Resume any node drained by a test."""
yield
for node, node_dict in atf.get_nodes(quiet=True).items():
if {"DOWN", "DRAIN", "FAIL"} & set(node_dict["state"]):
atf.run_command(
f"scontrol update nodename={node} state=resume",
user="root",
quiet=True,
)
@pytest.fixture(scope="function")
def start_gate():
"""Hold slurm_switch_job_start() for start_hold jobs until released."""
path = pathlib.Path(START_GATE_FILE)
path.touch()
os.chmod(START_GATE_FILE, 0o666)
yield lambda: path.unlink(missing_ok=True)
path.unlink(missing_ok=True)
@pytest.fixture(scope="function")
def complete_gate():
"""Hold complete_hold and retry complete_retry teardowns until released."""
path = pathlib.Path(COMPLETE_GATE_FILE)
path.touch()
os.chmod(COMPLETE_GATE_FILE, 0o666)
yield lambda: path.unlink(missing_ok=True)
path.unlink(missing_ok=True)
@pytest.fixture(scope="function")
def slow_prolog_slurmctld(tmp_path):
"""Hold new jobs in CONFIGURING with a slow PrologSlurmctld."""
prolog = str(tmp_path / "prolog_slurmctld.sh")
atf.make_bash_script(prolog, "sleep 120")
atf.set_config_parameter("PrologSlurmctld", prolog, restart=True)
yield
atf.set_config_parameter("PrologSlurmctld", None, restart=True)
def crash_slurmctld():
"""Kill slurmctld (and slurmscriptd) without a graceful shutdown.
A shutdown signals PrologSlurmctld, which is treated as success, so the
job would launch before state is saved.
"""
assert not atf.stop_slurmctld(signal="KILL"), "slurmctld survived being killed"
def save_job_state(job_id):
"""Force a job state save and wait for it to complete.
The job must still be updatable. mtime is compared at sub-second
precision, as the save can land in the same second as the update.
"""
state_file = f"{atf.get_config_parameter('StateSaveLocation')}/job_state"
since = time.time()
atf.run_command(
f"scontrol update jobid={job_id} comment=save_{int(since)}",
user=atf.properties["slurm-user"],
fatal=True,
)
for t in atf.timer():
mtime = atf.run_command_output(
f"stat -c %.9Y {state_file}", user="root", quiet=True
).strip()
if float(mtime or 0) >= since:
return True
return False
def marker_lines(path):
"""Return the lines a hook recorded, or an empty list."""
return atf.run_command_output(f"cat {path}", quiet=True).split()
def test_job_start_is_rerun_for_an_unfinished_setup(start_gate, resume_drained_nodes):
"""Test that a setup outstanding at a crash is run again.
slurmctld is killed, so it restarts from the state saved during the setup.
"""
job_id = atf.submit_job_sbatch(
f'-N1 --network={DIRECTIVE_PREFIX}start_hold --wrap "true"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "CONFIGURING", fatal=True
), f"Job {job_id} never reached CONFIGURING"
marker = f"{MARKER_DIR}/start.{job_id}"
for t in atf.timer():
if len(marker_lines(marker)) >= 1:
break
else:
pytest.fail(f"slurm_switch_job_start() did not run for job {job_id}")
assert save_job_state(job_id), (
"Job state was never written out while the setup was outstanding, so"
" the crash would not capture it"
)
crash_slurmctld()
atf.start_slurmctld()
assert atf.wait_for_job_state(job_id, "CONFIGURING"), (
f"Job {job_id} did not come back configuring, so the state the crash"
" captured was not the state the test meant to create"
)
for t in atf.timer():
if len(marker_lines(marker)) >= 2:
break
else:
pytest.fail(
f"slurm_switch_job_start() was not run again for job {job_id}, whose"
" setup was outstanding when slurmctld went down"
)
# The rerun has a fresh switch_timeout deadline.
start_gate()
assert atf.wait_for_job_state(job_id, "COMPLETED"), (
f"Job {job_id} did not run once the re-run slurm_switch_job_start()"
" had answered"
)
@pytest.mark.parametrize(
"network",
[
pytest.param(f"{DIRECTIVE_PREFIX}decline", id="declined"),
pytest.param(MANAGED, id="with_jobinfo"),
],
)
def test_job_start_is_not_rerun_for_a_finished_setup(slow_prolog_slurmctld, network):
"""Test that a finished setup is not rerun for a job still configuring."""
job_id = atf.submit_job_sbatch(f'-N1 --network={network} --wrap "true"', fatal=True)
assert atf.wait_for_job_state(
job_id, "CONFIGURING", fatal=True
), f"Job {job_id} never reached CONFIGURING"
marker = f"{MARKER_DIR}/start.{job_id}"
for t in atf.timer():
if len(marker_lines(marker)) >= 1:
break
else:
pytest.fail(f"slurm_switch_job_start() did not run for job {job_id}")
# slurmctld records the answer after the marker is written.
for t in atf.timer():
if f"switch setup done for JobId={job_id}\n" in atf.get_log("slurmctld"):
break
else:
pytest.fail(f"slurmctld did not record the setup of job {job_id}")
assert save_job_state(job_id), "Job state was never written out"
crash_slurmctld()
atf.start_slurmctld()
assert atf.wait_for_job_state(job_id, "CONFIGURING"), (
f"Job {job_id} did not come back configuring, so the state the crash"
" captured was not the state the test meant to create"
)
# A rerun would start while state is loaded, so a short wait suffices.
for t in atf.timer(timeout=3, xfail=True):
if len(marker_lines(marker)) >= 2:
pytest.fail(
f"slurm_switch_job_start() was run again for job {job_id}, whose setup"
" had already finished"
)
def test_running_job_is_torn_down_once_after_a_restart(resume_drained_nodes):
"""Test that a job running across a restart is torn down once."""
job_id = atf.submit_job_sbatch(
f'-N1 --network={MANAGED} --wrap "sleep infinity"', fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING", fatal=True
), f"Job {job_id} did not start"
start_marker = f"{MARKER_DIR}/start.{job_id}"
complete_marker = f"{MARKER_DIR}/complete.{job_id}"
for t in atf.timer():
if len(marker_lines(start_marker)) >= 1:
break
else:
pytest.fail(f"slurm_switch_job_start() did not run for job {job_id}")
atf.restart_slurmctld()
assert atf.wait_for_job_state(
job_id, "RUNNING", fatal=True
), f"Job {job_id} did not survive the restart"
assert not marker_lines(
complete_marker
), f"slurm_switch_job_complete() ran for job {job_id} while it was running"
atf.run_command(f"scancel {job_id}", fatal=True)
for t in atf.timer():
if len(marker_lines(complete_marker)) >= 1:
break
else:
pytest.fail(
f"slurm_switch_job_complete() never ran for job {job_id}, so the"
" jobinfo that marks it as managed did not survive the restart"
)
# Exact, as a held node also reports IDLE.
assert atf.wait_for_node_state(
sorted(atf.nodes), "IDLE", operator=atf.SetMatch.EQUAL, fatal=True
), f"Nodes did not come back after job {job_id} was torn down"
assert (
len(marker_lines(complete_marker)) == 1
), f"slurm_switch_job_complete() was run more than once for job {job_id}"
assert (
len(marker_lines(start_marker)) == 1
), f"slurm_switch_job_start() was run again for job {job_id}"
sluid = marker_lines(start_marker)[0]
assert sluid != "nil", f"slurm_switch_job_start() got no job.sluid for job {job_id}"
assert marker_lines(complete_marker) == [sluid], (
f"slurm_switch_job_complete() got job.sluid"
f" {marker_lines(complete_marker)} for job {job_id}, whose setup got"
f" {sluid}"
)
# The teardown gets the jobinfo restored from the state file.
jobinfo = marker_lines(f"{MARKER_DIR}/jobinfo.{job_id}")
assert jobinfo == ["mode="], (
f"slurm_switch_job_complete() got jobinfo {jobinfo} for job {job_id},"
" not the dictionary its setup returned before the restart"
)
@pytest.mark.skipif(
atf.get_version("bin/scontrol") < (25, 11),
reason="Issue 51019: scontrol shows the SLUID of a job from 25.11",
)
@pytest.mark.parametrize(
"network",
[
pytest.param(f"{DIRECTIVE_PREFIX}complete_hold", id="running"),
pytest.param(f"{DIRECTIVE_PREFIX}complete_retry", id="waiting_to_retry"),
],
)
def test_outstanding_teardown_is_rerun_after_a_restart(
network, complete_gate, resume_drained_nodes
):
"""Test that a teardown outstanding at a crash is run again, nodes held.
A requeue held in COMPLETING by it must finish once the rerun succeeds,
which is detected by the SLUID changing.
"""
retry = network.endswith("complete_retry")
job_id = atf.submit_job_sbatch(
f'-N1 --network={network} --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING", fatal=True
), f"Job {job_id} did not start"
# A completing job lists only nodes still running their epilog.
node = atf.get_job_parameter(job_id, "NodeList")
sluid_before = atf.get_job_parameter(job_id, "SLUID")
assert sluid_before, f"Job {job_id} has no SLUID"
# Hold so the job is not scheduled again.
atf.run_command(
f"scontrol requeuehold {job_id}",
user=atf.properties["slurm-user"],
fatal=True,
)
marker = f"{MARKER_DIR}/complete.{job_id}"
for t in atf.timer():
if len(marker_lines(marker)) >= 1:
break
else:
pytest.fail(
f"slurm_switch_job_complete() did not run for requeued job {job_id},"
" so there is no outstanding teardown to rerun"
)
# Wait for every epilog, so only the teardown holds the job at the crash.
for t in atf.timer():
if not atf.get_job_parameter(job_id, "NodeList", quiet=True):
break
else:
pytest.fail(
f"The epilog of job {job_id} did not finish while its teardown was held"
)
assert save_job_state(job_id), (
"Job state was never written out after the teardown began, so the"
" crash would not capture it"
)
assert (
atf.get_job_parameter(job_id, "JobState") == "COMPLETING"
), f"Job {job_id} was no longer completing when the crash was due"
crash_slurmctld()
runs = len(marker_lines(marker))
atf.start_slurmctld()
# The gate still holds or retries the rerun.
for t in atf.timer():
if len(marker_lines(marker)) > runs:
break
else:
pytest.fail(
f"slurm_switch_job_complete() was not run again for job {job_id},"
" whose teardown was holding its node when slurmctld went down"
)
states = atf.get_nodes(quiet=True)[node]["state"]
assert "COMPLETING" in states, (
f"{node} was not held while the rerun teardown of job {job_id} was"
f" outstanding: {states}"
)
# The rerun has a fresh switch_timeout deadline.
complete_gate()
# Exact, as a held or drained node also reports IDLE. A retry comes at the
# next periodic check.
assert atf.wait_for_node_state(
node,
"IDLE",
operator=atf.SetMatch.EQUAL,
timeout=SWITCH_TICK + 15,
fatal=True,
), f"{node} did not come back once the rerun teardown of job {job_id} succeeded"
for _ in atf.timer():
sluid_after = atf.get_job_parameter(job_id, "SLUID", quiet=True)
if sluid_after != sluid_before:
break
else:
pytest.fail(
f"Job {job_id} still has SLUID {sluid_before} after the rerun of"
" its teardown succeeded, so its requeue was never finished"
)
assert (
atf.get_job_parameter(job_id, "JobState") == "PENDING"
), f"Job {job_id} was not left pending by a requeue hold"
# The rerun, and for a retrying teardown the retry that succeeded.
reruns = len(marker_lines(marker)) - runs
assert reruns == (2 if retry else 1), (
f"slurm_switch_job_complete() was run {reruns} times for job {job_id}"
" after the restart"
)
# The reruns get the jobinfo restored from the state file.
jobinfo = marker_lines(f"{MARKER_DIR}/jobinfo.{job_id}")
assert set(jobinfo) == {f"mode={network.removeprefix(DIRECTIVE_PREFIX)}"}, (
f"slurm_switch_job_complete() got jobinfo {jobinfo} for job {job_id},"
" not the dictionary its setup returned"
)
def test_job_ended_during_setup_is_torn_down_after_a_restart(
start_gate, resume_drained_nodes
):
"""Test that a job ended during an outstanding setup is torn down after a crash.
The setup never reported, so it is torn down as an abandoned setup. The
ended job cannot be updated to force a save, so a held job is.
"""
node = sorted(atf.nodes)[0]
job_id = atf.submit_job_sbatch(
f'-N1 -w {node} --network={DIRECTIVE_PREFIX}start_hold --wrap "true"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "CONFIGURING", fatal=True
), f"Job {job_id} never reached CONFIGURING"
start_marker = f"{MARKER_DIR}/start.{job_id}"
complete_marker = f"{MARKER_DIR}/complete.{job_id}"
for t in atf.timer():
if len(marker_lines(start_marker)) >= 1:
break
else:
pytest.fail(f"slurm_switch_job_start() did not run for job {job_id}")
atf.run_command(f"scancel {job_id}", fatal=True)
other_id = atf.submit_job_sbatch('-N1 -H --wrap "true"', fatal=True)
assert save_job_state(other_id), (
"Job state was never written out after the job ended, so the crash"
" would not capture it"
)
assert not marker_lines(complete_marker), (
f"slurm_switch_job_complete() ran for job {job_id} before the crash,"
" so its setup was no longer outstanding"
)
crash_slurmctld()
atf.start_slurmctld()
for t in atf.timer():
if len(marker_lines(complete_marker)) >= 1:
break
else:
pytest.fail(
f"slurm_switch_job_complete() was not run for job {job_id}, whose"
" setup was outstanding when slurmctld went down"
)
# Exact, as a held or drained node also reports IDLE.
assert atf.wait_for_node_state(
node, "IDLE", operator=atf.SetMatch.EQUAL, fatal=True
), f"{node} did not come back once job {job_id} was torn down"
assert (
len(marker_lines(complete_marker)) == 1
), f"slurm_switch_job_complete() was run more than once for job {job_id}"
assert (
len(marker_lines(start_marker)) == 1
), f"slurm_switch_job_start() was run again for job {job_id}, which had ended"
assert marker_lines(complete_marker) == marker_lines(start_marker), (
f"slurm_switch_job_complete() got job.sluid"
f" {marker_lines(complete_marker)} for job {job_id}, whose setup got"
f" {marker_lines(start_marker)}"
)
jobinfo = marker_lines(f"{MARKER_DIR}/jobinfo.{job_id}")
assert jobinfo == ["nil"], (
f"slurm_switch_job_complete() got jobinfo {jobinfo} for job {job_id},"
" whose setup never reported"
)