blob: a43cf167756f5a7f58a35d7611b4809ca28f3c65 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test hetjob preemption when PreemptMode resolves from mixed sources."""
from datetime import datetime
import pytest
import atf
pytestmark = pytest.mark.slow
# Reasons that mean "another job holds the resources this job asked for". A job
# pending for anything else -- a limit, a hold, a drained node -- is pending for
# a reason unrelated to what these tests check.
BLOCKED_REASONS = ("Resources", "Priority")
# A negative wait has to burn its whole timeout, so bound it rather than taking
# the atf default of 45s. With bf_interval=1 and sched_interval=1 the scheduler
# re-evaluates every second, so a broken exemption shows up within a cycle or
# two; this leaves a wide margin and is the difference between a ~10s test and
# a ~50s one. Shortening it can only lower sensitivity, never cause a false
# failure.
NOT_RUNNING_TIMEOUT = 15
# GraceTime, in seconds, for the two partitions used by the GraceTime test. The
# gap between them is what the test measures, so keep them far enough apart to
# tell apart with a comfortable tolerance.
GRACE_SHORT = 10
GRACE_LONG = 30
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"sbin/slurmctld",
reason="Ticket 23654: hetjob PreemptMode resolution changed to "
"REQUEUE then CANCEL, with SUSPEND treated as OFF, in 26.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU_Memory")
atf.require_config_parameter("SchedulerType", "sched/backfill")
atf.require_config_parameter("PreemptType", "preempt/partition_prio")
# GANG is required so a partition may override PreemptMode to SUSPEND.
atf.require_config_parameter("PreemptMode", "SUSPEND,GANG")
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
# A hetjob needs the backfill scheduler to place each component on a
# different node to start, so require one node per component. Three, since
# the three-component case below is what makes "the first component found"
# and "applied to every component" distinguishable.
atf.require_nodes(3, [("CPUs", 2), ("RealMemory", 512)])
atf.require_config_parameter("DefMemPerNode", "128")
# A REQUEUE-mode preemption can only requeue a requeue-able job; otherwise
# it falls back to cancel. Force requeue-ability so the resolved mode, not
# the cluster's JobRequeue default, decides the outcome.
atf.require_config_parameter("JobRequeue", "1")
# Low-priority partitions, one per PreemptMode, so a hetjob can put each
# component in a partition with a different mode. A high-priority partition
# provides the preemptor.
atf.require_config_parameter(
"PartitionName",
{
"low_suspend": {
"Nodes": "ALL",
"PriorityTier": "1",
"PreemptMode": "SUSPEND",
},
"low_requeue": {
"Nodes": "ALL",
"PriorityTier": "1",
"PreemptMode": "REQUEUE",
},
"low_cancel": {
"Nodes": "ALL",
"PriorityTier": "1",
"PreemptMode": "CANCEL",
},
# Two more REQUEUE partitions that differ only in GraceTime, so a
# hetjob can span both and each component's own GraceTime can be
# told apart. GraceTime is not used under SUSPEND, so these
# resolve to REQUEUE.
"low_grace_short": {
"Nodes": "ALL",
"PriorityTier": "1",
"PreemptMode": "REQUEUE",
"GraceTime": str(GRACE_SHORT),
},
"low_grace_long": {
"Nodes": "ALL",
"PriorityTier": "1",
"PreemptMode": "REQUEUE",
"GraceTime": str(GRACE_LONG),
},
"highprio": {"Nodes": "ALL", "PriorityTier": "2"},
},
)
atf.require_slurm_running()
def _assert_blocked(job_id, what):
"""Assert job_id is pending because another job holds its resources.
wait_for_job_state() also returns False for a terminal state, so the state
is checked explicitly rather than inferred from a failed wait.
IN job_id - the job that must be pending
IN what - how to name the job in the failure message
"""
state = atf.get_job_parameter(job_id, "JobState")
assert state == "PENDING", f"{what} ({job_id}) should be pending, it is {state}"
reason = atf.get_job_parameter(job_id, "Reason")
assert reason in BLOCKED_REASONS, (
f"{what} ({job_id}) is pending for {reason}, not because another job "
f"holds its resources"
)
def _submit_hetjob(name, partitions):
"""Submit a hetjob with one component per named partition, one per node.
IN name - file name for the generated batch script
IN partitions - partition for each component, in order
RET (leader job id, list of component job ids)
"""
components = [
f"#SBATCH -N1 -c2 -p {partition} -t5 -o /dev/null" for partition in partitions
]
atf.make_bash_script(
name, "\n#SBATCH hetjob\n".join(components) + "\nsleep infinity\n"
)
het_id = atf.submit_job_sbatch(name, fatal=True)
return het_id, atf.range_to_list(
atf.get_job_parameter(het_id, "HetJobIdSet", fatal=True)
)
def _end_time(job_id):
"""Return a job's EndTime as a datetime, or None if it is not a timestamp.
scontrol renders EndTime as "Unknown" or "None" for a job whose end time
has been reset, which a component that has left its grace period and been
requeued will show.
"""
end_time = atf.get_job_parameter(job_id, "EndTime")
if end_time in ("Unknown", "None"):
return None
return datetime.fromisoformat(end_time)
@pytest.mark.parametrize(
"partitions,resolved_mode,preempted_state",
[
# A component resolving to SUSPEND is treated as OFF, so REQUEUE and
# CANCEL both win over it whichever component carries them.
(["low_suspend", "low_requeue"], "REQUEUE", "PENDING"),
(["low_requeue", "low_suspend"], "REQUEUE", "PENDING"),
(["low_suspend", "low_cancel"], "CANCEL", "PREEMPTED"),
(["low_cancel", "low_suspend"], "CANCEL", "PREEMPTED"),
# REQUEUE is searched for before CANCEL, so it wins from either
# position. The reversed row is the one that tells this apart from
# simply taking the first component that is not SUSPEND.
(["low_requeue", "low_cancel"], "REQUEUE", "PENDING"),
(["low_cancel", "low_requeue"], "REQUEUE", "PENDING"),
# Every component resolving to SUSPEND means the whole job resolves to
# OFF: it is exempt, so it keeps RUNNING and the preemptor waits.
(["low_suspend", "low_suspend"], "SUSPEND", "RUNNING"),
# Three components put the winning mode in the middle, where neither
# "the first component found" nor "applied to every component" is
# degenerate.
(["low_cancel", "low_requeue", "low_suspend"], "REQUEUE", "PENDING"),
],
)
def test_hetjob_mixed_component_modes_resolve_requeue_then_cancel(
partitions, resolved_mode, preempted_state
):
"""A hetjob with mixed-mode components resolves to one mode for all of them.
The first component found with PreemptMode=REQUEUE sets the mode for every
component; if there is none, the first found with CANCEL does. A component
resolving to SUSPEND is treated as PreemptMode=OFF, and a job with no
REQUEUE or CANCEL component is not preempted at all (slurm.conf(5),
PreemptMode). Each pair of partitions is submitted in both orders, so the
search order is what decides the outcome rather than component position.
"""
hetjob_id, hetjob_components = _submit_hetjob("hetjob_mixed.in", partitions)
for component_id in hetjob_components:
assert atf.wait_for_job_state(
component_id, "RUNNING"
), f"Heterogeneous job component ({component_id}) never started running"
# A higher-priority job needing every node the hetjob holds forces it to be
# preempted as a unit, using the single resolved mode.
preemptor_id = atf.submit_job_sbatch(
f"-N{len(partitions)} --ntasks-per-node=1 -c2 -p highprio -t5 "
f'-o /dev/null --wrap "sleep infinity"',
fatal=True,
)
if resolved_mode == "SUSPEND":
# SUSPEND resolves to OFF for a hetjob, so it is exempt: the preemptor
# must stay pending. With bf_interval=1 a broken exemption would
# preempt well inside NOT_RUNNING_TIMEOUT.
assert not atf.wait_for_job_state(
preemptor_id, "RUNNING", timeout=NOT_RUNNING_TIMEOUT, xfail=True
), f"Preemptor ({preemptor_id}) started; a SUSPEND-resolved hetjob must not be preempted"
_assert_blocked(preemptor_id, "Preemptor")
else:
assert atf.wait_for_job_state(preemptor_id, "RUNNING"), (
f"Preemptor ({preemptor_id}) never started, the hetjob should have "
f"been preempted as {resolved_mode}"
)
# Every component must reach the state implied by the resolved mode
# (RUNNING when SUSPEND-exempt), including components whose own partition
# uses a different mode.
for component_id in hetjob_components:
assert atf.wait_for_job_state(component_id, preempted_state), (
f"Component ({component_id}) is "
f"{atf.get_job_parameter(component_id, 'JobState')}, expected "
f"{preempted_state}; the hetjob should resolve to {resolved_mode} "
f"for all components"
)
def test_hetjob_components_keep_their_own_grace_time():
"""Each hetjob component keeps its own partition's GraceTime.
One resolved PreemptMode is applied to every component, but "the GraceTime
and user warning signal for each component of the heterogeneous job remain
unique" (slurm.conf(5), PreemptMode). Span a hetjob across two partitions
that differ only in GraceTime and check each component's end time moves in
by its own partition's value, not by a single shared one.
Both components enter their grace period in the same pass, so the gap
between their end times is the gap between the two GraceTime settings.
"""
hetjob_id, hetjob_components = _submit_hetjob(
"hetjob_grace.in", ["low_grace_short", "low_grace_long"]
)
for component_id in hetjob_components:
assert atf.wait_for_job_state(
component_id, "RUNNING"
), f"Heterogeneous job component ({component_id}) never started running"
short_id, long_id = hetjob_components
original_end_times = {job_id: _end_time(job_id) for job_id in hetjob_components}
# A higher-priority job needing both nodes selects the hetjob for
# preemption, which starts each component's grace period.
preemptor_id = atf.submit_job_sbatch(
'-N2 --ntasks-per-node=1 -c2 -p highprio -t5 -o /dev/null --wrap "sleep infinity"',
fatal=True,
)
# The hetjob is not actually preempted until the longest grace expires, so
# there is a full GRACE_LONG window in which to read both end times. Bound
# the wait by that window rather than by the atf default: past it the end
# times are gone and a longer wait could only report a later failure.
grace_end_times = {}
for _ in atf.timer(timeout=GRACE_LONG, poll_interval=1):
end_times = {job_id: _end_time(job_id) for job_id in hetjob_components}
if all(
end_times[job_id] is not None
and end_times[job_id] < original_end_times[job_id]
for job_id in hetjob_components
):
grace_end_times = end_times
break
assert grace_end_times, (
f"Both components should have entered a grace period after preemptor "
f"({preemptor_id}) was submitted"
)
gap = (grace_end_times[long_id] - grace_end_times[short_id]).total_seconds()
expected_gap = GRACE_LONG - GRACE_SHORT
assert abs(gap - expected_gap) <= 5, (
f"Component ({long_id}) in low_grace_long should end {expected_gap}s "
f"after component ({short_id}) in low_grace_short, but the gap is "
f"{gap}s; the components did not keep their own GraceTime"
)
def test_normal_job_in_low_suspend_is_suspend_preempted():
"""Positive control: a non-hetjob in low_suspend IS SUSPEND-preempted.
Anchors the ["low_suspend", "low_suspend"] row above, which asserts that an
all-SUSPEND hetjob resolves to OFF and is left alone. That row would read
the same way if SUSPEND preemption were simply inoperative in this module's
partition layout, which differs from test_128_3's. Proving a normal job in
the same partition does get suspended is what makes the hetjob's survival a
real exemption (slurm.conf(5), PreemptMode).
"""
# Every node, so the preemptor has nowhere to start except by preempting.
victim_id = atf.submit_job_sbatch(
"-N3 --ntasks-per-node=1 -c2 -p low_suspend -t2 -o /dev/null "
'--wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
victim_id, "RUNNING", fatal=True
), f"Low-priority victim ({victim_id}) never started running"
preemptor_id = atf.submit_job_sbatch(
'-N1 -c2 -p highprio -t1 -o /dev/null --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
preemptor_id, "RUNNING"
), f"Preemptor ({preemptor_id}) never started; SUSPEND preemption is not live"
assert atf.wait_for_job_state(victim_id, "SUSPENDED"), (
f"Victim ({victim_id}) in low_suspend was not suspended; SUSPEND "
f"preemption is not live in this configuration"
)