blob: 43d2a4215db568c9d99da984900a030e6991bb50 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""
Verify a job preempted before it runs is reported as PREEMPTED.
A job holds its allocation from the moment it is selected, so a higher priority
job can reclaim it before it ever runs. With PrologFlags=Alloc that window is
the whole Prolog, and the job is CONFIGURING for all of it. The kill path for a
configuring job reported every termination as CANCELLED, which hid preemption as
the reason the job ended.
The Prolog blocks on a sentinel file rather than sleeping for a fixed time, so
the preemptee is still configuring when the preemptor arrives however the two
submissions happen to be scheduled.
A partition GraceTime takes a different route to the same kill: the job is sent
SIGTERM and given until its GraceTime runs out, and only then is it killed. With
no step running yet the SIGTERM has nothing to signal, so the job must stay
CONFIGURING through its grace time and still end as PREEMPTED afterwards.
The state the job records and the kill RPC its nodes are sent both come from
the same preempt flag, so both are checked. The RPC is read out of the
slurmctld log, since a job with no step running yet is torn down the same way
whichever kill it is handed.
"""
import os
import time
import pytest
import atf
pytestmark = pytest.mark.slow
# GraceTime, in seconds, of the partition used by the GraceTime case. Long enough
# that the preemptee is plainly held through it rather than killed at once.
GRACE_TIME = 20
@pytest.fixture(scope="module", autouse=True)
def setup(block_file):
atf.require_version(
(26, 11),
component="sbin/slurmctld",
reason="Ticket 15001: a preempted CONFIGURING job is PREEMPTED in 26.11",
)
# enable_stepmgr is only supported with PrologFlags=contain. slurmctld
# refuses to start with Alloc under it and keeps its previous flags, which
# would leave the preemptee RUNNING and test a different window.
if atf.config_parameter_includes(
"SlurmctldParameters", "enable_stepmgr", live=False, quiet=True
):
pytest.skip("enable_stepmgr is only supported with PrologFlags=contain")
prolog_script = f"{os.getcwd()}/prolog.sh"
atf.make_bash_script(
prolog_script,
f"""while [ -f {block_file} ]; do
sleep 0.5
done
""",
)
atf.require_config_parameter("Prolog", prolog_script)
atf.require_config_parameter("PrologFlags", "Alloc")
atf.require_config_parameter("PreemptType", "preempt/partition_prio")
atf.require_config_parameter("PreemptMode", "CANCEL")
# The agent names the RPC it dispatches only under this flag, which is how
# the kill sent to the nodes is told apart from a plain terminate.
atf.require_config_parameter_includes("DebugFlags", "Agent")
# Memory has to be configured for a SelectTypeParameters tracking it.
atf.require_nodes(1, [("CPUs", 2), ("RealMemory", 512)])
atf.require_config_parameter("DefMemPerNode", "128")
atf.require_slurm_running()
@pytest.fixture(scope="module")
def block_file():
"""Return the file every Prolog waits on for as long as it exists.
The Prolog runs on the slurmd with a working directory of its own, so the
file is reached by absolute path rather than relative to the test.
"""
return f"{os.getcwd()}/block_prolog"
@pytest.fixture(scope="module")
def preempt_node():
"""Return the one node both partitions are restricted to.
Both jobs are exclusive, so they only contend for a node if there is only
one node they can run on.
"""
return sorted(atf.get_nodes())[0]
@pytest.fixture(scope="module")
def low_priority_partition(preempt_node):
"""Create the partition the preemptee runs in."""
partition_name = "lowprio"
atf.run_command(
f"scontrol create partitionname={partition_name} nodes={preempt_node} "
f"priority=1 preemptmode=cancel",
user=atf.properties["slurm-user"],
fatal=True,
)
return partition_name
@pytest.fixture(scope="module")
def low_priority_grace_partition(preempt_node):
"""Create a partition like lowprio whose preemptees get a GraceTime."""
partition_name = "lowprio_grace"
atf.run_command(
f"scontrol create partitionname={partition_name} nodes={preempt_node} "
f"priority=1 preemptmode=cancel gracetime={GRACE_TIME}",
user=atf.properties["slurm-user"],
fatal=True,
)
return partition_name
@pytest.fixture(scope="module")
def high_priority_partition(preempt_node):
"""Create the partition the preemptor runs in."""
partition_name = "highprio"
atf.run_command(
f"scontrol create partitionname={partition_name} nodes={preempt_node} "
f"priority=2 preemptmode=off",
user=atf.properties["slurm-user"],
fatal=True,
)
return partition_name
def _slurmctld_log():
return atf.get_config_parameter("SlurmctldLogFile", live=False, quiet=True)
def log_mark():
"""Current line count of the slurmctld log, to read forward from later.
Marking a position beats grepping the whole file: the log is not cleared
between tests in a module, so an earlier kill would otherwise count.
"""
return int(
atf.run_command_output(
f"wc -l < {_slurmctld_log()}",
user=atf.properties["slurm-user"],
quiet=True,
).strip()
)
def kill_rpcs_since(mark):
"""The kill RPC names the agent dispatched after mark, in order.
DebugFlags=Agent makes the agent thread log the message type it is about
to send, which is the only place the choice deallocate_nodes() made is
visible from outside: for a job with no step running yet, the node does
the same work whichever kill RPC it is handed.
"""
return atf.run_command_output(
f"tail -n +{mark} {_slurmctld_log()} | "
f"grep -oE 'REQUEST_(KILL_PREEMPTED|KILL_TIMELIMIT|TERMINATE_JOB)' || true",
user=atf.properties["slurm-user"],
quiet=True,
).split()
@pytest.fixture
def release_prologs(block_file):
"""Hold every Prolog until the yielded function releases them.
Released again on the way out so that a failure part way through still
lets the Prologs exit and leaves the nodes usable for later tests.
"""
def release():
atf.run_command(f"rm -f {block_file}", fatal=True)
atf.run_command(f"touch {block_file}", fatal=True)
yield release
release()
@pytest.mark.parametrize(
"preemptee_partition, grace_time",
[
pytest.param("low_priority_partition", 0, id="no-grace"),
pytest.param("low_priority_grace_partition", GRACE_TIME, id="grace"),
],
)
def test_preempt_configuring_job(
request, release_prologs, high_priority_partition, preemptee_partition, grace_time
):
"""Verify a job preempted while its Prolog runs is reported as PREEMPTED."""
preemptee_partition = request.getfixturevalue(preemptee_partition)
preemptee = atf.submit_job_sbatch(
f"-N1 -t1 -o /dev/null --exclusive -p {preemptee_partition} "
f"--wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(
preemptee, "CONFIGURING", timeout=60
), f"Preemptee ({preemptee}) was not held in CONFIGURING by its Prolog"
# Marked before the preemptor exists, so the only kill that can appear
# past here is the one preemption drives.
mark = log_mark()
# A grace time can only start once the preemptor exists.
preempt_start = time.time()
atf.submit_job_sbatch(
f"-N1 -t1 -o /dev/null --exclusive -p {high_priority_partition} "
f"--wrap 'true'",
fatal=True,
)
# Selecting the preemptee clamps its end time to PreemptTime plus its
# GraceTime, as for a running job, while it is still CONFIGURING. A job
# not yet selected reports PreemptTime as set to 0, and requiring
# EndTime > PreemptTime rules out a sample taken once the grace time
# has already run out.
if grace_time:
job = preempt_time = end_time = None
for _ in atf.timer():
job = atf.get_jobs(preemptee, use_json=True, quiet=True)[preemptee]
preempt_time = job["preempt_time"]
end_time = job["end_time"]
if (
preempt_time["set"]
and preempt_time["number"]
and end_time["set"]
and end_time["number"] > preempt_time["number"]
):
break
assert preempt_time["number"], f"Preemptee ({preemptee}) got no PreemptTime"
assert (
"CONFIGURING" in job["job_state"]
), f"Preemptee ({preemptee}) was {job['job_state']} when selected"
grace = end_time["number"] - preempt_time["number"]
assert (
grace == grace_time
), f"Preemptee ({preemptee}) EndTime is {grace}s after PreemptTime"
# The Prolog is still blocked, so being preempted is the only way the
# preemptee can leave CONFIGURING. Once a grace time is up the kill
# waits on the next time limit pass, so allow for that as well. Poll
# every second, as the held_for check below is only as precise as this.
for _ in atf.timer(timeout=60 + grace_time, poll_interval=1):
state = atf.get_job_parameter(
preemptee, "JobState", default="NOT_FOUND", quiet=True
)
if state != "CONFIGURING":
break
else:
pytest.fail(
f"Preemptee ({preemptee}) was not preempted while it was CONFIGURING"
)
# Its SIGTERM has no step to reach, so nothing should end the preemptee
# before its grace time is up. slurmctld counts that in whole seconds,
# hence the one second of slack.
held_for = time.time() - preempt_start
assert held_for >= grace_time - 1, (
f"Preemptee ({preemptee}) was killed {held_for:.0f}s after the "
f"preemptor arrived, before its {grace_time}s GraceTime was up"
)
# A job being killed reads as COMPLETING until its Prolog exits and the
# node can finish cleaning up, which is when the state it ended in
# becomes visible.
release_prologs()
assert atf.wait_for_job_state(preemptee, "PREEMPTED", timeout=60), (
f"Preemptee ({preemptee}) ended in state "
f"{atf.get_job_parameter(preemptee, 'JobState', default='NOT_FOUND')} "
f"rather than PREEMPTED"
)
# The state above is what the job recorded; this is what its nodes were
# told. Both come from the same preempt flag, so a kill that reports
# PREEMPTED has to tear the job down with the preemption RPC too. Only
# a preemption sends this one, so finding it is enough -- the preemptor
# finishing in the same window contributes a terminate of its own.
kills = kill_rpcs_since(mark)
assert "REQUEST_KILL_PREEMPTED" in kills, (
f"Preemptee ({preemptee}) should have had its nodes sent "
f"REQUEST_KILL_PREEMPTED, but the agent only dispatched "
f"{sorted(set(kills)) or 'no kill RPC at all'}"
)