blob: 25b54ff8edfcc9c85c601886efce4115e0e2f243 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Tests for CPU drain and resume via the TresName/Action node options.
Validates that draining individual CPU indices marks a node DEGRADED, reduces
scheduling capacity, survives slurmctld restart, and is fully reversed by
Action=Resume.
NODE_A and NODE_B are non-adjacent in the cluster's node bitmap (node1 is never
targeted and NODE_GOOD sits between them), so spanning allocations
exercise the discontinuous-bitmap code paths in the select plugin. NODE_SMT
schedules two threads per core, so a drain names one thread and removes the
whole core. NODE_SOCKETS has as many CPUs as sockets, so a CPU names a core
and there are more cores than CPUs.
Some tests here guard against a specific control-plane failure rather than
check a behaviour:
- a resume naming CPUs that were never drained is a no-op, and must not leave
the node an empty drain map with no reasons beside it, which the next state
save would walk
- a request naming a node that does not exist must apply to no node, rather
than drain the rest of the hostlist and report only a failure, which leaves
the admin unable to tell what was applied
- every field, not only the TRES selectors, must be refused when given twice,
rather than a repeated TresName= or Action= silently taking the last one
- a MaxDrainedTres the parser cannot read is read as "no limit", so it must
be reported rather than silently disable the safety limit
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
# require_nodes(4, ...) yields node1..node4. node1 is never targeted, so it
# absorbs the scheduler's first-choice bias, and NODE_GOOD sits between the two
# drain nodes to keep them non-adjacent in the node bitmap.
NODE_A = "node2"
NODE_GOOD = "node3"
NODE_B = "node4"
# Filled in by setup(); an existing node may not have this topology.
NODE_SMT = None
NODE_SOCKETS = None
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
component="bin/scontrol",
reason="Ticket 25180: cpu TresName/Action added in 26.11",
)
atf.require_version(
(26, 11),
component="sbin/slurmd",
reason="Ticket 25180: node MaxDrainedTres added in 26.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_nodes(4, [("CPUs", 4)])
global NODE_SMT
# 1 socket, 2 cores, 2 threads: CPUs 0-1 are core 0, CPUs 2-3 are core 1.
NODE_SMT = atf.require_nodes(
1,
[
("Sockets", 1),
("CoresPerSocket", 2),
("ThreadsPerCore", 2),
("CPUs", 4),
],
)[0]
global NODE_SOCKETS
# 2 sockets of 4 cores, configured with one CPU per socket.
NODE_SOCKETS = atf.require_nodes(
1,
[
("Sockets", 2),
("CoresPerSocket", 4),
("ThreadsPerCore", 1),
("CPUs", 2),
],
)[0]
atf.require_slurm_running()
def _update_tres_raw(*args):
"""Run "scontrol update" with the fields exactly as given.
atf.update_tres_drain() builds a well-formed command, so it cannot express
a field supplied twice.
"""
return atf.run_command(
f"scontrol update {' '.join(args)}",
user=atf.properties["slurm-user"],
quiet=True,
)
def _drained_cpus(node):
"""The node's DrainedTRES CPU text, or "" when nothing is drained."""
return atf.get_node_tres_drained(node) or ""
@pytest.fixture(autouse=True)
def resume_everything():
"""Leave every node undrained and resumed, so each test starts clean.
State=RESUME also returns a node that a MaxDrainedTres breach or the test
drained as a whole.
"""
yield
for node in (NODE_A, NODE_B, NODE_GOOD, NODE_SMT, NODE_SOCKETS):
if node:
atf.update_tres_drain(node, "cpu", "Index=ALL", action="Resume", quiet=True)
# Not fatal: a node that is not drained refuses the transition.
atf.run_command(
f"scontrol update NodeName={node} State=RESUME Reason=cleanup",
user=atf.properties["slurm-user"],
quiet=True,
)
@pytest.fixture
def max_drained_tres():
"""Set a node's MaxDrainedTres for one test, and drop it afterwards.
set_node_parameter() only restores slurm.conf when the module ends, so
the later tests would still have the limit.
"""
nodes = []
def _set(node, spec):
if node not in nodes:
nodes.append(node)
atf.set_node_parameter(node, "MaxDrainedTres", spec)
yield _set
for node in nodes:
atf.set_node_parameter(node, "MaxDrainedTres", None)
@pytest.fixture
def clear_triggers():
"""Clear the slurm user's triggers after the test."""
yield
atf.run_command(
f"strigger --clear --user={atf.properties['slurm-user']}",
user=atf.properties["slurm-user"],
quiet=True,
)
def test_cpu_drain_reduces_capacity_and_resume_restores():
"""Draining one CPU on NODE_A degrades it and blocks a spanning 2-node job."""
result = atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="cpu_drain")
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
NODE_A, "DEGRADED"
), "NODE_A should be DEGRADED after CPU drain"
for _ in atf.timer(fatal=True):
f = atf.get_node_tres_drained(NODE_A)
if bool(f) and "cpu" in f.lower():
break
assert "DEGRADED" not in (
atf.get_node_parameter(NODE_B, "state") or ""
), "NODE_B must not be DEGRADED -- drain is per-node"
# 4 tasks per node needs all 4 CPUs on NODE_A, but IDX:0 is drained -> PENDING.
job_id = atf.submit_job_sbatch(
f'--nodes=2 -w {NODE_A},{NODE_B} --ntasks-per-node=4 --wrap="sleep infinity"'
)
assert job_id != 0, "job should be accepted (but stay pending)"
assert atf.job_stays_pending(
job_id
), "4-tasks-per-node job must stay PENDING while a CPU is drained on NODE_A"
# Resume: NODE_A returns to 4 CPUs and the spanning job can start.
result = atf.update_tres_drain(NODE_A, "cpu", "Index=0", action="Resume")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
f = atf.get_node_tres_drained(NODE_A)
if not f or "cpu" not in f.lower():
break
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "spanning job should start once the CPU is resumed"
def test_cpu_drain_allows_already_running_job():
"""A spanning job running before a CPU drain on NODE_A continues running after."""
job_id = atf.submit_job_sbatch(
f'--nodes=2 -w {NODE_A},{NODE_B} --ntasks-per-node=4 --wrap="sleep infinity"'
)
assert job_id != 0, "job submission should succeed"
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "4-tasks-per-node spanning job should reach RUNNING before drain"
result = atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="running-job-drain")
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
NODE_A, "DEGRADED"
), "NODE_A should be DEGRADED after CPU drain"
job_state = atf.get_job_parameter(job_id, "JobState", quiet=True)
assert (
job_state == "RUNNING"
), "spanning job should still be RUNNING after CPU drain"
atf.cancel_all_jobs(quiet=True)
atf.update_tres_drain(NODE_A, "cpu", "Index=0", action="Resume")
atf.wait_for_node_state(NODE_A, "DEGRADED", reverse=True)
def test_cpu_drain_new_job_avoids_drained_cpu():
"""Draining CPU 0 on NODE_A: a 3-tasks-per-node spanning job runs; 4-per-node stays PENDING.
On NODE_SMT the same index drains both threads of the core, so a 2-task
job still fits and a 3-task job does not. Resuming the other thread
clears the core.
"""
result = atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="avoidance-drain")
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
NODE_A, "DEGRADED"
), "NODE_A should be DEGRADED after CPU drain"
job3 = atf.submit_job_sbatch(
f'--nodes=2 -w {NODE_A},{NODE_B} --ntasks-per-node=3 --wrap="sleep infinity"'
)
assert job3 != 0, "3-tasks-per-node job submission should succeed"
assert atf.wait_for_job_state(
job3, "RUNNING"
), "3-tasks-per-node job should run on 3 undrained CPUs on NODE_A"
job4 = atf.submit_job_sbatch(
f'--nodes=2 -w {NODE_A},{NODE_B} --ntasks-per-node=4 --wrap="sleep infinity"'
)
assert job4 != 0, "4-tasks-per-node job submission should succeed"
assert atf.job_stays_pending(
job4
), "4-tasks-per-node job must stay PENDING while CPU 0 is drained on NODE_A"
atf.cancel_all_jobs(quiet=True)
atf.update_tres_drain(NODE_A, "cpu", "Index=0", action="Resume")
# After resume NODE_A is back to 4 CPUs; NODE_B was never affected.
atf.wait_for_node_state(NODE_A, "DEGRADED", reverse=True)
# NODE_SMT has two threads per core. Draining CPU 0 drains its sibling
# too, so the node keeps 2 CPUs: a 3-task job stays pending and a 2-task
# job runs. Resuming the other thread clears the core.
result = atf.update_tres_drain(NODE_SMT, "cpu", "Index=0", reason="core0")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
f = atf.get_node_tres_drained(NODE_SMT) or ""
if f == "cpu(IDX:0-1)(core0)":
break
assert atf.wait_for_node_state(
NODE_SMT, "DEGRADED"
), "NODE_SMT should reach DEGRADED"
# --nodes=1: without a node limit the scheduler adds another node to fit
# the third task, and the job runs without needing the drained core.
job3 = atf.submit_job_sbatch(
f'--nodes=1 --nodelist={NODE_SMT} --ntasks=3 --wrap="sleep infinity"'
)
assert job3 != 0, "3-CPU job submission should succeed"
assert atf.job_stays_pending(
job3
), "3-CPU job must stay PENDING while one core (2 CPUs) is drained"
for _ in atf.timer(fatal=True):
if atf.get_job_parameter(job3, "Reason", quiet=True) == "Resources":
break
job2 = atf.submit_job_sbatch(
f'--nodes=1 --nodelist={NODE_SMT} --ntasks=2 --wrap="sleep infinity"'
)
assert job2 != 0, "2-CPU job submission should succeed"
assert atf.wait_for_job_state(
job2, "RUNNING"
), "2-CPU job should run on the undrained core"
atf.cancel_all_jobs(quiet=True)
result = atf.update_tres_drain(NODE_SMT, "cpu", "Index=1", action="Resume")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
f = atf.get_node_tres_drained(NODE_SMT)
if f is None:
break
def test_cpu_drain_range_and_partial_resume():
"""Drain CPUs 0-1 on both nodes; per-node partial resume gates the spanning job.
Each node has 2 undrained CPUs. A 3-tasks-per-node job stays PENDING.
Resuming IDX:0 on NODE_A only raises NODE_A to 3 CPUs, but NODE_B still has 2 --
the job stays PENDING because the per-node drain bitmaps are independent.
Resuming IDX:0 on NODE_B too gives both nodes 3 CPUs and the job starts.
"""
atf.update_tres_drain(
NODE_A, "cpu", "Index=0-1", reason="range-drain-a", fatal=True
)
atf.update_tres_drain(
NODE_B, "cpu", "Index=0-1", reason="range-drain-b", fatal=True
)
for node in (NODE_A, NODE_B):
assert atf.wait_for_node_state(
node, "DEGRADED"
), f"{node} should be DEGRADED after draining CPUs 0-1"
# Each node has 2 undrained CPUs; job needs 3 per node -> PENDING.
job = atf.submit_job_sbatch(
f'--nodes=2 -w {NODE_A},{NODE_B} --ntasks-per-node=3 --wrap="sleep infinity"'
)
assert job != 0, "3-tasks-per-node job submission should succeed"
assert atf.job_stays_pending(
job
), "job must stay PENDING when only 2 CPUs are undrained per node"
# Resume IDX:0 on NODE_A only: A has 3 CPUs, B still has 2 -> still PENDING.
atf.update_tres_drain(NODE_A, "cpu", "Index=0", action="Resume")
assert atf.wait_for_node_state(
NODE_B, "DEGRADED"
), "NODE_B should remain DEGRADED while IDX:0-1 are still drained"
assert atf.job_stays_pending(
job
), "job must remain PENDING after resuming only NODE_A IDX:0 -- NODE_B still has 2 CPUs"
# Resume IDX:0 on NODE_B: both nodes now have 3 undrained CPUs -> job starts.
atf.update_tres_drain(NODE_B, "cpu", "Index=0", action="Resume")
assert atf.wait_for_job_state(
job, "RUNNING"
), "job should start once both nodes have 3 undrained CPUs"
atf.cancel_all_jobs(quiet=True)
atf.update_tres_drain(NODE_A, "cpu", "Index=1", action="Resume")
atf.update_tres_drain(NODE_B, "cpu", "Index=1", action="Resume")
for node in (NODE_A, NODE_B):
atf.wait_for_node_state(node, "DEGRADED", reverse=True)
def test_cpu_drain_refused_for_non_admin():
"""Only an administrator can drain a TRES."""
result = atf.update_tres_drain(
NODE_A,
"cpu",
"Index=0",
reason="not_admin",
user=atf.properties["test-user"],
)
assert result["exit_code"] != 0, "a non-admin drain should be refused"
assert "Invalid user id" in result["stderr"], result
drained = atf.get_node_tres_drained(NODE_A)
assert drained is None, f"DrainedTRES should be unset; got {drained!r}"
def test_refused_tres_update_has_no_side_effects(clear_triggers):
"""A refused TRES update does not run the follow-up an applied one does.
An applied update saves node state, queues the scheduler and fires the
reconfigure triggers. A refused request must do none of that, whether it
was refused for its user or failed in the controller. Its reconfig
trigger is the side effect a test can see, so set one, check that the
refused requests leave it alone, and check that an admin's update fires
it, so a trigger that never fires cannot pass the first half.
"""
flag_dir = atf.module_tmp_path / "reconfig_trigger"
flag_dir.mkdir(exist_ok=True)
flag_dir.chmod(0o777)
flag_file = flag_dir / "fired"
trigger_script = atf.module_tmp_path / "reconfig_trigger.sh"
atf.make_bash_script(trigger_script, f"touch {flag_file}")
slurm_user = atf.properties["slurm-user"]
# Permanent, so a reconfigure still pending from an earlier test can fire
# it without using it up. Let that happen, then start from no flag file.
atf.run_command(
f"strigger --set --reconfig --flags=perm --program={trigger_script}",
user=slurm_user,
fatal=True,
)
for _ in atf.timer(timeout=35, xfail=True, fatal=True):
fired = flag_file.exists()
if fired:
break
flag_file.unlink(missing_ok=True)
result = atf.update_tres_drain(
NODE_A,
"cpu",
"Index=0",
reason="not_admin",
user=atf.properties["test-user"],
)
assert result["exit_code"] != 0, "a non-admin drain should be refused"
assert "Invalid user id" in result["stderr"], result
# An admin's request the controller refuses, such as a mistyped node
# name, changed nothing either.
result = _update_tres_raw(
"TresName=cpu",
"NodeName=nosuchnode",
"Index=0",
"Action=Drain",
'Reason="no_node"',
)
assert result["exit_code"] != 0, "a drain of an unknown node should fail"
assert "Invalid node name specified" in result["stderr"], result
# Triggers are processed every 15 seconds, so give it two passes.
# A timeout is the pass: the refused update must not fire the trigger.
fired = False
for _ in atf.timer(timeout=35, xfail=True):
fired = flag_file.exists()
if fired:
break
assert not fired, "a refused TRES update fired the reconfig trigger"
atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="admin", fatal=True)
atf.update_tres_drain(NODE_A, "cpu", "Index=0", action="Resume", fatal=True)
for _ in atf.timer(fatal=True):
fired = flag_file.exists()
if fired:
break
def test_cpu_drain_out_of_range_index_rejected():
"""A drain with a CPU index beyond the node's CPU count is rejected."""
result = atf.update_tres_drain(NODE_A, "cpu", "Index=99", reason="oor-drain")
assert result["exit_code"] != 0, "expected non-zero exit for out-of-range CPU index"
assert "CPU count specification invalid" in result["stderr"], result
drained = atf.get_node_tres_drained(NODE_A)
assert drained is None, f"DrainedTRES should be unset; got {drained!r}"
def test_cpu_drain_persists_across_slurmctld_restart():
"""CPU drain on NODE_A, NODE_B, and a threaded core survives a restart.
NODE_SMT was drained by one thread of its second core, so the restored
report is still that core's CPU range.
"""
atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="persist-a", fatal=True)
atf.update_tres_drain(NODE_B, "cpu", "Index=0", reason="persist-b", fatal=True)
# Index=2 names one thread of the second core; the saved range is both.
atf.update_tres_drain(NODE_SMT, "cpu", "Index=2", reason="persist", fatal=True)
expected = {
NODE_A: "cpu(IDX:0)(persist-a)",
NODE_B: "cpu(IDX:0)(persist-b)",
}
for node in (NODE_A, NODE_B):
for _ in atf.timer(fatal=True):
if atf.get_node_tres_drained(node) == expected[node]:
break
atf.restart_slurmctld(clean=False, quiet=True)
for node in (NODE_A, NODE_B):
for _ in atf.timer(fatal=True):
if atf.get_node_tres_drained(node) == expected[node]:
break
assert atf.wait_for_node_state(
node, "DEGRADED"
), f"{node} should remain DEGRADED after controller restart"
for _ in atf.timer(fatal=True):
f = atf.get_node_tres_drained(NODE_SMT) or ""
if f == "cpu(IDX:2-3)(persist)":
break
assert atf.wait_for_node_state(
NODE_SMT, "DEGRADED"
), "NODE_SMT should reach DEGRADED"
atf.update_tres_drain(NODE_A, "cpu", "Index=0", action="Resume")
atf.update_tres_drain(NODE_B, "cpu", "Index=0", action="Resume")
atf.update_tres_drain(NODE_SMT, "cpu", "Index=2", action="Resume")
def test_cpu_noop_resume_survives_restart():
"""Resuming CPUs that were never drained must not break the state save.
The resume is a no-op, and must not allocate the node's drain map on the
way in and leave it behind empty. Saving state would then pack one reason
per CPU from an array that was never allocated, taking slurmctld down.
"""
result = atf.update_tres_drain(NODE_A, "cpu", "Index=0", action="Resume")
assert result["exit_code"] == 0, result
assert not _drained_cpus(NODE_A), "a no-op resume must not drain anything"
# The restart is the assertion: it writes state on the way down and reads
# it on the way up, so a controller that survives both proves the save.
atf.restart_slurmctld(clean=False, quiet=True)
assert atf.wait_for_node_state(
NODE_A, "IDLE"
), "NODE_A should still be IDLE after the restart"
assert not _drained_cpus(NODE_A), "nothing should be drained after the restart"
def test_cpu_last_resume_survives_restart():
"""Draining then fully resuming leaves no drain map behind.
Same failure as the no-op resume, reached the other way: the map is real
while a CPU is drained and must be dropped once the last one is resumed.
"""
assert (
atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="regression")[
"exit_code"
]
== 0
), "drain update should succeed"
for _ in atf.timer(fatal=True):
f = _drained_cpus(NODE_A)
if "cpu" in f.lower():
break
result = atf.update_tres_drain(NODE_A, "cpu", "Index=ALL", action="Resume")
assert result["exit_code"] == 0, result
assert not _drained_cpus(NODE_A), "the resume should clear the drain"
atf.restart_slurmctld(clean=False, quiet=True)
assert atf.wait_for_node_state(
NODE_A, "IDLE"
), "NODE_A should be IDLE after resuming everything and restarting"
assert not _drained_cpus(NODE_A), "node should have no drained CPUs"
def test_cpu_drain_exclusive_job_respects_drain():
"""An exclusive job on a drained node is allocated only undrained CPUs.
Mirrors the single-node GRES exclusive test pattern: drain IDX:0, submit
one exclusive job (no competing higher-priority exclusive job on the same
node), verify it runs and its TRES allocation shows 3 CPUs, not 4.
"""
result = atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="excl-drain")
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
NODE_A, "DEGRADED"
), "NODE_A should be DEGRADED after CPU drain"
# Exclusive job with 3 tasks: fits on the 3 undrained CPUs and must run.
job_id = atf.submit_job_sbatch(
f'--exclusive -w {NODE_A} --ntasks=3 --wrap="sleep infinity"'
)
assert job_id != 0, "exclusive 3-task job submission should succeed"
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "exclusive 3-task job should run on the 3 undrained CPUs"
# TRES allocation must show 3 CPUs -- drained IDX:0 excluded.
alloc_tres = atf.get_job_parameter(job_id, "AllocTRES", quiet=True)
m = re.search(r"\bcpu=(\d+)\b", alloc_tres or "")
assert m, f"job has no allocated cpu TRES: {alloc_tres!r}"
assert int(m.group(1)) == 3, (
f"exclusive job allocated {m.group(1)} CPUs; "
f"expected 3 (drained IDX:0 must be excluded from the TRES allocation)"
)
atf.cancel_all_jobs(quiet=True)
atf.update_tres_drain(NODE_A, "cpu", "Index=0", action="Resume")
atf.wait_for_node_state(NODE_A, "DEGRADED", reverse=True)
def test_cpu_drain_independent_drain_per_node():
"""CPU drains on two non-adjacent nodes are tracked independently.
Different CPU indices are drained on NODE_A (IDX:0) and NODE_B (IDX:1).
A 4-tasks-per-node spanning job stays PENDING until both drains are cleared.
Resuming only one node is not sufficient -- the per-node bitmaps are independent.
"""
atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="indep-drain-a", fatal=True)
atf.update_tres_drain(NODE_B, "cpu", "Index=1", reason="indep-drain-b", fatal=True)
for node in (NODE_A, NODE_B):
assert atf.wait_for_node_state(
node, "DEGRADED"
), f"{node} should be DEGRADED after CPU drain"
job_id = atf.submit_job_sbatch(
f"--nodes=2 -w {NODE_A},{NODE_B} --ntasks-per-node=4 --wrap='sleep infinity'",
)
assert job_id != 0, "job submission should succeed"
assert atf.job_stays_pending(
job_id
), "4-tasks-per-node job must stay PENDING: each node has only 3 undrained CPUs"
# Resume only NODE_A; NODE_B still has IDX:1 drained -- job must stay PENDING.
atf.update_tres_drain(NODE_A, "cpu", "Index=0", action="Resume")
assert atf.wait_for_node_state(
NODE_A, "DEGRADED", reverse=True
), "NODE_A should leave DEGRADED after resume"
assert atf.job_stays_pending(job_id), (
"job must remain PENDING after resuming only NODE_A; "
"NODE_B still has IDX:1 drained"
)
# Resume NODE_B: both nodes now have 4 undrained CPUs -- job starts.
atf.update_tres_drain(NODE_B, "cpu", "Index=1", action="Resume")
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "job should start after both per-node drains are resumed"
atf.cancel_all_jobs(quiet=True)
atf.wait_for_node_state(NODE_B, "DEGRADED", reverse=True)
def test_cpu_drain_unknown_node_changes_nothing():
"""A hostlist naming a node that does not exist applies to no node.
Applying it to the nodes that did resolve while reporting a failure would
leave the admin unable to tell what had happened.
"""
result = _update_tres_raw(
"TresName=cpu",
f"NodeName={NODE_A},nosuchnode",
"Index=0",
"Action=Drain",
'Reason="should_not_apply"',
)
assert result["exit_code"] != 0, "an unknown node must fail the request"
assert "Invalid node name specified" in result["stderr"], result
assert not _drained_cpus(
NODE_A
), "no node may be drained when another node in the list is unknown"
def test_cpu_drain_valid_hostlist_applies_to_every_node():
"""The atomic check must not stop a hostlist naming only real nodes."""
result = _update_tres_raw(
"TresName=cpu",
f"NodeName={NODE_A},{NODE_B}",
"Index=0",
"Action=Drain",
'Reason="multi_node"',
)
assert result["exit_code"] == 0, result
for node in (NODE_A, NODE_B):
for _ in atf.timer(fatal=True):
f = _drained_cpus(node)
if "cpu" in f.lower():
break
def test_cpu_max_drained_tres_over_limit_drains_node(max_drained_tres):
"""MaxDrainedTres=cpu:N auto-drains the node once drained CPUs exceed N.
Draining CPUs at or below the limit only marks the node DEGRADED; draining
past the limit drains the node itself (recoverable via ``scontrol update
State=RESUME``), leaving a reason that names the cpu breach. MaxDrainedTres
is node-local: NODE_B, which has no limit, stays merely DEGRADED even with
more CPUs drained. On NODE_SMT the same limit counts both threads of a
core, so one core is 2 CPUs and two cores are 4.
"""
max_drained_tres(NODE_A, "cpu:1")
# 1 CPU drained == limit -> DEGRADED, not DRAIN.
assert (
atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="within_limit")[
"exit_code"
]
== 0
), "drain update should succeed"
for _ in atf.timer(fatal=True):
s = atf.get_node_parameter(NODE_A, "state") or ""
if "DEGRADED" in s and "DRAIN" not in s:
break
# 2 CPUs drained > limit of 1 -> whole-node DRAIN.
assert (
atf.update_tres_drain(NODE_A, "cpu", "Index=1", reason="over_limit")[
"exit_code"
]
== 0
), "drain update should succeed"
assert atf.wait_for_node_state(NODE_A, "DRAIN"), (
"NODE_A should DRAIN once drained CPUs (2) exceed MaxDrainedTres (1): "
f"{atf.get_node_parameter(NODE_A, 'state')!r}"
)
assert (
atf.get_node_parameter(NODE_A, "reason")
== "cpu has 2 drained, exceeding MaxDrainedTres limit of 1"
), atf.get_node_parameter(NODE_A, "reason")
# The limit is node-local: NODE_B (no limit) never auto-drains, even
# with more CPUs drained than NODE_A's limit.
assert (
atf.update_tres_drain(NODE_B, "cpu", "Index=0-1", reason="nodeb")["exit_code"]
== 0
), "drain update should succeed"
assert atf.wait_for_node_state(
NODE_B, "DEGRADED"
), "NODE_B should be DEGRADED after CPU drain"
assert "DRAIN" not in (
atf.get_node_parameter(NODE_B, "state") or ""
), "NODE_B must not DRAIN -- it has no MaxDrainedTres limit"
# The limit counts threads. One core on NODE_SMT is 2 CPUs, which
# equals cpu:2 and leaves the node DEGRADED; the second core is 4.
max_drained_tres(NODE_SMT, "cpu:2")
assert (
atf.update_tres_drain(NODE_SMT, "cpu", "Index=0", reason="within_limit")[
"exit_code"
]
== 0
), "drain update should succeed"
for _ in atf.timer(fatal=True):
s = atf.get_node_parameter(NODE_SMT, "state") or ""
if "DEGRADED" in s and "DRAIN" not in s:
break
assert (
atf.update_tres_drain(NODE_SMT, "cpu", "Index=2", reason="over_limit")[
"exit_code"
]
== 0
), "drain update should succeed"
assert atf.wait_for_node_state(NODE_SMT, "DRAIN"), (
"two cores (4 CPUs) exceed MaxDrainedTres cpu:2: "
f"{atf.get_node_parameter(NODE_SMT, 'state')!r}"
)
assert (
atf.get_node_parameter(NODE_SMT, "reason")
== "cpu has 4 drained, exceeding MaxDrainedTres limit of 2"
), atf.get_node_parameter(NODE_SMT, "reason")
def test_cpu_max_drained_tres_preserves_existing_drain_reason(max_drained_tres):
"""A later MaxDrainedTres breach does not erase why the node was drained."""
max_drained_tres(NODE_A, "cpu:1")
atf.run_command(
f"scontrol update NodeName={NODE_A} State=DRAIN Reason=manual_drain",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.update_tres_drain(NODE_A, "cpu", "Index=0-1", reason="cpu_fault", fatal=True)
for _ in atf.timer(fatal=True):
drained = atf.get_node_tres_drained(NODE_A) or ""
if drained == "cpu(IDX:0-1)(cpu_fault)":
break
assert (
atf.get_node_parameter(NODE_A, "reason") == "manual_drain"
), "MaxDrainedTres must preserve the existing whole-node drain reason"
assert "DRAIN" in (
atf.get_node_parameter(NODE_A, "state") or ""
), atf.get_node_detail(NODE_A)
def _set_state(node, state):
"""Run "scontrol update NodeName=node State=state" as the slurm user."""
return atf.run_command(
f"scontrol update NodeName={node} State={state}",
user=atf.properties["slurm-user"],
quiet=True,
)
def test_cpu_undrain_leaves_drained_tres():
"""State=UNDRAIN clears the node drain but leaves its drained TRES alone.
That is the documented difference from RESUME, which returns the drained
TRES to service as well. The node stays DEGRADED until they are resumed.
"""
atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="undrain", fatal=True)
assert atf.wait_for_node_state(NODE_A, "DEGRADED"), "NODE_A should reach DEGRADED"
result = atf.run_command(
f"scontrol update NodeName={NODE_A} State=DRAIN Reason=undrain_node",
user=atf.properties["slurm-user"],
quiet=True,
)
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(NODE_A, "DRAIN"), "NODE_A should reach DRAIN"
result = _set_state(NODE_A, "UNDRAIN")
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
NODE_A, "DRAIN", reverse=True
), "NODE_A should leave DRAIN"
assert "DEGRADED" in (
atf.get_node_parameter(NODE_A, "state") or ""
), "node should be DEGRADED"
assert "cpu(IDX:0)(undrain)" in (
atf.get_node_tres_drained(NODE_A) or ""
), atf.get_node_detail(NODE_A)
@pytest.mark.parametrize(
"recovery",
["resume", "resume_tres_then_node", "resume_keep_tres", "undrain", "idle"],
)
def test_cpu_max_drained_tres_recovery(recovery, max_drained_tres):
"""Each documented way out of a MaxDrainedTres breach does what it says.
State=RESUME resumes the offending TRES with the node. Resuming enough TRES
and then the node also clears it. State=RESUME_KEEP_TRES, UNDRAIN and IDLE
leave the TRES drained, so the node stays drained while it is still over
the limit.
"""
reason = "cpu has 2 drained, exceeding MaxDrainedTres limit of 1"
max_drained_tres(NODE_A, "cpu:1")
atf.update_tres_drain(NODE_A, "cpu", "Index=0-1", reason="breach", fatal=True)
assert atf.wait_for_node_state(NODE_A, "DRAIN"), "NODE_A should reach DRAIN"
assert (
atf.get_node_parameter(NODE_A, "reason") == reason
), "node reason should be the drain reason"
if recovery == "resume":
assert (
_set_state(NODE_A, "RESUME")["exit_code"] == 0
), "state update should succeed"
assert atf.wait_for_node_state(
NODE_A, "DRAIN", reverse=True
), "NODE_A should leave DRAIN"
assert atf.get_node_tres_drained(NODE_A) is None, atf.get_node_detail(NODE_A)
elif recovery == "resume_tres_then_node":
# Back within the limit, the node can return to service with the
# rest still drained, and the next registration leaves it there.
atf.update_tres_drain(NODE_A, "cpu", "Index=1", action="Resume", fatal=True)
assert (
_set_state(NODE_A, "RESUME_KEEP_TRES")["exit_code"] == 0
), "state update should succeed"
assert atf.wait_for_node_state(
NODE_A, "DRAIN", reverse=True
), "NODE_A should leave DRAIN"
atf.start_slurmd(NODE_A)
assert not atf.wait_for_node_state(
NODE_A, "DRAIN", timeout=15
), "a node back within its limit was drained again"
assert "DEGRADED" in (
atf.get_node_parameter(NODE_A, "state") or ""
), "node should be DEGRADED"
assert "cpu(IDX:0)(breach)" in (
atf.get_node_tres_drained(NODE_A) or ""
), atf.get_node_detail(NODE_A)
else:
assert (
_set_state(NODE_A, recovery.upper())["exit_code"] == 0
), "state update should succeed"
assert atf.wait_for_node_state(
NODE_A, "DRAIN"
), "NODE_A should stay DRAIN while still over MaxDrainedTres"
assert "cpu(IDX:0-1)(breach)" in (
atf.get_node_tres_drained(NODE_A) or ""
), atf.get_node_detail(NODE_A)
assert (
atf.get_node_parameter(NODE_A, "reason") == reason
), "node reason should be the drain reason"
def test_cpu_unparsable_max_drained_tres_is_reported(max_drained_tres):
"""A MaxDrainedTres the parser cannot read is called out, not ignored.
An unreadable specification is read as "no limit", so without a diagnostic
a typo silently removes the protection the option was configured for.
Each rejected form is one token, so one value exercises every rejection.
"""
log_path = atf.get_config_parameter("SlurmctldLogFile", live=False, quiet=True)
# "gpu:1" is missing the gres/ prefix. The rest are a missing count, an
# empty count, a negative count, a non-numeric count, a suffix on cpu, an
# unknown gres suffix, and a gres count that overflows.
spec = (
"gpu:1,cpu,cpu:,cpu:-1,cpu:abc,cpu:1K,"
"gres/gpu:1Z,gres/gpu:18446744073709551615K"
)
phrases = (
"is not a node-local TRES",
'is missing the ":<count>" suffix',
"has no usable count",
"is not a number",
"has trailing characters",
"has an unrecognized suffix",
"is too large",
)
# Set it on the node's existing NodeName line -- appending a second line
# for a node that require_nodes() already defined is a duplicate
# definition, which the controller rejects outright and so never reaches
# the check this test is about.
max_drained_tres(NODE_GOOD, spec)
# Match the specific diagnostic for this value on this node. A bare
# "MaxDrainedTres" would also match the valid limit another test in
# this module sets, and so would pass without the parser saying
# anything at all.
needle = f"MaxDrainedTres={spec} for NodeName={NODE_GOOD}"
def _log_text():
return atf.run_command_output(
f"grep -F '{needle}' {log_path} || true",
user=atf.properties["slurm-user"],
quiet=True,
)
for _ in atf.timer(fatal=True):
text = _log_text()
if text and all((phrase in text for phrase in phrases)):
break
# Ignored also means it sets no cpu limit. Three drained CPUs exceed the
# 0 or 1 that a misread "cpu:", "cpu:abc" or "cpu:1K" would set.
atf.update_tres_drain(NODE_GOOD, "cpu", "Index=0-2", reason="nolimit", fatal=True)
assert atf.wait_for_node_state(
NODE_GOOD, "DEGRADED"
), "NODE_GOOD should reach DEGRADED"
assert not atf.wait_for_node_state(
NODE_GOOD, "DRAIN", timeout=5
), f"an ignored MaxDrainedTres drained the node: {atf.get_node_detail(NODE_GOOD)}"
def test_cpu_drain_set_replaces_drained_set():
"""Action=SetDrain makes the drained set exactly what it names.
Add is cumulative, so it can only grow the set; Set both drains what is
missing and resumes what is no longer listed, in one request. A selector
with an empty value is refused and leaves the set alone.
"""
assert (
atf.update_tres_drain(NODE_A, "cpu", "Index=0-1", reason="first")["exit_code"]
== 0
), "drain update should succeed"
for _ in atf.timer(fatal=True):
f = atf.get_node_tres_drained(NODE_A) or ""
if "cpu(IDX:0-1)" in f:
break
# Replace {0,1} with {2}: CPU 2 drains, CPUs 0 and 1 come back.
assert (
atf.update_tres_drain(
NODE_A, "cpu", "Index=2", action="SetDrain", reason="second"
)["exit_code"]
== 0
), "drain update should succeed"
for _ in atf.timer(fatal=True):
f = atf.get_node_tres_drained(NODE_A) or ""
if f == "cpu(IDX:2)(second)":
break
# Set is declarative, so repeating it changes nothing.
before = atf.get_node_tres_drained(NODE_A)
assert (
atf.update_tres_drain(
NODE_A, "cpu", "Index=2", action="SetDrain", reason="second"
)["exit_code"]
== 0
), "drain update should succeed"
assert atf.get_node_tres_drained(NODE_A) == before, atf.get_node_detail(NODE_A)
# An empty selector is most likely an unset shell variable. Reading it as
# the empty set would resume everything for that TRES, so it is refused.
for selector in ("Index=", "Uuid=", "Count="):
result = atf.update_tres_drain(
NODE_A, "cpu", selector, action="SetDrain", reason="none"
)
assert result["exit_code"] != 0, f"{selector} should be refused: {result}"
assert f"{selector[:-1]} requires a value" in result["stderr"], result
assert atf.get_node_tres_drained(NODE_A) == before, atf.get_node_detail(NODE_A)
def test_cpu_drain_keeps_per_cpu_reasons():
"""Draining more CPUs later must not overwrite the earlier reason.
Each CPU keeps the reason it was drained with, so DrainedTRES reports one
entry per distinct reason rather than relabelling everything with the most
recent one. On NODE_SMT those reasons belong to cores, and naming the
other thread of a core does not create another one.
"""
assert (
atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="flaky")["exit_code"]
== 0
), "drain update should succeed"
assert (
atf.update_tres_drain(NODE_A, "cpu", "Index=2", reason="thermal")["exit_code"]
== 0
), "drain update should succeed"
for _ in atf.timer(fatal=True):
f = atf.get_node_tres_drained(NODE_A) or ""
if "(flaky)" in f and "(thermal)" in f:
break
# On a threaded node the same two reasons stay on separate cores, and
# draining the sibling thread does not add a second reason.
assert (
atf.update_tres_drain(NODE_SMT, "cpu", "Index=0", reason="flaky")["exit_code"]
== 0
), "drain update should succeed"
assert (
atf.update_tres_drain(NODE_SMT, "cpu", "Index=2", reason="thermal")["exit_code"]
== 0
), "drain update should succeed"
assert (
atf.update_tres_drain(NODE_SMT, "cpu", "Index=1", reason="flaky")["exit_code"]
== 0
), "drain update should succeed"
for _ in atf.timer(fatal=True):
f = atf.get_node_tres_drained(NODE_SMT) or ""
if "cpu(IDX:0-1)(flaky)" in f and "cpu(IDX:2-3)(thermal)" in f:
break
def test_cpu_drain_reasons_survive_restart():
"""Each drained CPU keeps its own reason across a restart.
The reasons are packed as a counted array beside the drain map, so a
miscounted save loses them all while the drains themselves survive.
"""
assert (
atf.update_tres_drain(NODE_A, "cpu", "Index=0", reason="first_reason")[
"exit_code"
]
== 0
), "drain update should succeed"
assert (
atf.update_tres_drain(NODE_A, "cpu", "Index=2", reason="second_reason")[
"exit_code"
]
== 0
), "drain update should succeed"
atf.restart_slurmctld(clean=False, quiet=True)
for _ in atf.timer(fatal=True):
f = _drained_cpus(NODE_A)
if "first_reason" in f and "second_reason" in f:
break
def test_cpu_drain_rejects_bad_request_without_changing_node():
"""Every rejected form leaves the node untouched.
The request is resolved before anything is applied, so a bad selector
cannot leave the node half drained.
"""
for fields, error in (
("TresName=cpu", "TresName requires Index, Uuid or Count"),
(
"TresName=cpu Index=0 Count=1",
"Count cannot be combined with Index or Uuid",
),
("TresName=cpu Count=2", "CPU count specification invalid"),
("Index=0", "Update of this parameter is not supported: Index=0"),
):
result = atf.run_command(
f"scontrol update NodeName={NODE_A} {fields} Action=Drain Reason=bad",
user=atf.properties["slurm-user"],
)
assert result["exit_code"] != 0, f"{fields} should be rejected: {result}"
assert error in result["stderr"], result
assert atf.get_node_tres_drained(NODE_A) is None, atf.get_node_detail(NODE_A)
# Action is mandatory: an omitted mode must not be assumed for CPU either.
result = atf.run_command(
f"scontrol update TresName=cpu NodeName={NODE_A} Index=0 Reason=bad",
user=atf.properties["slurm-user"],
)
assert result["exit_code"] != 0, result
assert (
"TresName requires Action=Drain, Resume or SetDrain" in result["stderr"]
), result
# A Reason is refused when resuming.
result = atf.update_tres_drain(
NODE_A, "cpu", "Index=0", action="Resume", reason="unused"
)
assert result["exit_code"] != 0, result
assert "Reason is not used with Action=Resume" in result["stderr"], result
assert atf.get_node_tres_drained(NODE_A) is None, atf.get_node_detail(NODE_A)
@pytest.mark.parametrize(
"extra",
[
pytest.param("TresName=mem", id="TresName"),
pytest.param(f"NodeName={NODE_B}", id="NodeName"),
pytest.param("Action=Resume", id="Action"),
pytest.param('Reason="second"', id="Reason"),
],
)
def test_cpu_drain_repeated_fields_refused(extra):
"""A field given twice is refused rather than silently taking the last.
Taking the last TresName= or Action= would act on something other than
what was written first.
"""
result = _update_tres_raw(
"TresName=cpu",
f"NodeName={NODE_A}",
"Index=0",
"Action=Drain",
'Reason="first"',
extra,
)
assert result["exit_code"] != 0, f"a repeated field must be refused: {result}"
assert (
"specified more than once" in result["stderr"]
), f"the error should name the repetition: {result['stderr']!r}"
assert not _drained_cpus(NODE_A), "a refused request must change nothing"
def test_cpu_drain_all_reaches_every_core_when_cpus_are_sockets():
"""Index=ALL drains the whole node when its CPUs are its sockets.
NODE_SOCKETS has two CPUs over eight cores, and CPU i names core i, so its
CPUs reach only the first two cores. ALL still means every core: a
one-CPU job pends while they are drained and runs once ALL is resumed.
"""
atf.update_tres_drain(
NODE_SOCKETS, "cpu", "Index=ALL", reason="all_cores", fatal=True
)
assert atf.wait_for_node_state(NODE_SOCKETS, "DEGRADED"), atf.get_node_detail(
NODE_SOCKETS
)
job_id = atf.submit_job_sbatch(
f"-w {NODE_SOCKETS} -n1 --wrap 'sleep infinity'", fatal=True
)
assert atf.job_stays_pending(
job_id
), f"a job ran on {NODE_SOCKETS} with every core drained"
atf.update_tres_drain(NODE_SOCKETS, "cpu", "Index=ALL", action="Resume", fatal=True)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "the job should run once every core is resumed"