blob: 5ca2d7515162cbc6dd09bf94f965432951879a47 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify reservations set and clear NODE_STATE_RES and NODE_STATE_MAINT."""
import os
import time
import pytest
import atf
test_name = os.path.splitext(os.path.basename(__file__))[0]
res_name = f"res_{test_name}"
res_name2 = f"res2_{test_name}"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_nodes(3)
atf.require_slurm_running()
@pytest.fixture()
def nodes():
node_list = sorted(atf.get_nodes(quiet=True).keys())[:3]
yield node_list
slurm_user = atf.properties["slurm-user"]
for name in [res_name, res_name2]:
atf.run_command(f"scontrol delete reservationname={name}", user=slurm_user)
atf.wait_for_node_state(
node_list, ["RESERVED", "MAINTENANCE"], reverse=True, fatal=True
)
def create_resv(name, node_list, flags="ignore_jobs", duration=60, start="now"):
"""Create a reservation on the nodes. Wait for the RESERVED state."""
slurm_user = atf.properties["slurm-user"]
atf.run_command(
f"scontrol create reservationname={name} start={start} "
f"duration={duration} nodes={','.join(node_list)} "
f"user={slurm_user} flags={flags}",
user=slurm_user,
fatal=True,
)
if start != "now":
return
assert atf.wait_for_node_state(
node_list, "RESERVED"
), f"{node_list} must reach the RESERVED state while {name} is active"
def node_state(node):
"""Return the current state string for one node."""
return atf.get_node_parameter(node, "state")
def test_res_flag_cleared_on_delete(nodes):
"""Delete a reservation. The nodes lose the RESERVED state."""
slurm_user = atf.properties["slurm-user"]
create_resv(res_name, nodes[:2])
assert (
atf.get_node_parameter(nodes[0], "reservation") == res_name
), "The reservation field must show the name of the reservation"
atf.run_command(
f"scontrol delete reservationname={res_name}", user=slurm_user, fatal=True
)
assert atf.wait_for_node_state(
nodes[:2], "RESERVED", reverse=True
), f"{nodes[:2]} must lose the RESERVED state after the delete"
assert not atf.get_node_parameter(
nodes[0], "reservation"
), "The reservation field must be empty after the delete"
@pytest.mark.slow
def test_res_flag_cleared_on_expiry(nodes):
"""A reservation stops at its end time. The nodes lose the RESERVED
state. No delete command is needed."""
create_resv(res_name, nodes[:2], duration=1)
# The reservation is one minute long. That is more than the default poll
# timeout, so use a longer one.
assert atf.wait_for_node_state(
nodes[:2], "RESERVED", reverse=True, timeout=120
), f"{nodes[:2]} must lose the RESERVED state after the reservation ends"
def test_res_flag_cleared_on_shrink(nodes):
"""Remove one node from a reservation. That node loses the RESERVED
state. The other node keeps it."""
slurm_user = atf.properties["slurm-user"]
create_resv(res_name, nodes[:2])
atf.run_command(
f"scontrol update reservationname={res_name} nodes={nodes[0]}",
user=slurm_user,
fatal=True,
)
# nodes[1] losing RESERVED is the barrier: it proves the recompute the
# update triggers has run before nodes[0] is read below. One snapshot
# after the wait keeps the two assertions on nodes[0] consistent with
# each other.
assert atf.wait_for_node_state(
nodes[1], "RESERVED", reverse=True
), f"{nodes[1]} must lose the RESERVED state after you remove it"
nodes_now = atf.get_nodes(quiet=True)
assert not nodes_now[nodes[1]][
"reservation"
], f"The reservation field on {nodes[1]} must be empty after you remove it"
assert (
"RESERVED" in nodes_now[nodes[0]]["state"]
), f"{nodes[0]} must keep the RESERVED state because it stays in the reservation"
assert (
nodes_now[nodes[0]]["reservation"] == res_name
), f"The reservation field on {nodes[0]} must still show {res_name}"
def test_maint_flag_cleared_on_delete(nodes):
"""A MAINT reservation sets the MAINTENANCE state. A delete of the
reservation clears this state."""
slurm_user = atf.properties["slurm-user"]
create_resv(res_name, nodes[:2], flags="maint,ignore_jobs")
assert atf.wait_for_node_state(
nodes[0], "MAINTENANCE"
), "The MAINT reservation must set the MAINTENANCE state"
atf.run_command(
f"scontrol delete reservationname={res_name}", user=slurm_user, fatal=True
)
assert atf.wait_for_node_state(
nodes[0], "MAINTENANCE", reverse=True
), "The MAINTENANCE state must clear after the delete"
def test_maint_kept_while_second_maint_resv_covers_node(nodes):
"""A node is in two MAINT reservations. Delete one reservation. The node
keeps the MAINTENANCE state."""
slurm_user = atf.properties["slurm-user"]
create_resv(res_name, nodes[:2], flags="maint,ignore_jobs")
create_resv(res_name2, [nodes[0], nodes[2]], flags="maint,ignore_jobs,overlap")
atf.run_command(
f"scontrol delete reservationname={res_name2}", user=slurm_user, fatal=True
)
# nodes[2] is only in the deleted reservation. It must lose both states,
# which proves the recompute ran before the states below were read.
assert atf.wait_for_node_state(
nodes[2], ["RESERVED", "MAINTENANCE"], reverse=True
), f"{nodes[2]} must lose both states after the delete"
for node in nodes[:2]:
assert "MAINTENANCE" in node_state(
node
), f"{node} must keep the MAINTENANCE state because {res_name} covers it"
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 11),
reason="Earlier versions clear both flags from the overlapped node until "
"the next recompute",
)
def test_maint_clears_but_res_stays_on_mixed_overlap(nodes):
"""A node is in a MAINT reservation and a plain reservation. Delete the
MAINT reservation. The node loses MAINTENANCE and keeps RESERVED."""
slurm_user = atf.properties["slurm-user"]
create_resv(res_name, nodes[:2])
create_resv(res_name2, [nodes[0], nodes[2]], flags="maint,ignore_jobs,overlap")
assert atf.wait_for_node_state(
nodes[0], "MAINTENANCE"
), f"{nodes[0]} must reach the MAINTENANCE state from {res_name2}"
atf.run_command(
f"scontrol delete reservationname={res_name2}", user=slurm_user, fatal=True
)
assert atf.wait_for_node_state(
nodes[2], "RESERVED", reverse=True
), f"{nodes[2]} must lose the RESERVED state after the delete"
nodes_now = atf.get_nodes(quiet=True)
state = nodes_now[nodes[0]]["state"]
assert (
"MAINTENANCE" not in state
), f"{nodes[0]} must lose the MAINTENANCE state with {res_name2} gone"
assert (
"RESERVED" in state
), f"{nodes[0]} must keep the RESERVED state from {res_name}"
assert (
nodes_now[nodes[0]]["reservation"] == res_name
), f"The reservation field on {nodes[0]} must fall back to {res_name}"
@pytest.mark.slow
def test_future_reservation_leaves_nodes_unreserved(nodes):
"""A reservation that starts later does not set the RESERVED state. The
nodes get the state when the start time arrives."""
# duration is minutes, start is seconds; use a duration that does not
# collide with the start offset below so the two are not misread as the
# same unit.
create_resv(res_name, nodes[:2], start="now+60", duration=5)
# Poll the reservation's own State field instead of sleeping a fixed
# amount of wall-clock time. That ties the negative check to slurmctld's
# own answer for whether the reservation is active yet, so a slow
# 'scontrol create' or a loaded controller cannot turn a *correct* late
# RESERVED flag into a false failure here.
deadline = time.time() + 90
while atf.get_reservation_parameter(res_name, "State") == "INACTIVE":
assert time.time() < deadline, f"{res_name} never left the INACTIVE state"
for node in nodes[:2]:
assert "RESERVED" not in node_state(
node
), f"{node} must not get the RESERVED state before {res_name} starts"
assert not atf.get_node_parameter(
node, "reservation"
), f"The reservation field on {node} must be empty before {res_name} starts"
time.sleep(2)
# The reservation starts 60s after creation, past the default poll
# timeout, so use a longer one.
assert atf.wait_for_node_state(
nodes[:2], "RESERVED", timeout=120
), f"{nodes[:2]} must get the RESERVED state when {res_name} starts"