blob: 7c1355e0ca39ee9c71e9081b2ca545e1e728fa26 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Accounting event coverage for per-TRES drain and undrain.
Draining a node-local TRES -- GRES, CPU, or memory -- on a node (leaving the
node DEGRADED but usable) must open an event in the slurmdbd event_table whose
TRES column reflects the drained resources; undraining must close that event.
"""
import re
import time
import pytest
import atf
pytestmark = pytest.mark.slow
# Node names need to be in the form of nodeNN for the s2n variant. DYN_NODE is
# the node under test; DYN_NODE_GOOD is a second, healthy node that joins the
# cluster in the test for an absent node's drain event.
DYN_NODE = "node100"
DYN_NODE_GOOD = "node101"
# Each dynamic slurmd on the same host must listen on a distinct port. Without
# explicit Port=, both slurmds default to SlurmdPort (e.g. 6818) and only the
# first one can bind; the second node fails to register.
PORT_GOOD = 61601
# 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"
GPU_C = "GPU-cccccccc-2222-2222-2222-222222222222"
# An old-style MIG UUID, with "/" separators.
GPU_MIG = "MIG-GPU-dddddddd-3333-3333-3333-333333333333/1/0"
# Its parentheses cannot be written into a TresReference, so a drain of this
# device alone records an event with a blank one.
GPU_UNRECORDABLE = "GPU-eeeeeeee-4444-4444-4444-(444444444444)"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter("MaxNodeCount", 4)
atf.require_config_parameter("TreeWidth", 65533)
atf.require_config_parameter_includes("SlurmctldParameters", "cloud_reg_addrs")
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter_includes("GresTypes", "gpu")
atf.require_config_parameter_includes("GresTypes", "bandwidth")
atf.require_accounting()
# get_node_events() queries the TresReference event format field, which only
# exists in 26.11 sacctmgr; an older sacctmgr exits 1 on an unknown
# field.
atf.require_version(
(26, 11),
component="bin/sacctmgr",
reason="Ticket 25180: TresReference event format field and per-TRES drain accounting "
"events added in 26.11",
)
atf.require_version(
(26, 11),
component="sbin/slurmd",
reason="Ticket 25180: GRES device slots and MaxDrainedTres added in 26.11",
)
atf.require_config_parameter_includes("AccountingStorageTRES", "gres/gpu")
atf.require_config_parameter_includes("AccountingStorageTRES", "gres/bandwidth")
atf.require_config_parameter_includes(
"AccountingStorageTRES", "gres/bandwidth:lustre"
)
atf.require_tty(0)
atf.require_tty(1)
atf.require_tty(2)
atf.require_slurm_running()
def _wait_for_next_second():
"""Wait until the clock has moved into a later second.
Event rows are keyed on their start second, so a refresh in the same second
rewrites the row in place instead of starting a new one.
"""
start = int(time.time())
for _ in atf.timer(timeout=3, poll_interval=0.05, fatal=True, quiet=True):
if int(time.time()) > start:
break
@pytest.fixture
def two_gpu_dynamic_node(dynamic_slurmds):
"""Configure the dynamic node with two a100 GPUs, restart Slurm, start it.
Yields a callable returning the started node name. ``gres_conf`` writes
gres.conf when the test needs one of its own (see atf.gpu_gres_conf());
``uuids`` gives the two devices reportable UUIDs.
"""
def _start(
extra_conf="Gres=gpu:a100:2",
node_name=DYN_NODE,
gres_conf=None,
uuids=(None, None),
):
if gres_conf is not None:
atf.require_config_file("gres.conf", gres_conf)
atf.require_config_file(
"fake_gpus.conf",
atf.fake_gpus_conf(
(0, uuids[0] or "(null)"),
(1, uuids[1] or "(null)"),
),
)
atf.restart_slurm(clean=True, quiet=True)
return dynamic_slurmds(node_name, extra_conf)
return _start
@pytest.fixture
def maint_resv():
"""Name a maintenance reservation the test creates, and delete it after.
A dynamic node in a reservation cannot be deleted, and neither can one
left in MAINTENANCE. Deleting a reservation the test already deleted only
fails, quietly. When it is still there, wait for MAINTENANCE to clear
before the dynamic node is deleted.
"""
resv = "resv_190_9_maint"
yield resv
slurm_user = atf.properties["slurm-user"]
show = (
atf.run_command_output(
f"scontrol show reservation {resv}",
user=slurm_user,
fatal=False,
quiet=True,
)
or ""
)
nodes = []
match = re.search(r"\bNodes=(\S+)", show)
if match and match.group(1) != "(null)":
nodes = match.group(1).split(",")
atf.run_command(
f"scontrol delete reservationname={resv}",
user=slurm_user,
quiet=True,
)
for node in nodes:
atf.wait_for_node_state(node, "MAINTENANCE", reverse=True, fatal=False)
def _has_open_drain_event(node, reason):
"""True if there is an open event for node with the given reason and a gpu TRES."""
out = atf.get_node_events(node) or ""
# An open event has no end time, which sacctmgr prints as "Unknown". The
# State column carries the node's state when the event opened, which for a
# node with TRES drained but otherwise in service reads DEG:
# node|reason|gres/gpu=1|gres/gpu:a100(IDX:0)(UUID:GPU-...)|Unknown|DEG|2026-...
return bool(
re.search(
rf"^{node}\|{reason}\|[^|]*gpu[^|]*\|[^|]*\|Unknown\|",
out,
re.MULTILINE,
)
)
def _drain_events(node, before=()):
"""The node's partial-drain events as lists of fields, open or closed.
Events already in before are left out. The node name is reused from test to
test, so its earlier events are still in the table.
A partial drain is the event with a TresReference, which a whole-node event
leaves blank. The fields are those of atf.get_node_events():
nodename|reason|tres|tresreference|end|state|start
"""
return [
line.split("|")
for line in (atf.get_node_events(node) or "").splitlines()
if line.count("|") >= 6 and line.split("|")[3] and line.split("|") not in before
]
def _events_with_reason(node, reason):
"""The node's events whose Reason column is exactly ``reason``.
Fields are those of atf.get_node_events():
nodename|reason|tres|tresreference|end|state|start
"""
rows = []
for line in (atf.get_node_events(node) or "").splitlines():
parts = line.split("|")
if len(parts) >= 7 and parts[0] == node and parts[1] == reason:
rows.append(parts)
return rows
def _setup_uuid_gpus(*devices):
"""Configure the node with (tty, type, uuid) GPUs and restart Slurm."""
atf.require_config_file(
"gres.conf",
"Autodetect=full\n"
+ "".join(
f"NodeName={DYN_NODE} Name=gpu File=/dev/tty{tty} Type={gpu_type}\n"
for tty, gpu_type, _ in devices
),
)
atf.require_config_file("fake_gpus.conf", atf.fake_gpus_conf(*devices))
atf.restart_slurm(clean=True, quiet=True)
def test_tres_drain_multiple_gres_records_combined_event(two_gpu_dynamic_node):
"""Several GRES drained for one reason share one event listing them all.
An event covers the TRES drained for a single reason, so draining more
GRES with the reason already open updates that event in place rather than
leaving a stale single-GRES event or opening a second one.
"""
node = two_gpu_dynamic_node(
extra_conf="Gres=gpu:a100:2,bandwidth:lustre:4",
gres_conf=(
atf.gpu_gres_conf(DYN_NODE) + f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=lustre Count=4 Flags=CountOnly\n"
),
)
result = atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="combo")
assert result["exit_code"] == 0, result
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=2", reason="combo")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if (
t
and re.search("(?:^|,)gres/gpu=1(?:,|$)", t)
and re.search("(?:^|,)gres/bandwidth=2(?:,|$)", t)
and re.search("(?:^|,)gres/bandwidth:lustre=2(?:,|$)", t)
):
break
assert (
atf.get_open_node_event_count(node) == 1
), f"the drains should stay one event: {atf.get_node_events(node)!r}"
def test_tres_drain_same_reason_updates_event(two_gpu_dynamic_node):
"""A second drain with the same reason refreshes the event's TRES.
Event dedup compares the TRES column too, so draining more of the same GRES
(even with an identical reason) updates the open event rather than being
treated as 'no change'.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
result = atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="same")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and re.search("gpu=1\\b", t):
break
assert (
atf.get_open_node_event_count(node) == 1
), f"the first drain should be one event: {atf.get_node_events(node)!r}"
result = atf.update_tres_drain(node, "gres/gpu", "Index=1", reason="same")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and re.search("gpu=2\\b", t):
break
assert (
atf.get_open_node_event_count(node) == 1
), f"the second drain should stay one event: {atf.get_node_events(node)!r}"
def test_tres_partial_resume_updates_event(two_gpu_dynamic_node):
"""Resuming part of a drained GRES keeps the event open with the remainder.
Draining two GPUs then resuming one must leave the node DEGRADED with the
open event's TRES reduced to the still-drained GPU (gres/gpu=1) rather than
closing the event or leaving a stale gres/gpu=2.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
result = atf.update_tres_drain(node, "gres/gpu", "Index=0,1", reason="partresume")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and bool(re.search("gpu=2\\b", t)):
break
# Resume only one GPU; the node stays DEGRADED and the event shrinks to =1.
result = atf.update_tres_drain(node, "gres/gpu", "Index=0", action="Resume")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and bool(re.search("gpu=1\\b", t)):
break
assert "DEGRADED" in (
atf.get_node_parameter(node, "state") or ""
), f"node should still be DEGRADED after partial resume: {atf.get_node_events(node)!r}"
def test_whole_node_drain_supersedes_drained_tres_event(two_gpu_dynamic_node):
"""A whole-node DRAIN replaces the node's drained-TRES event.
A node has one event open at a time. The whole-node event accounts for
every TRES on the node, the drained GPU included, so it closes the
drained-TRES event rather than running alongside it -- charging both would
count the GPU twice. Resuming the node hands the row back: the GPU is
still drained, so a drained-TRES event opens again.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
# Drain one GPU: node DEGRADED with an open GPU-subset event.
result = atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="partdrain")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
found = _has_open_drain_event(node, "partdrain")
if found:
break
# Drain the whole node: its event takes over the node's one open row.
result = atf.run_command(
f"scontrol update NodeName={node} State=DRAIN Reason=wholenode",
user=atf.properties["slurm-user"],
)
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
reason = atf.get_open_node_event_reason(node)
if reason == "wholenode":
break
# It covers the whole node, so it carries node-level TRES.
tres = atf.get_open_node_event_tres(node)
assert tres and re.search(
r"\bcpu=", tres
), f"whole-node event should cover the node's TRES: {atf.get_node_events(node)!r}"
assert (
atf.get_open_node_event_count(node) == 1
), f"expected only the whole-node event open: {atf.get_node_events(node)!r}"
assert atf.get_open_node_event_tres_reference(node) == "", (
"whole-node event must store an empty TresReference, not a placeholder: "
f"{atf.get_node_events(node)!r}"
)
# Resume the node, keeping its drained TRES -- plain State=RESUME would
# resume the GPU too, and this is about the events. The GPU is still
# drained, so a drained-TRES event opens again, carrying the reason the GPU
# was drained with rather than the node's cleared one.
result = atf.run_command(
f"scontrol update NodeName={node} State=RESUME_KEEP_TRES",
user=atf.properties["slurm-user"],
)
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
reason = atf.get_open_node_event_reason(node)
if reason == "partdrain":
break
tres = atf.get_open_node_event_tres(node)
assert tres and re.search(
r"(?:^|,)gres/gpu=1(?:,|$)", tres
), f"reopened event should describe the drained GPU: {atf.get_node_events(node)!r}"
assert (
atf.get_open_node_event_count(node) == 1
), f"expected a single open event: {atf.get_node_events(node)!r}"
assert not atf.get_overlapping_node_events(
node
), f"events overlap each other: {atf.get_node_events(node)!r}"
def test_max_drained_tres_escalation_opens_whole_node_event(two_gpu_dynamic_node):
"""Exceeding MaxDrainedTres auto-drains the node and opens its own event.
Under a MaxDrainedTres=gres/gpu:1 limit, draining one GPU leaves the node
DEGRADED with an open GPU-subset event. Draining the second GPU exceeds the
limit, so the controller drains the whole node. A node has one event open
at a time, so the whole-node event replaces the drained-TRES one -- it
covers every TRES on the node, the drained GPUs included, so nothing goes
unaccounted.
"""
node = two_gpu_dynamic_node(
extra_conf="Gres=gpu:a100:2 MaxDrainedTres=gres/gpu:1",
gres_conf=atf.gpu_gres_conf(DYN_NODE),
)
# Drain one GPU: within the limit, so only DEGRADED with an open event.
result = atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="underlimit")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
found = _has_open_drain_event(node, "underlimit")
if found:
break
assert "DRAIN" not in (
atf.get_node_parameter(node, "state") or ""
), f"node should not be DRAINED at the limit: {atf.get_node_events(node)!r}"
# Drain the second GPU: exceeds MaxDrainedTres, so the node is drained and
# the whole-node event takes over the node's one open row, in the same
# second the drained-TRES event would have been rewritten in.
result = atf.update_tres_drain(node, "gres/gpu", "Index=1", reason="overlimit")
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
node, "DRAIN"
), f"node was not drained after exceeding MaxDrainedTres: {atf.get_node_events(node)!r}"
assert (
atf.get_node_parameter(node, "reason")
== "gpu has 2 drained, exceeding MaxDrainedTres limit of 1"
), atf.get_node_parameter(node, "reason")
# The whole-node event covers node-level resources (cpu, mem), not just the
# GPU subset.
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and bool(re.search("\\bcpu=", t)) and bool(re.search("\\bmem=", t)):
break
assert atf.get_open_node_event_tres_reference(node) == "", (
"whole-node event must store an empty TresReference, not a placeholder: "
f"{atf.get_node_events(node)!r}"
)
assert (
atf.get_open_node_event_count(node) == 1
), f"expected only the whole-node event open: {atf.get_node_events(node)!r}"
assert not atf.get_overlapping_node_events(
node
), f"events overlap each other: {atf.get_node_events(node)!r}"
def test_tres_drain_cpu_mem_undrain_records_accounting_event(dynamic_slurmds):
"""Node-local CPU and memory drains open one event; undraining closes it.
CPU and memory are node-local TRES like GRES, so draining them on an
otherwise-usable node must open an event_table interval whose TRES column
reflects the drained cpu/mem. Resuming both must close the interval.
"""
# No GRES: this test exercises node-local cpu/mem drain accounting only.
atf.require_config_file("gres.conf", "")
atf.require_config_file("fake_gpus.conf", "")
atf.restart_slurm(clean=True, quiet=True)
node = dynamic_slurmds(DYN_NODE)
# Drain a CPU and some memory; the node stays usable (DEGRADED).
result = atf.update_tres_drain(node, "cpu", "Index=0", reason="nl_evt")
assert result["exit_code"] == 0, result
result = atf.update_tres_drain(node, "mem", "Count=512", reason="nl_evt")
assert result["exit_code"] == 0, result
# A single open event should list both node-local TRES.
for _ in atf.timer(fatal=True):
ev = atf.get_open_node_event(node)
if (
ev
and ev[0] == "nl_evt"
and re.search("(?:^|,)cpu=[1-9]", ev[1] or "")
and re.search("(?:^|,)mem=512M?(?:,|$)", ev[1] or "")
):
break
# Resume both; the open event interval must close.
for tres, selector in (("cpu", "Index=0"), ("mem", "Count=512")):
result = atf.update_tres_drain(node, tres, selector, action="Resume")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
ev = atf.get_open_node_event(node)
if ev is None:
break
def test_cpu_mem_reason_only_updates_refresh_accounting(dynamic_slurmds):
"""Re-draining the same CPU or memory count replaces the event reason.
Action=SetDrain makes the second request resolve to the set already
drained. Action=Drain would add to it, which is a different (drained-more)
update and would not exercise the reason-only path.
"""
atf.require_config_file("gres.conf", "")
atf.require_config_file("fake_gpus.conf", "")
atf.restart_slurm(clean=True, quiet=True)
node = dynamic_slurmds(DYN_NODE)
for tres, selector in (("cpu", "Index=0"), ("mem", "Count=512")):
atf.update_tres_drain(node, tres, selector, reason="reason_old", fatal=True)
for _ in atf.timer(fatal=True):
reason = atf.get_open_node_event_reason(node)
if reason == "reason_old":
break
atf.update_tres_drain(
node,
tres,
selector,
action="SetDrain",
reason="reason_new",
fatal=True,
)
for _ in atf.timer(fatal=True):
reason = atf.get_open_node_event_reason(node)
if reason == "reason_new":
break
atf.update_tres_drain(node, tres, selector, action="Resume", fatal=True)
for _ in atf.timer(fatal=True):
reason = atf.get_open_node_event_reason(node)
if reason is None:
break
def test_tres_drain_gres_cpu_mem_records_combined_event(two_gpu_dynamic_node):
"""Draining GRES with node-local CPU/mem keeps one combined event.
The TRES-aware event dedup must fold node-local cpu/mem drains into the same
open event as the GRES drain, so the event stays a complete snapshot of every
drained TRES on the node rather than several partial events.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
for tres, selector in (
("gres/gpu", "Index=0"),
("cpu", "Index=0"),
("mem", "Count=512"),
):
result = atf.update_tres_drain(node, tres, selector, reason="combo")
assert result["exit_code"] == 0, result
# One open event whose TRES lists the GPU together with cpu and mem.
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if (
t
and re.search("(?:^|,)gres/gpu=1(?:,|$)", t)
and re.search("(?:^|,)cpu=[1-9]", t)
and re.search("(?:^|,)mem=512M?(?:,|$)", t)
):
break
assert atf.get_open_node_event_tres_reference(node) == (
"cpu(IDX:0),gres/gpu:a100(IDX:0),mem(CNT:512)"
), f"combined TresReference is wrong: {atf.get_node_events(node)!r}"
def test_drain_records_gpu_uuid_in_event(dynamic_slurmds):
"""A drained GPU's UUID is recorded in the event's TresReference column."""
_setup_uuid_gpus((0, "a100", GPU_A), (1, "a100", GPU_B))
node = dynamic_slurmds(DYN_NODE, extra_conf="Gres=gpu:a100:2")
result = atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="uuid_evt")
assert result["exit_code"] == 0, result
# The identity is fully qualified by GRES name and type, and -P must not
# truncate it.
for _ in atf.timer(fatal=True):
u = atf.get_open_node_event_tres_reference(node)
if u == f"gres/gpu:a100(IDX:0)(UUID:{GPU_A})":
break
# The count column stays a plain TRES count alongside it.
assert re.search(
r"(?:^|,)gres/gpu=1(?:,|$)", atf.get_open_node_event_tres(node) or ""
), f"open event TRES count is wrong: {atf.get_node_events(node)!r}"
def test_swapping_drained_gpu_updates_event_uuid(dynamic_slurmds):
"""Swapping which GPU is drained must not leave the event naming the old one.
Resuming one GPU and draining another in a single update leaves the node
state, the reason and the drained TRES counts identical -- only the device
identity changes. The event dedup must compare the UUIDs too, or the open
event keeps naming the wrong physical GPU.
"""
_setup_uuid_gpus((0, "a100", GPU_A), (1, "a100", GPU_B))
node = dynamic_slurmds(DYN_NODE, extra_conf="Gres=gpu:a100:2")
result = atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="swap")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
u = atf.get_open_node_event_tres_reference(node)
if u == f"gres/gpu:a100(IDX:0)(UUID:{GPU_A})":
break
# event_table's primary key is (node_name, time_start) at one-second
# granularity. Sleep past it so the swap lands on a new time_start and the
# GPU_A interval is closed and a new row inserted, rather than the existing
# row being refreshed in place -- which is what keeps GPU_A's interval in
# the history asserted below.
_wait_for_next_second()
# Action=SetDrain makes the drained set exactly this selection, so it
# resumes the first GPU and drains the second in one RPC. Count stays 1 and
# the reason is unchanged, so only the UUID distinguishes the two states.
result = atf.update_tres_drain(
node, "gres/gpu", "Index=1", action="SetDrain", reason="swap"
)
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
u = atf.get_open_node_event_tres_reference(node)
if u == f"gres/gpu:a100(IDX:1)(UUID:{GPU_B})":
break
# The count is unchanged, confirming the UUID alone drove the update.
assert re.search(
r"(?:^|,)gres/gpu=1(?:,|$)", atf.get_open_node_event_tres(node) or ""
), f"open event TRES count changed unexpectedly: {atf.get_node_events(node)!r}"
# GPU_A's drain interval is preserved as history.
assert GPU_A in (
atf.get_node_events(node) or ""
), f"history lost the GPU_A drain interval: {atf.get_node_events(node)!r}"
def test_typed_gres_uuid_keys_are_distinct(dynamic_slurmds):
"""Devices of different types get distinct, sorted, fully qualified keys.
h100 is placed at the lower device index so bitmap order and lexicographic
order disagree; the expected string is therefore only produced if the
entries really are sorted. The event dedup compares this string verbatim,
so a non-canonical ordering would spuriously reopen events.
"""
_setup_uuid_gpus((0, "h100", GPU_C), (1, "a100", GPU_A))
node = dynamic_slurmds(DYN_NODE, extra_conf="Gres=gpu:h100:1,gpu:a100:1")
result = atf.update_tres_drain(node, "gres/gpu", "Index=ALL", reason="typed")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
u = atf.get_open_node_event_tres_reference(node)
if u == (
f"gres/gpu:a100(IDX:1)(UUID:{GPU_A})," f"gres/gpu:h100(IDX:0)(UUID:{GPU_C})"
):
break
def test_gres_without_uuid_records_index_in_tres_reference(two_gpu_dynamic_node):
"""A UUID-less device is identified by index in TresReference."""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
result = atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="no_uuid")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
u = atf.get_open_node_event_tres_reference(node)
if u == "gres/gpu:a100(IDX:0)":
break
def test_cpu_mem_and_count_only_drain_are_named_in_tres_reference(dynamic_slurmds):
"""CPU, memory and count-only GRES are recorded with IDX/CNT groups."""
atf.require_config_file(
"gres.conf",
"Autodetect=full\n"
f"NodeName={DYN_NODE} Name=gpu File=/dev/tty0 Type=a100\n"
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=lustre Count=4 Flags=CountOnly\n",
)
atf.require_config_file("fake_gpus.conf", atf.fake_gpus_conf((0, GPU_A)))
atf.restart_slurm(clean=True, quiet=True)
node = dynamic_slurmds(DYN_NODE, extra_conf="Gres=gpu:a100:1,bandwidth:lustre:4")
for tres, selector in (
("cpu", "Index=0"),
("mem", "Count=512"),
("gres/bandwidth", "Count=2"),
):
result = atf.update_tres_drain(node, tres, selector, reason="no_ident")
assert result["exit_code"] == 0, result
for _ in atf.timer(fatal=True):
u = atf.get_open_node_event_tres_reference(node)
if u == "cpu(IDX:0),gres/bandwidth:lustre(CNT:2),mem(CNT:512)":
break
def test_rapid_drain_resume_keeps_one_open_event(two_gpu_dynamic_node):
"""Drain/resume churn on a degraded node must not strand an open event.
The event table is keyed on (node_name, time_start), so two events for one
node landing in the same second collide. Reopening the colliding row
without replacing what it holds resurrects whatever it described before --
typically a whole-node event carrying the node's full TRES, left open with
no end, which the rollup then charges against every TRES on the node while
jobs keep running there.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="churn", fatal=True)
for _ in atf.timer(fatal=True):
found = _has_open_drain_event(node, "churn")
if found:
break
# Same-second collisions are the point, so do not pause between these.
for _ in range(10):
atf.run_command(
f"scontrol update NodeName={node} State=DRAIN Reason=churn_node",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
# RESUME_KEEP_TRES, not RESUME: the churn is meant to reopen the
# whole-node event against a node whose GPU stays drained throughout.
atf.run_command(
f"scontrol update NodeName={node} State=RESUME_KEEP_TRES",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
# The GPU is still drained, so the node settles back to DEGRADED with its
# drained-TRES event open -- and only that one.
for _ in atf.timer(fatal=True):
state = atf.get_node_parameter(node, "state") or ""
if "DEGRADED" in state and "DRAIN" not in state:
break
# Without this the pre-churn row already satisfies every check below.
atf.wait_for_dbd_agent_drained(fatal=True)
for _ in atf.timer(fatal=True):
count = atf.get_open_node_event_count(node)
if count == 1:
break
tres = atf.get_open_node_event_tres(node)
assert tres and re.search(
r"(?:^|,)gres/gpu=1(?:,|$)", tres
), f"open event should describe the drained GPU: {atf.get_node_events(node)!r}"
assert not re.search(
r"(?:^|,)cpu=", tres
), f"a whole-node event was resurrected: {atf.get_node_events(node)!r}"
assert not atf.get_overlapping_node_events(
node
), f"events overlap each other: {atf.get_node_events(node)!r}"
def test_drain_with_running_job_keeps_partial_event_live(two_gpu_dynamic_node):
"""A node draining with jobs on it keeps its drained-TRES event live.
No whole-node event is sent until the last job exits, so the drained-TRES
event is still the one describing what is unavailable and has to stay
refreshable. It must not freeze the moment the node becomes DRAIN: further
drains must be recorded, and resuming every drained TRES must close it.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
holder = atf.submit_job_sbatch(f"-w {node} --wrap 'sleep infinity'", fatal=True)
atf.wait_for_job_state(holder, "RUNNING", fatal=True, timeout=60)
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="live0", fatal=True)
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and bool(re.search("(?:^|,)gres/gpu=1(?:,|$)", t)):
break
# Drain the node itself. The job is still running, so no whole-node
# event is sent and the drained-TRES event is still the live one.
atf.run_command(
f"scontrol update NodeName={node} State=DRAIN Reason=drainjob",
user=atf.properties["slurm-user"],
fatal=True,
)
assert atf.wait_for_node_state(
node, "DRAIN"
), f"node was not drained: {atf.get_node_events(node)!r}"
# Draining a second GPU must still be recorded. It joins the node's
# one event, so the counts grow and the reason gains the new one.
atf.update_tres_drain(node, "gres/gpu", "Index=1", reason="live1", fatal=True)
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and bool(re.search("(?:^|,)gres/gpu=2(?:,|$)", t)):
break
assert (
atf.get_open_node_event_reason(node) == "live0,live1"
), f"both reasons should be recorded: {atf.get_node_events(node)!r}"
tres = atf.get_open_node_event_tres(node)
assert not re.search(
r"(?:^|,)cpu=", tres or ""
), f"a whole-node event opened while jobs were running: {atf.get_node_events(node)!r}"
assert (
atf.get_open_node_event_count(node) == 1
), f"expected a single open event: {atf.get_node_events(node)!r}"
atf.cancel_jobs([holder], fatal=True)
# With the job gone the node is DRAINED, and the whole-node event (which
# carries the node's full TRES) supersedes the drained-TRES one.
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and bool(re.search("(?:^|,)cpu=", t)):
break
assert (
atf.get_open_node_event_count(node) == 1
), f"expected exactly one open whole-node event: {atf.get_node_events(node)!r}"
assert not atf.get_overlapping_node_events(
node
), f"events overlap each other: {atf.get_node_events(node)!r}"
def test_node_update_preserves_open_degraded_event(two_gpu_dynamic_node):
"""Updating a node's instance details leaves its drained-TRES event alone.
A node whose only event is an open one must not count as never seen.
Otherwise the node-update path synthesizes a whole-node down/up pair that
closes the drained-TRES interval.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="instupd", fatal=True)
for _ in atf.timer(fatal=True):
found = _has_open_drain_event(node, "instupd")
if found:
break
atf.run_command(
f"scontrol update NodeName={node} InstanceId=i-165-9 InstanceType=t-165-9",
user=atf.properties["slurm-user"],
fatal=True,
)
# Nothing about the node's drained TRES changed, so neither should its event.
for _ in atf.timer(fatal=True):
event = atf.get_open_node_event(node)
if (
bool(event)
and event[0] == "instupd"
and bool(re.search("(?:^|,)gres/gpu=1(?:,|$)", event[1]))
):
break
assert (
atf.get_open_node_event_count(node) == 1
), f"expected exactly one open drained-TRES event: {atf.get_node_events(node)!r}"
def test_quoted_drain_reason_stays_one_event(dynamic_slurmds):
"""A drain reason containing a quote and a backslash stays one event.
slurmdbd unpacks the reason with quotes and backslashes escaped, then
stores the text MariaDB keeps after interpreting those escapes. Resending
the same drain compares the unpacked reason to that stored text. If they
differ, the open interval is closed and a new one is opened.
The resend is a node Reason update. That leaves the drained CPU and its
reason alone, and update_node reports the drain again because the node's
own reason changed. An InstanceId update does not: an unchanged node
reason skips the accounting update. Sleeping past the event's start
second means a failed compare inserts a second row rather than updating
the first in place.
"""
# The quote and the backslash are the characters slurmdbd escapes.
reason = f"it's\\a-{time.time_ns()}"
atf.require_config_file("gres.conf", "")
atf.require_config_file("fake_gpus.conf", "")
atf.restart_slurm(clean=True, quiet=True)
node = dynamic_slurmds(DYN_NODE)
result = atf.update_tres_drain(node, "cpu", "Index=0", reason=reason)
assert result["exit_code"] == 0, result
drained = atf.get_node_tres_drained(node) or ""
assert (
"'" in drained and "\\" in drained
), f"the drain reason lost its quote or backslash: {drained!r}"
assert "DEGRADED" in (atf.get_node_parameter(node, "state") or ""), (
f"node is not DEGRADED, so a later update would not resend the drain: "
f"{atf.get_node_parameter(node, 'state')!r}"
)
for _ in atf.timer(fatal=True):
rows = _events_with_reason(node, reason)
if len(rows) == 1 and rows[0][4].strip() == "Unknown":
break
start = _events_with_reason(node, reason)[0][6].strip()
# event_table's primary key is (node_name, time_start) at one-second
# granularity. A resend in this same second updates the row in place
# whether or not the reason compared equal, which would hide a mismatch.
_wait_for_next_second()
atf.run_command(
f"scontrol update NodeName={node} Reason=resend",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.wait_for_dbd_agent_drained(fatal=True)
rows = _events_with_reason(node, reason)
assert len(rows) == 1, (
"resending the drain opened another event for the same reason: "
f"{atf.get_node_events(node)!r}"
)
assert (
rows[0][4].strip() == "Unknown"
), f"the original event was closed: {atf.get_node_events(node)!r}"
assert rows[0][6].strip() == start, (
"the open event was replaced: "
f"start {start!r} became {rows[0][6]!r}; "
f"{atf.get_node_events(node)!r}"
)
def test_drain_event_stays_one_row_as_node_state_changes(two_gpu_dynamic_node):
"""One continuous drain is one event, whatever state the node moves through.
A node going from idle to allocated with its drain unchanged refreshes the
open drained-TRES interval. It does not close it and open an identical
one, so the drain stays a single row.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
before = _drain_events(node)
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="onedrain", fatal=True)
for _ in atf.timer(fatal=True):
found = _has_open_drain_event(node, "onedrain")
if found:
break
events = _drain_events(node, before)
assert len(events) == 1, f"expected one drain event: {events!r}"
# A job with no GRES leaves the drain alone but changes the node's state.
job_id = atf.submit_job_sbatch(f"-w {node} --wrap 'sleep infinity'", fatal=True)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True, timeout=60)
# Rows are keyed on their start second. A refresh in that same second
# rewrites the row in place, so let a second pass first.
_wait_for_next_second()
# Refreshes the node's event with nothing about its drain changed.
atf.run_command(
f"scontrol update NodeName={node} Reason=refresh",
user=atf.properties["slurm-user"],
fatal=True,
)
# A split shows up as a second row. Expect it not to.
atf.wait_for_dbd_agent_drained(fatal=True)
got = _drain_events(node, before)
assert len(got) <= 1, f"the drain was split into several events: {got!r}"
assert got == events, f"drain event changed: {got!r}"
def test_drain_event_without_reference_stays_one_row(dynamic_slurmds):
"""A drain event with a blank TresReference is still one row across states.
A drained device whose identity cannot be written to TresReference leaves
it blank. The dedup must still tell its partial drain from a whole-node
event, which it does by the DEGRADED state that only a partial drain
records, or the event is split on every state change.
"""
_setup_uuid_gpus((0, "a100", GPU_UNRECORDABLE), (1, "a100", GPU_B))
node = dynamic_slurmds(DYN_NODE, extra_conf="Gres=gpu:a100:2")
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="norefdrain", fatal=True)
for _ in atf.timer(fatal=True):
rows = _events_with_reason(node, "norefdrain")
if len(rows) == 1 and rows[0][4] == "Unknown":
break
events = _events_with_reason(node, "norefdrain")
# The case under test: the event names no device.
assert events[0][3] == "", f"expected a blank TresReference: {events!r}"
# A job with no GRES leaves the drain alone but changes the node's state.
job_id = atf.submit_job_sbatch(f"-w {node} --wrap 'sleep infinity'", fatal=True)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True, timeout=60)
# Rows are keyed on their start second. A refresh in that same second
# rewrites the row in place, so let a second pass first.
_wait_for_next_second()
# Refreshes the node's event with nothing about its drain changed.
atf.run_command(
f"scontrol update NodeName={node} Reason=refresh",
user=atf.properties["slurm-user"],
fatal=True,
)
# A split shows up as a second row. Expect it not to.
atf.wait_for_dbd_agent_drained(fatal=True)
got = _events_with_reason(node, "norefdrain")
assert len(got) <= 1, f"the drain was split into several events: {got!r}"
assert got == events, f"drain event changed: {got!r}"
def test_legacy_mig_uuid_is_recorded(dynamic_slurmds):
"""A legacy MIG UUID, with "/" separators, is named in TresReference."""
_setup_uuid_gpus((0, "a100", GPU_MIG), (1, "a100", GPU_B))
node = dynamic_slurmds(DYN_NODE, extra_conf="Gres=gpu:a100:2")
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="migdrain", fatal=True)
for _ in atf.timer(fatal=True):
rows = _events_with_reason(node, "migdrain")
if len(rows) == 1 and f"(UUID:{GPU_MIG})" in rows[0][3]:
break
def test_drain_event_follows_maintenance_state(two_gpu_dynamic_node, maint_resv):
"""A drain event is split when the node enters and leaves maintenance.
The rollup leaves event rows with the MAINT state to the reservation's
accounting. A drain event that kept the state it opened with would stay
counted as down through the maintenance, or never be counted if it was
opened during it. Starting and ending the reservation each start a new row
on their own, and the drain itself is still one continuous drain.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
slurm_user = atf.properties["slurm-user"]
resv = maint_resv
before = _drain_events(node)
def _is_open(event):
return event[4] == "Unknown"
def _is_maint(event):
return "MAINT" in event[5].upper()
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="maintdrain", fatal=True)
for _ in atf.timer(fatal=True):
found = _has_open_drain_event(node, "maintdrain")
if found:
break
events = _drain_events(node, before)
assert len(events) == 1, f"expected one drain event: {events!r}"
assert not _is_maint(events[0]), f"drain event opened as MAINT: {events!r}"
# Rows are keyed on their start second, so let one pass.
_wait_for_next_second()
atf.run_command(
f"scontrol create reservationname={resv} start=now duration=60 "
f"nodes={node} user={slurm_user} flags=maint,ignore_jobs",
user=slurm_user,
fatal=True,
)
assert atf.wait_for_node_state(
node, "MAINTENANCE"
), f"{node} did not enter maintenance: {atf.get_node_events(node)!r}"
# Nothing else touches the node: the reservation starting must split
# the event by itself.
for _ in atf.timer(fatal=True):
events = _drain_events(node, before)
if len(events) == 2 and any((_is_open(e) and _is_maint(e) for e in events)):
break
_wait_for_next_second()
atf.run_command(
f"scontrol delete reservationname={resv}", user=slurm_user, fatal=True
)
assert atf.wait_for_node_state(
node, "MAINTENANCE", reverse=True
), f"{node} did not leave maintenance: {atf.get_node_events(node)!r}"
for _ in atf.timer(fatal=True):
events = _drain_events(node, before)
if len(events) == 3 and any(
(_is_open(e) and (not _is_maint(e)) for e in events)
):
break
# One continuous drain: exactly one row is open, and every row records the
# same drained GPU with the same reason.
events = _drain_events(node, before)
assert (
sum(_is_open(e) for e in events) == 1
), f"expected exactly one open drain event: {events!r}"
assert (
len({(e[1], e[2], e[3]) for e in events}) == 1
), f"drain changed across the maintenance: {events!r}"
def test_degraded_events_selectable_by_state(two_gpu_dynamic_node):
"""sacctmgr selects events by the node's own state.
A partial TRES drain is DEGRADED, so States=Drain does not match it.
A whole-node drain of that node is selected by States=Drain and not by
States=Degraded.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="bystate", fatal=True)
for _ in atf.timer(fatal=True):
found = _has_open_drain_event(node, "bystate")
if found:
break
def _events_in_state(state):
# all_time, not "start=": a start date makes sacctmgr ask for
# "time_start < now", which hides the event just opened above until the
# clock ticks past the second it was opened in.
return atf.run_command_output(
f"sacctmgr -n -P list events all_time Event=Node Nodes={node} "
f"States={state} format=nodename,reason,tres,end",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
assert "bystate" in (
_events_in_state("Degraded") or ""
), f"States=Degraded did not select the drained-TRES event: {atf.get_node_events(node)!r}"
assert "bystate" not in (
_events_in_state("Drain") or ""
), f"States=Drain should not match a drained-TRES event: {atf.get_node_events(node)!r}"
atf.run_command(
f"scontrol update NodeName={node} State=DRAIN Reason=outright",
user=atf.properties["slurm-user"],
fatal=True,
)
for _ in atf.timer(fatal=True):
reason = atf.get_open_node_event_reason(node)
if reason == "outright":
break
assert "outright" in (
_events_in_state("Drain") or ""
), f"States=Drain missed the whole-node drain: {atf.get_node_events(node)!r}"
assert "outright" not in (
_events_in_state("Degraded") or ""
), f"States=Degraded matched the whole-node drain: {atf.get_node_events(node)!r}"
def test_two_reasons_share_one_event(dynamic_slurmds):
"""Two GPUs drained for different reasons share the node's one event.
A node has one event open at a time, so the event covers everything
drained on it and its reason names every distinct reason something was
drained for. The GPUs are given UUIDs so the devices it names can be
checked.
The two drains are sent back to back, so they normally land in the same
second. The event table is keyed on (node_name, time_start), so the second
drain has to rewrite that row rather than be dropped by the collision, or
the GPU it drained goes unaccounted.
"""
_setup_uuid_gpus((0, "a100", GPU_A), (1, "a100", GPU_B))
node = dynamic_slurmds(DYN_NODE, extra_conf="Gres=gpu:a100:2")
# No pause between them: the same-second collision is part of the point.
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="why_a", fatal=True)
atf.update_tres_drain(node, "gres/gpu", "Index=1", reason="why_b", fatal=True)
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and bool(re.search("(?:^|,)gres/gpu=2(?:,|$)", t)):
break
assert (
atf.get_open_node_event_count(node) == 1
), f"expected a single open event: {atf.get_node_events(node)!r}"
assert not atf.get_overlapping_node_events(
node
), f"events overlap each other: {atf.get_node_events(node)!r}"
# Both reasons are recorded, joined in sorted order.
assert (
atf.get_open_node_event_reason(node) == "why_a,why_b"
), f"both reasons should be recorded: {atf.get_node_events(node)!r}"
# The event names both devices, in sorted order.
assert atf.get_open_node_event_tres_reference(node) == (
f"gres/gpu:a100(IDX:0)(UUID:{GPU_A})," f"gres/gpu:a100(IDX:1)(UUID:{GPU_B})"
), f"the event should name both devices: {atf.get_node_events(node)!r}"
def test_resuming_one_reason_narrows_the_event(two_gpu_dynamic_node):
"""Resuming the TRES drained for one reason leaves the rest recorded, and
resuming the rest closes the event.
The node still has something drained, so its event stays open -- but it is
reopened describing only what is left, both in the TRES counts and in the
reason. Once nothing is drained the interval ends.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="goes", fatal=True)
atf.update_tres_drain(node, "gres/gpu", "Index=1", reason="keep", fatal=True)
for _ in atf.timer(fatal=True):
r = atf.get_open_node_event_reason(node)
if r == "goes,keep":
break
atf.update_tres_drain(node, "gres/gpu", "Index=0", action="Resume", fatal=True)
for _ in atf.timer(fatal=True):
r = atf.get_open_node_event_reason(node)
if r == "keep":
break
tres = atf.get_open_node_event_tres(node)
assert tres and re.search(
r"(?:^|,)gres/gpu=1(?:,|$)", tres
), f"only the still-drained GPU should be covered: {atf.get_node_events(node)!r}"
assert (
atf.get_open_node_event_count(node) == 1
), f"expected a single open event: {atf.get_node_events(node)!r}"
atf.update_tres_drain(node, "gres/gpu", "Index=ALL", action="Resume", fatal=True)
for _ in atf.timer(fatal=True):
count = atf.get_open_node_event_count(node)
if count == 0:
break
def test_cpu_and_gres_reasons_share_one_event(two_gpu_dynamic_node):
"""A drained CPU and a drained GPU are covered by the same event.
They are different TRES drained for different reasons, but the node has
one event, so it carries both counts and both reasons.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="gpu_why", fatal=True)
atf.update_tres_drain(node, "cpu", "Index=0", reason="cpu_why", fatal=True)
for _ in atf.timer(fatal=True):
r = atf.get_open_node_event_reason(node)
if r == "cpu_why,gpu_why":
break
tres = atf.get_open_node_event_tres(node)
assert tres and re.search(
r"(?:^|,)gres/gpu=1(?:,|$)", tres
), f"the drained GPU should be covered: {atf.get_node_events(node)!r}"
assert tres and re.search(
r"(?:^|,)cpu=1(?:,|$)", tres
), f"the drained CPU should be covered: {atf.get_node_events(node)!r}"
assert (
atf.get_open_node_event_count(node) == 1
), f"expected a single open event: {atf.get_node_events(node)!r}"
def test_reasons_differing_only_in_case_are_recorded_once(two_gpu_dynamic_node):
"""Reasons that differ only in case are one reason, not two.
The reason is compared case insensitively, so recording both spellings
would restamp the event every time the drain is refreshed.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="CaseTest", fatal=True)
atf.update_tres_drain(node, "gres/gpu", "Index=1", reason="casetest", fatal=True)
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and bool(re.search("(?:^|,)gres/gpu=2(?:,|$)", t)):
break
reason = atf.get_open_node_event_reason(node)
assert (
reason and reason.lower() == "casetest"
), f"the reason should be recorded once: {atf.get_node_events(node)!r}"
assert (
atf.get_open_node_event_count(node) == 1
), f"expected a single open event: {atf.get_node_events(node)!r}"
def test_long_reasons_are_recorded_in_full(two_gpu_dynamic_node):
"""Reasons sharing a long prefix are told apart, however long they are.
The reason column is not keyed and not truncated, so two reasons that
agree for the first 42 characters are still two reasons.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
shared = "x" * 42
atf.update_tres_drain(
node, "gres/gpu", "Index=0", reason=f"{shared}_tail_one", fatal=True
)
atf.update_tres_drain(
node, "gres/gpu", "Index=1", reason=f"{shared}_tail_two", fatal=True
)
for _ in atf.timer(fatal=True):
t = atf.get_open_node_event_tres(node)
if bool(t) and bool(re.search("(?:^|,)gres/gpu=2(?:,|$)", t)):
break
reason = atf.get_open_node_event_reason(node) or ""
assert ("_tail_one" in reason) and (
"_tail_two" in reason
), f"both reasons should be recorded in full: {atf.get_node_events(node)!r}"
def test_drain_event_survives_a_cluster_node_change(
two_gpu_dynamic_node, dynamic_slurmds
):
"""Another node joining the cluster must not lose this node's drain event.
A change to the cluster's node names makes slurmdbd close every open
interval in the event table at once -- that statement carries no node name
and no state, so it takes the drained TRES events of every node with it --
and then ask the controller to send its node state again. Drained TRES
events survive only because send_nodes_to_accounting() reports them for
every node unconditionally.
Were that re-send ever narrowed to the nodes it sends a whole-node event
for, every node's drain would stop being accounted for the moment any node
registered or was deleted, and nothing would say so.
The interval itself is torn in two by the close and reopen, which is what
already happens to whole-node events here; what must not happen is the
drain going unrecorded.
"""
node = two_gpu_dynamic_node(gres_conf=atf.gpu_gres_conf(DYN_NODE))
atf.update_tres_drain(node, "gres/gpu", "Index=0", reason="sweep_a", fatal=True)
drained_at = int(time.time())
for _ in atf.timer(fatal=True):
found = _has_open_drain_event(node, "sweep_a")
if found:
break
# The open event already passes _has_open_drain_event(), so after the sweep
# look for its two halves: this row closed and a new one opened for it.
opened = [
row[6].strip()
for row in _events_with_reason(node, "sweep_a")
if row[4].strip() == "Unknown"
]
assert len(opened) == 1, atf.get_node_events(node)
# The event's start is keyed by the second. A re-send in the second of
# the drain reopens this row instead of starting a new one.
for _ in atf.timer(fatal=True):
if int(time.time()) > drained_at:
break
# A second node registering changes the cluster's node names, which is what
# triggers the close-everything sweep. Its own drain state is irrelevant.
dynamic_slurmds(DYN_NODE_GOOD, f"Port={PORT_GOOD}")
for _ in atf.timer(fatal=True):
rows = _events_with_reason(node, "sweep_a")
closed = [
row
for row in rows
if row[6].strip() == opened[0] and row[4].strip() != "Unknown"
]
reopened = [
row
for row in rows
if row[6].strip() != opened[0]
and row[4].strip() == "Unknown"
and "gpu" in row[2]
]
if closed and reopened:
break