blob: 90e77c0f4143d77bea84c246458b9f16820e8ed7 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""The node list of a pending job across rejected and accepted updates.
A job's required node list fixes where it can run once its node counts are
final: a list longer than --nodes means "allocate from this list", a list
as long as a fixed --nodes is the whole allocation. Every update must leave
the job able to run where its current list and node count allow:
- a rejected update must leave the job as submitted. Ticket 25865: a
rejected update left a job with a list longer than --nodes with the whole
list required, and it was never scheduled again;
- an accepted update of the list or the node count must let the job run
where the new list and count allow, keeping nothing of the old list.
The same must hold for a job recovered by a slurmctld restart, which
rebuilds the node lists on recovery: with no update at all, and with an
update made after the restart.
"""
import pytest
import atf
pytestmark = pytest.mark.slow
_BLOCKED_REASONS = ("Resources", "Priority")
# A negative wait only needs a few scheduler cycles (sched_interval=1)
_NEGATIVE_WAIT_SEC = 5
_NODE_FIELDS = ("ReqNodeList", "ExcNodeList", "NumNodes")
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
component="sbin/slurmctld",
reason="Ticket 25865: node list exclusion kept across failed updates in 26.11",
)
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
atf.require_nodes(2, [("CPUs", 2), ("RealMemory", 512)])
atf.require_config_parameter("DefMemPerNode", "128")
atf.require_slurm_running()
@pytest.fixture(scope="module")
def nodes():
"""(node_a, node_b, exclude): two test nodes and -x for the rest.
require_nodes() guarantees at least two nodes. A job whose node list is
cleared or grown could run on any further node instead, so the jobs
below exclude the others.
"""
pair = atf.run_job_nodes("-N2 true", fatal=True)
assert len(pair) == 2, f"Expected two allocated nodes, got {pair}"
others = sorted(set(atf.get_nodes(quiet=True)) - set(pair))
exclude = f"-x {','.join(others)}" if others else ""
return pair[0], pair[1], exclude
def _occupy(node):
job_id = atf.submit_job_sbatch(
f"-N1 -w {node} --exclusive -t5 -o /dev/null --wrap 'sleep infinity'",
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
return job_id
def _submit(args):
return atf.submit_job_sbatch(
f"{args} --exclusive -t1 -o /dev/null --wrap 'sleep infinity'",
fatal=True,
)
def _assert_pending_blocked(job):
"""Check that the job stays pending for lack of nodes over a few cycles."""
assert not atf.wait_for_job_state(
job, "RUNNING", timeout=_NEGATIVE_WAIT_SEC, xfail=True
), f"Job ({job}) should pend while the nodes it may use are busy"
state = atf.get_job_parameter(job, "JobState", quiet=True)
assert state == "PENDING", f"Job ({job}) should be pending, is {state}"
reason = atf.get_job_parameter(job, "Reason", quiet=True)
assert reason in _BLOCKED_REASONS, f"Job ({job}) is pending for {reason}"
def _assert_runs_on(job, expected):
assert atf.wait_for_job_state(
job, "RUNNING"
), f"Job ({job}) never started; it must be able to run on {expected}"
allocated = atf.node_range_to_list(
atf.get_job_parameter(job, "NodeList", quiet=True)
)
assert sorted(allocated) == sorted(
expected
), f"Job ({job}) should run on {expected}, got {allocated}"
@pytest.mark.parametrize(
"whole_list", [False, True], ids=["list_longer_than_count", "list_is_count"]
)
def test_node_list_kept_after_restart(nodes, whole_list):
"""A job recovered by a restart keeps the meaning of its node list.
-N1 -w node_a,node_b must still run on node_b once it is freed, and
-N1 -w node_a must stay off the free node_b, with no update in between.
"""
node_a, node_b, exclude = nodes
occupant_a = _occupy(node_a)
if whole_list:
job = _submit(f"-N1 -w {node_a} {exclude}")
else:
occupant_b = _occupy(node_b)
job = _submit(f"-N1 -w {node_a},{node_b} {exclude}")
_assert_pending_blocked(job)
atf.restart_slurmctld()
_assert_pending_blocked(job)
if whole_list:
atf.cancel_jobs([occupant_a], fatal=True)
_assert_runs_on(job, [node_a])
else:
atf.cancel_jobs([occupant_b], fatal=True)
_assert_runs_on(job, [node_b])
@pytest.mark.parametrize("restart", [False, True], ids=["no_restart", "restart"])
@pytest.mark.parametrize(
"bad_update",
[
"ExcNodeList=nosuchnode",
"ReqNodeList=nosuchnode",
"NumNodes=0",
# Rejected before the node bitmaps are rebuilt: the translation
# applied at the end of every update must leave them as they were
"Partition=nosuchpart",
],
ids=["exc_nodes", "req_nodes", "node_count", "partition"],
)
def test_node_list_kept_after_rejected_update(nodes, restart, bad_update):
"""-N1 -w node_a,node_b still runs on node_b after a rejected update."""
node_a, node_b, exclude = nodes
_occupy(node_a)
occupant_b = _occupy(node_b)
job = _submit(f"-N1 -w {node_a},{node_b} {exclude}")
_assert_pending_blocked(job)
if restart:
atf.restart_slurmctld()
_assert_pending_blocked(job)
# Taken after the restart: NumNodes is displayed as "1-1" or "1" depending
# on whether the scheduler has estimated the node count yet, which a
# restart resets. The comparison is about the rejected update only.
before = {f: atf.get_job_parameter(job, f, quiet=True) for f in _NODE_FIELDS}
result = atf.run_command(
f"scontrol update JobId={job} {bad_update}",
user=atf.properties["slurm-user"],
xfail=True,
)
assert result["exit_code"] != 0, f"Update '{bad_update}' should be rejected"
assert (
"invalid" in result["stderr"].lower()
), f"Update '{bad_update}' should be rejected as invalid: {result}"
after = {f: atf.get_job_parameter(job, f, quiet=True) for f in _NODE_FIELDS}
assert after == before, (
f"Job ({job}) node fields changed after the rejected update: "
f"{before} -> {after}"
)
atf.cancel_jobs([occupant_b], fatal=True)
_assert_runs_on(job, [node_b])
@pytest.mark.parametrize("restart", [False, True], ids=["no_restart", "restart"])
@pytest.mark.parametrize(
"submit,update,expected",
[
("-N1 -w {node_a}", "ReqNodeList={node_b}", ["node_b"]),
("-N1 -w {node_a}", "ReqNodeList=", ["node_b"]),
("-N1 -w {node_a}", "NumNodes=2", ["node_a", "node_b"]),
("-N1 -w {node_a}", "ReqNodeList={node_a},{node_b}", ["node_b"]),
("-N2 -w {node_a},{node_b}", "NumNodes=1", ["node_b"]),
],
ids=["req_node_b", "req_cleared", "two_nodes", "req_grown", "count_shrunk"],
)
def test_node_list_rederived_after_accepted_update(
nodes, restart, submit, update, expected
):
"""A pending job runs where an accepted update sends it.
While node_a is busy the job pends, kept off node_b by its node list or
by needing both nodes. After the update it must run on node_b (node_a
is still busy) or, with two nodes, on both once node_a is freed.
"""
node_a, node_b, exclude = nodes
names = {"node_a": node_a, "node_b": node_b}
submit = submit.format(**names)
update = update.format(**names)
expected = [names[name] for name in expected]
occupant_a = _occupy(node_a)
job = _submit(f"{submit} {exclude}")
_assert_pending_blocked(job)
if restart:
atf.restart_slurmctld()
_assert_pending_blocked(job)
atf.run_command(
f"scontrol update JobId={job} {update}",
user=atf.properties["slurm-user"],
fatal=True,
)
if node_a in expected:
atf.cancel_jobs([occupant_a], fatal=True)
_assert_runs_on(job, expected)