blob: 64ed3e0b18b99a916fd749d9b076efb395afbdff [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Draining a TRES must not shrink the node or cluster TRES totals.
A drained TRES is reported to accounting as a node event whose TRES column
names what was taken away (see test_190_9), and the slurmdbd rollup charges
that event as down time. The capacity it is charged against comes from a
separate cluster-registration event built by set_cluster_tres(), so the totals
must stay at *configured* capacity for every TRES. Subtracting a drain from the
totals as well would count the same TRES twice, leaving sreport under-reporting
both the cluster's capacity and its idle time.
set_cluster_tres() only republishes on a reconfigure (or a node add/delete), so
each test drains, asserts, reconfigures and asserts again -- a total that is
only wrong after the reconfigure is exactly the regression being guarded.
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
NODE = "node1" # require_nodes(1, ...) yields node1
NODE_CPUS = 4
NODE_MEM = 2048
NODE_GPUS = 2
# Fake GPU device UUIDs, in fake_gpus.conf device order.
GPU_A = "GPU-aaaaaaaa-0000-0000-0000-000000000000"
GPU_B = "GPU-bbbbbbbb-1111-1111-1111-111111111111"
DRAIN_MEM = 512
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
component="bin/scontrol",
reason="Ticket 25180: TRES drain and its accounting events added in 26.11",
)
atf.require_version(
(26, 11),
component="bin/sacctmgr",
reason="Ticket 25180: TresReference event format field added in 26.11",
)
atf.require_version(
(26, 11),
component="sbin/slurmd",
reason="Ticket 25180: GRES device slots keyed by UUID added in 26.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU_Memory")
atf.require_config_parameter_includes("GresTypes", "gpu")
atf.require_accounting()
atf.require_config_parameter_includes("AccountingStorageTRES", "gres/gpu")
atf.require_tty(0)
atf.require_tty(1)
atf.require_config_file("gres.conf", "Autodetect=full\n")
atf.require_config_file(
"fake_gpus.conf", atf.fake_gpus_conf((0, GPU_A), (1, GPU_B))
)
atf.require_nodes(
1,
[
("Gres", f"gpu:a100:{NODE_GPUS}"),
("CPUs", NODE_CPUS),
("RealMemory", NODE_MEM),
],
)
atf.require_slurm_running()
@pytest.fixture
def resume_all():
"""Resume every TRES the test drained, so totals start clean each time."""
yield
for tres, selector in (
("cpu", "Index=ALL"),
("mem", f"Count={DRAIN_MEM}"),
("gres/gpu", "Index=ALL"),
):
# Not fatal: a test may have left only some of these drained.
atf.update_tres_drain(NODE, tres, selector, action="Resume", quiet=True)
for _ in atf.timer(fatal=True):
drained = atf.get_node_tres_drained(NODE)
if not drained:
break
def _tres_dict(tres_str):
"""Parse a 'cpu=4,mem=2G,gres/gpu=2' TRES string into a dict."""
parsed = {}
for item in (tres_str or "").split(","):
name, sep, count = item.partition("=")
if sep:
parsed[name.strip()] = count.strip()
return parsed
def _node_tres():
"""Return the node's configured TRES (CfgTRES) as a dict."""
return _tres_dict(atf.get_node_parameter(NODE, "tres"))
def _cluster_tres():
"""Return the cluster's registered TRES as a dict."""
cluster = atf.get_config_parameter("ClusterName", quiet=True)
output = atf.run_command_output(
"sacctmgr -n -P show cluster format=cluster,tres",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
for line in output.splitlines():
name, sep, tres = line.partition("|")
if sep and name.strip().lower() == (cluster or "").lower():
return _tres_dict(tres)
return {}
def _drain_one_of_each():
"""Drain a CPU, some memory and a GPU, and wait for the node to go DEGRADED."""
for tres, selector in (
("cpu", "Index=0"),
("mem", f"Count={DRAIN_MEM}"),
("gres/gpu", "Index=0"),
):
result = atf.update_tres_drain(NODE, tres, selector, reason="tres_totals")
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
NODE, "DEGRADED"
), f"node should be DEGRADED after the drains: {atf.get_node_tres_drained(NODE)!r}"
def _reconfigure():
"""Republish the cluster TRES, which set_cluster_tres() only does here.
The reconfigured slurmctld sends the new totals to slurmdbd and waits for
the reply before it notifies its parent, so they are stored by the time
scontrol returns. Node events still go through the agent queue, so wait
for it to drain before anything reads the database.
"""
atf.run_command(
"scontrol reconfigure",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
for _ in atf.timer(fatal=True):
state = atf.get_node_parameter(NODE, "state") or ""
if "DEGRADED" in state:
break
atf.wait_for_dbd_agent_drained(fatal=True)
def test_node_cfgtres_is_configured_capacity_while_drained(resume_all):
"""CfgTRES keeps reporting configured capacity for cpu, mem and gres.
CfgTRES is built straight from the tres_cnt[] array that set_cluster_tres()
fills and that the cluster registration is summed from, so this is the
per-node half of the same contract.
"""
before = _node_tres()
assert before, "could not read the node's CfgTRES"
_drain_one_of_each()
# The drain is visible as drained TRES...
drained = atf.get_node_tres_drained(NODE) or ""
assert re.search(r"\bcpu\b", drained), f"CPU drain not reported: {drained!r}"
assert re.search(r"\bmem\b", drained), f"memory drain not reported: {drained!r}"
assert "gpu" in drained, f"GPU drain not reported: {drained!r}"
# ...but must not have shrunk the configured totals, before or after the
# reconfigure that rebuilds them.
assert _node_tres() == before, "CfgTRES changed when the TRES were drained"
_reconfigure()
assert _node_tres() == before, "CfgTRES changed after reconfigure while drained"
# Spelled out per TRES so a failure names the offender rather than diffing
# two long strings.
after = _node_tres()
assert after.get("cpu") == str(NODE_CPUS), f"cpu total changed: {after!r}"
assert after.get("gres/gpu") == str(NODE_GPUS), f"gres/gpu total changed: {after!r}"
assert "mem" in after and after["mem"] == before["mem"], f"mem changed: {after!r}"
assert after.get("billing") == before.get("billing"), f"billing changed: {after!r}"
def test_cluster_tres_is_configured_capacity_while_drained(resume_all):
"""The cluster registration TRES stays at configured capacity.
This is the value the rollup uses as total capacity. If a drain were
subtracted here as well as charged as event down time, the drained TRES
would be counted twice.
Node CfgTRES and the cluster registration are filled separately, so keep
this even though test_node_cfgtres_is_configured_capacity_while_drained
already covers the per-node totals.
"""
before = _cluster_tres()
assert before, "could not read the cluster's registered TRES"
_drain_one_of_each()
_reconfigure()
after = _cluster_tres()
for tres in ("cpu", "mem", "gres/gpu", "billing"):
if tres in before:
assert after.get(tres) == before[tres], (
f"cluster {tres} changed from {before[tres]!r} to "
f"{after.get(tres)!r} while drained"
)
def test_drain_is_charged_as_an_event_not_as_lost_capacity(resume_all):
"""The drain is recorded in the event's TRES while the totals stay whole.
Both halves of the contract in one place: the event names what was drained,
and the totals it will be charged against are untouched. Resuming closes the
event and still leaves the totals alone.
"""
node_before = _node_tres()
assert node_before, "could not read the node's CfgTRES"
cluster_before = _cluster_tres()
assert cluster_before, "could not read the cluster's registered TRES"
_drain_one_of_each()
for _ in atf.timer(fatal=True):
tres = atf.get_open_node_event_tres(NODE)
if (
tres
and re.search("(?:^|,)cpu=1(?:,|$)", tres)
and re.search(f"(?:^|,)mem={DRAIN_MEM}M?(?:,|$)", tres)
and re.search("(?:^|,)gres/gpu=1(?:,|$)", tres)
):
break
_reconfigure()
assert _node_tres() == node_before, "CfgTRES shrank while the event was open"
assert (
_cluster_tres() == cluster_before
), "cluster TRES shrank while the event was open"
for tres, selector in (
("cpu", "Index=0"),
("mem", f"Count={DRAIN_MEM}"),
("gres/gpu", "Index=0"),
):
result = atf.update_tres_drain(NODE, tres, selector, action="Resume")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
event = atf.get_open_node_event(NODE)
if event is None:
break
assert (
_node_tres() == node_before
), "CfgTRES did not return to configured capacity after resume"