blob: b6e830b9cc377ea7d6e05fd392ae08a0ad88097f [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Backfill starts jobs on a nextstate=resume node that is still available.
A node waiting on a non-ASAP reboot with nextstate=resume can be allocated
until its reboot is issued, and backfill must start a job there when the node
can run it now. A node drained by reboot ASAP, or one whose reboot was already
issued, can not be allocated. Those cases, and the same node after a slurmctld
restart, check that a job still waits there.
"""
import pytest
import atf
node = None
@pytest.fixture(scope="module", autouse=True)
def setup():
"""Test setup with required configurations."""
atf.require_config_parameter("SchedulerType", "sched/backfill")
# Only backfill may start the job: with defer, nothing starts a job at
# submit, and sched_interval=-1 turns the main scheduler off. Use the
# (key, value) form so a value the config already has is replaced; Slurm
# honors the first occurrence of a key.
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", -1))
atf.require_config_parameter_includes("SchedulerParameters", "defer")
# Backfill plans a pending job, and sets SchedNodes, only at or above
# bf_min_prio_reserve; the perf config raises it above our job's priority.
# Use 1 rather than the default 0: the backfill parser logs an explicit 0
# as invalid, and 1 only excludes held (priority 0) jobs, which we have none.
atf.require_config_parameter_includes(
"SchedulerParameters", ("bf_min_prio_reserve", 1)
)
# Issue a requested reboot within a second, not on the 10 s power thread
atf.require_config_parameter_includes(
"SlurmctldParameters", ("power_save_interval", 1)
)
# The holder and the job share the node, which needs cons_tres; CR_CPU is
# not valid with select/linear
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter("RebootProgram", "/bin/true")
# A node whose reboot was issued must not time out during the test.
atf.require_config_parameter("ResumeTimeout", 600)
nodes = atf.require_nodes(1, [("CPUs", 2), ("RealMemory", 512)])
atf.require_config_parameter(
"PartitionName",
{"part1": {"Nodes": nodes[0], "Default": "YES", "MaxTime": "INFINITE"}},
)
atf.require_slurm_running()
global node
node = nodes[0]
@pytest.fixture(scope="function", autouse=True)
def reset_node():
"""Cancel any pending reboot and jobs, and return the node to service."""
yield
# Cancel the reboot first: once the jobs are gone, an idle node with a
# pending reboot has it issued, and cancel_reboot then fails.
atf.run_command(
f"scontrol cancel_reboot {node}", user=atf.properties["slurm-user"], quiet=True
)
atf.cancel_all_jobs(quiet=True)
atf.run_command(
f"scontrol update nodename={node} state=resume",
user=atf.properties["slurm-user"],
quiet=True,
)
# Exact match: IDLE together with NOT_RESPONDING is not allocable yet
atf.wait_for_node_state(node, "IDLE", operator=atf.SetMatch.EQUAL, fatal=True)
def submit_holder():
"""Run a one CPU job so the node is MIXED, with a CPU left free."""
job_id = atf.submit_job_sbatch(
f"-J holder -N1 -n1 -w {node} --time=10 --wrap 'sleep infinity'",
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
return job_id
def submit_pack():
"""Submit a one CPU job that must run on the node."""
return atf.submit_job_sbatch(
f"-J pack -N1 -n1 -w {node} --time=2 --wrap 'sleep 30'", fatal=True
)
def job_snapshot(job_id):
"""Return a job's state, backfill-planned nodes and last scheduling
evaluation time from one scontrol call, so a start cannot fall between
two reads."""
job = atf.get_jobs(job_id=job_id, quiet=True)[job_id]
return (
job["JobState"],
job.get("SchedNodeList", ""),
job["LastSchedEval"],
)
def check_waits_on_node(job_id, node_flag):
"""Check backfill evaluated a job on the node and the job still waits.
Backfill sets SchedNodes in the pass that evaluates the job, both when it
postpones the start and when select_nodes() refuses it, so this does not
tell the two apart. A postponed job is postponed again in every pass, so
wait for a later pass, which advances LastSchedEval, and check the job
still waits. The state and nodes come from one scontrol call, so a start
cannot fall between two reads.
"""
# Poll faster than the timer default so a pass is seen soon after it runs
for _ in atf.timer(poll_interval=0.2, fatal=True):
first = job_snapshot(job_id)
if first[0] != "PENDING" or first[1] == node:
break
assert first[0] == "PENDING", f"The job should wait, not be {first[0]}"
for _ in atf.timer(poll_interval=0.2, fatal=True):
state, planned, last_eval = job_snapshot(job_id)
if state != "PENDING" or last_eval != first[2]:
break
assert state == "PENDING", f"The job should still wait, not be {state}"
assert planned == node, "Backfill should plan the job on the resuming node"
assert node_flag in atf.get_node_parameter(
node, "state"
), f"The node should still be {node_flag}"
# Releases before 26.11 postpone every backfill start on a resuming node.
@pytest.mark.xfail(
atf.get_version() < (26, 11),
reason="Ticket 25233: backfill treats an available resuming node as future-only",
)
def test_starts_on_available_resuming_node():
"""A job starts on a MIXED node waiting on a non-ASAP reboot."""
submit_holder()
atf.run_command(
f"scontrol reboot nextstate=resume {node}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.wait_for_node_state(node, "REBOOT_REQUESTED", fatal=True)
assert "RESUME" in atf.get_node_parameter(
node, "next_state_after_reboot", default=[]
), "The node should be a resuming node"
job_id = submit_pack()
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "Backfill should start the job on the available resuming node"
assert "REBOOT_REQUESTED" in atf.get_node_parameter(
node, "state"
), "The reboot request should still be pending"
assert "RESUME" in atf.get_node_parameter(
node, "next_state_after_reboot", default=[]
), "The node should still be a resuming node"
# The job deferred the reboot; it must still be issued once the node is idle
atf.cancel_all_jobs(quiet=True, fatal=True)
atf.wait_for_node_state(node, "REBOOT_ISSUED", fatal=True)
def test_asap_reboot_node_stays_unavailable():
"""A job does not start on a node drained by reboot ASAP.
select_nodes() also refuses the node, so this cannot tell a backfill
postpone from a refused start.
"""
submit_holder()
atf.run_command(
f"scontrol reboot ASAP nextstate=resume {node}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.wait_for_node_state(node, "DRAIN", fatal=True)
job_id = submit_pack()
check_waits_on_node(job_id, "DRAIN")
def check_waits(job_id, node_flag):
"""Check a job still waits after a later backfill pass, on a node that
backfill does not plan on, so SchedNodes stays empty."""
first = job_snapshot(job_id)
assert first[0] == "PENDING", f"The job should wait, not be {first[0]}"
for _ in atf.timer(poll_interval=0.2, fatal=True):
state, _, last_eval = job_snapshot(job_id)
if state != "PENDING" or last_eval != first[2]:
break
assert state == "PENDING", f"The job should still wait, not be {state}"
assert node_flag in atf.get_node_parameter(
node, "state"
), f"The node should still be {node_flag}"
@pytest.mark.parametrize("restart", [False, True])
@pytest.mark.parametrize("nextstate", ["nextstate=resume", ""])
def test_reboot_issued_node_stays_unavailable(nextstate, restart):
"""A job does not start on a node whose reboot was already issued.
Without a restart, select_nodes() also refuses the node, so this cannot
tell a backfill postpone from a refused start. The restart case covers the
controller coming back up with the node in REBOOT_ISSUED, where the node
must not be made available again, with and without nextstate=resume. A
node without nextstate is not a resuming node, so backfill does not plan
on it and only the wait is checked.
"""
atf.run_command(
f"scontrol reboot {nextstate} {node}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.wait_for_node_state(node, "REBOOT_ISSUED", fatal=True)
if restart:
atf.restart_slurmctld()
atf.wait_for_node_state(node, "REBOOT_ISSUED", fatal=True)
job_id = submit_pack()
if nextstate:
check_waits_on_node(job_id, "REBOOT_ISSUED")
else:
check_waits(job_id, "REBOOT_ISSUED")