blob: fbf552925a85d7af86cd4261ae606b8781df6fba [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Dynamic nodes must still be schedulable after delete and recreate.
Deleting a node clears its bit in share_node_bitmap. Short of the node going
DOWN or a job completing on it, the only paths that set it again are node
registration and ping replies, both of which need a slurmd. An EXTERNAL node
has none. A CLOUD node has one but cannot reach it either, because it only
boots once a job is allocated and the cleared bit is what blocks the
allocation. Either way the node is left unschedulable at the recycled node
index unless creation sets the bit.
The first node created at a given index inherits a set bit from
_build_bitmaps(), which sets every slot including ones holding no node, so
only the recreate is affected. Each test therefore runs a job, deletes and
recreates the node at the same index, and runs the job again -- the second
run is the assertion.
--exclusive is covered because it takes a different branch when the candidate
nodes are filtered (idle_node_bitmap rather than share_node_bitmap), yet is
still subject to the unconditional intersection with share_node_bitmap that
_pick_best_nodes() applies before calling the select plugin.
"""
import logging
import pytest
import atf
# The creation-time bit_set(share_node_bitmap) that makes the recreated node
# schedulable is in 26.11 and was backported to 26.05.3, so on any older
# slurmctld the "after recreate" run fails by design. xfail rather than skip
# so the first run still exercises those versions, and so the XPASS says when
# this gate can be retired.
pytestmark = [
pytest.mark.xfail(
atf.get_version("sbin/slurmctld") < (26, 5, 3),
reason="Issue 51106: dynamic node creation only sets share_node_bitmap in slurmctld 26.05.3+",
),
]
external_node = "extnode1"
cloud_node = "cloudnode1"
# The cloud node never registers (ResumeProgram is /bin/true), so a short
# timeout just bounds how long a stuck node lingers during cleanup.
resume_timeout = 15
@pytest.fixture(scope="module", autouse=True)
def setup():
# MaxNodeCount is required to allow creating dynamic nodes, and
# slurmctld rejects `scontrol create nodename=` unless SelectType is
# select/cons_tres.
atf.require_config_parameter("MaxNodeCount", 8)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
# Power saving has to be configured before a node can be State=CLOUD.
# ResumeProgram is a no-op, so the cloud node is allocated and asked to
# power up but never registers -- enough to prove it was selected.
atf.require_config_parameter("ResumeProgram", "/bin/true")
atf.require_config_parameter("SuspendProgram", "/bin/true")
atf.require_config_parameter("ResumeTimeout", resume_timeout)
atf.require_config_parameter("SuspendTime", "INFINITE")
# The dynamic nodes join the default partition via Nodes=ALL, so a job can
# target one with -w and no extra partition is needed.
atf.require_config_parameter(
"PartitionName", {"primary": {"Nodes": "ALL", "Default": "YES"}}
)
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
atf.require_slurm_running()
@pytest.fixture(autouse=True)
def reconfigure_between_tests():
"""Reconfigure before every test.
_build_bitmaps() re-runs bit_set_all(share_node_bitmap), so each test
starts from the state a freshly started slurmctld would have. Without
this, a node deleted by the previous test's teardown would leave the next
test's *first* job failing, which points at the wrong half of the cycle.
"""
atf.run_command(
"scontrol reconfigure",
user=atf.properties["slurm-user"],
fatal=True,
)
def node_states(node_name):
"""Node state as a list of strings, or [] if the node no longer exists.
A single query keeps this atomic and cheap. get_node_parameter() fails the
test outright on an unknown node, so guarding it with a separate get_nodes()
would both double the scontrol round trips this makes -- inside a poll loop
bounded by ResumeTimeout -- and leave a window where a teardown racing a
deletion still errors.
"""
return atf.get_nodes(quiet=True).get(node_name, {}).get("state", [])
class DynamicNodes:
"""Create and delete dynamic nodes of one State, tracking what exists."""
def __init__(self, state):
self.state = state
self.live = set()
def create(self, name):
atf.run_command(
f"scontrol create nodename={name} CPUs=4 State={self.state}",
user=atf.properties["slurm-user"],
fatal=True,
)
self.live.add(name)
def delete(self, name, fatal=True):
"""Remove a node, waiting for it to be released first.
_delete_node_ptr() refuses a node that is ALLOCATED or COMPLETING,
and cancelling a job only waits on the job, not on the node
bookkeeping that follows it.
fatal=False is for teardown, where failing outright would leave the
node behind and turn the next parametrization into a second, more
confusing failure.
"""
for _ in atf.timer():
if not ({"ALLOCATED", "COMPLETING", "MIXED"} & set(node_states(name))):
break
else:
message = f"Node {name} was never released, so cannot be deleted"
if fatal:
pytest.fail(message)
logging.warning(message)
return
result = atf.run_command(
f"scontrol delete nodename={name}",
user=atf.properties["slurm-user"],
fatal=fatal,
)
if result["exit_code"]:
logging.warning(f"Could not delete node {name}: {result['stderr']}")
return
self.live.discard(name)
def _cleanup(nodes):
"""Cancel jobs and remove any node the test left behind.
conftest.py function_setup also cancels jobs, but it unwinds after this
fixture, and a node that still holds a job cannot be deleted. A failure
here only warns: making it fatal would report the same underlying problem
twice, once as this teardown and again as an "already exists" create in
the next parametrization, with the second message naming the wrong cause.
"""
atf.cancel_all_jobs(quiet=True)
for name in sorted(nodes.live):
nodes.delete(name, fatal=False)
@pytest.fixture
def external_nodes():
"""An EXTERNAL node manager, cleaned up after the test."""
nodes = DynamicNodes("external")
yield nodes
_cleanup(nodes)
@pytest.fixture
def cloud_nodes():
"""A CLOUD node manager, cleaned up after the test."""
nodes = DynamicNodes("cloud")
yield nodes
_cleanup(nodes)
def expect_running(sbatch_args, label):
"""Submit an external job and require it to reach RUNNING."""
job_id = atf.submit_job_sbatch(sbatch_args, fatal=True)
if not atf.wait_for_job_state(job_id, "RUNNING"):
pytest.fail(
f"{label}: job {job_id} never reached RUNNING. Reason="
f"{atf.get_job_parameter(job_id, 'Reason', quiet=True)}"
)
return job_id
def expect_scheduled(sbatch_args, target, label):
"""Submit a cloud job and require the scheduler to select target.
The node never registers, so the job cannot reach RUNNING; being allocated
the node is the whole assertion. Checking the allocation rather than just
"the job left PENDING" matters, because a job that dies for an unrelated
reason also leaves PENDING without the node ever having been picked.
NodeList is compared exactly rather than by substring, since the job asks
for a single node by name and a substring would also accept a longer name
that merely starts with target. The node reaching POWERING_UP is not a
second acceptance condition: select_nodes() sets the job's node list at
allocation time, before the node transitions, so NodeList always shows it
first, and keying off the node state would also accept a power up that no
job caused.
"""
job_id = atf.submit_job_sbatch(sbatch_args, fatal=True)
nodes, state, reason = None, None, None
for _ in atf.timer():
# One query per iteration, so NodeList and JobState describe the same
# instant. Read separately, the job can be allocated the node and then
# go terminal in the gap between the two, and breaking on that state
# without rechecking NodeList reports an allocation that did happen as
# one that never did. Reason is captured here too rather than queried
# from inside pytest.fail(), where a job that has since been purged
# would replace the whole diagnostic with "Job was not found".
job = atf.get_jobs(quiet=True).get(job_id)
if not job:
continue
nodes = job.get("NodeList")
state = job.get("JobState")
reason = job.get("Reason")
if nodes and str(nodes).strip() == target:
return job_id
if state and not ({"PENDING", "CONFIGURING"} & set(str(state).split("+"))):
break
pytest.fail(
f"{label}: job {job_id} was never allocated {target} "
f"(JobState={state}, NodeList={nodes}, Reason={reason})"
)
@pytest.mark.parametrize("exclusive", [False, True], ids=["shared", "exclusive"])
def test_external_job_after_node_recreate(external_nodes, exclusive):
"""An external job still runs after its EXTERNAL node is recreated."""
atf.require_version(
(25, 5),
"bin/sbatch",
reason="Issue 50201: sbatch --external was added in 25.05",
)
args = f"--external -n1 -t1 -w {external_node}"
if exclusive:
args += " --exclusive"
external_nodes.create(external_node)
expect_running(args, "before recreate")
atf.cancel_all_jobs(fatal=True)
external_nodes.delete(external_node)
external_nodes.create(external_node)
expect_running(args, "after recreate")
@pytest.mark.parametrize("exclusive", [False, True], ids=["shared", "exclusive"])
def test_cloud_job_after_node_recreate(cloud_nodes, exclusive):
"""A job is still allocated to a CLOUD node after the node is recreated.
A cloud node only powers up once a job is allocated to it, so failing to
schedule leaves it unable to ever register and repair itself.
"""
args = f"-n1 -t1 -w {cloud_node} --wrap 'sleep infinity'"
if exclusive:
args += " --exclusive"
cloud_nodes.create(cloud_node)
expect_scheduled(args, cloud_node, "before recreate")
atf.cancel_all_jobs(fatal=True)
cloud_nodes.delete(cloud_node)
cloud_nodes.create(cloud_node)
expect_scheduled(args, cloud_node, "after recreate")