blob: 22820a749ca71f28e73091c10d9fb2a66501cd19 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""test_102_17.py - sacctmgr TresDecayHalfLife.
Ticket 50920: a per-TRES half-life for the decay of GrpTRESMins usage, so a
TRES quota can be kept from replenishing without turning off the fairshare
decay that PriorityDecayHalfLife controls.
This module covers only what sacctmgr stores and reports, so it submits no
jobs. What the value does to usage is test_130_6.py.
"""
import pytest
import atf
acct = "test_acct_102_17"
child = "test_child_102_17"
grandchild = "test_grandchild_102_17"
qos = "test_qos_102_17"
VERSION_REASON = "Ticket 50920: TresDecayHalfLife requires Slurm 26.11"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version((26, 11), "bin/sacctmgr", reason=VERSION_REASON)
atf.require_version((26, 11), "sbin/slurmdbd", reason=VERSION_REASON)
atf.require_accounting(modify=True)
atf.require_slurm_running()
# 'sacctmgr load' refuses to run for a uid the accounting system does not
# know about, so the user running these commands needs to exist in it.
# Existence is all that is needed - the privilege check exempts SlurmUser
# and root - so do not grant AdminLevel. Add only what is missing and
# remove exactly that, since under --local-config there is no mysqldump to
# restore and a site-owned slurm user must survive the run.
slurm_user = atf.properties["slurm-user"]
added_acct = added_user = False
if not atf.run_command_output(
f"sacctmgr -n -P show account {slurm_user} format=Account",
user=slurm_user,
).strip():
atf.run_command(
f"sacctmgr -i add account {slurm_user}",
user=slurm_user,
fatal=True,
)
added_acct = True
if not atf.run_command_output(
f"sacctmgr -n -P show user {slurm_user} format=User",
user=slurm_user,
).strip():
atf.run_command(
f"sacctmgr -i add user {slurm_user} Account={slurm_user}",
user=slurm_user,
fatal=True,
)
added_user = True
yield
if added_user:
atf.run_command(
f"sacctmgr -i remove user {slurm_user}",
user=slurm_user,
)
if added_acct:
atf.run_command(
f"sacctmgr -i remove account {slurm_user}",
user=slurm_user,
)
@pytest.fixture(scope="function", autouse=True)
def accounts():
atf.run_command(
f"sacctmgr -i add qos {qos}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add account {acct}",
user=atf.properties["slurm-user"],
fatal=True,
)
yield
# Deepest first: an account cannot be removed while it still has children.
atf.run_command(
f"sacctmgr -i remove account {grandchild},{child},{acct}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i remove qos {qos}",
user=atf.properties["slurm-user"],
)
@pytest.fixture(scope="function")
def coordinator():
"""Make the test user a coordinator of acct, with a sub-account under it.
The restriction only fires for a coordinator acting on an association that
is not its own, so every modify below targets the sub-account. Yields the
coordinator's name.
"""
user = atf.get_user_name()
slurm_user = atf.properties["slurm-user"]
added_user = False
atf.run_command(
f"sacctmgr -i add account {child} parent={acct}",
user=slurm_user,
fatal=True,
)
# Add only what is missing, as the module fixture does for SlurmUser: a
# site-owned test user has to survive the run under --local-config.
if not atf.run_command_output(
f"sacctmgr -n -P show user {user} format=User",
user=slurm_user,
).strip():
added_user = True
atf.run_command(
f"sacctmgr -i add user {user} account={acct}",
user=slurm_user,
fatal=True,
)
atf.run_command(
f"sacctmgr -i add coordinator account={acct} names={user}",
user=slurm_user,
fatal=True,
)
yield user
atf.run_command(
f"sacctmgr -i remove coordinator account={acct} names={user}",
user=slurm_user,
)
if added_user:
atf.run_command(f"sacctmgr -i remove user {user}", user=slurm_user)
else:
atf.run_command(
f"sacctmgr -i remove user {user} account={acct}",
user=slurm_user,
)
def _set(entity, name, value):
"""Run a sacctmgr modify, returning the result rather than asserting."""
return atf.run_command(
f"sacctmgr -i modify {entity} {name} set TresDecayHalfLife={value}",
user=atf.properties["slurm-user"],
)
def _coord_set(name, value, coord):
"""Run a sacctmgr modify as the coordinator rather than as SlurmUser."""
return atf.run_command(
f"sacctmgr -i modify account {name} set TresDecayHalfLife={value}",
user=coord,
)
def _show(entity, name):
"""Return the TresDecayHalfLife sacctmgr reports, as a string.
The value lives on the association, and 'show account' leaves every
association field blank unless WithAssoc is asked for, so an account is
read through 'show assoc' instead.
"""
if entity == "account":
cmd = f"show assoc account={name} format=Account,User,TresDecayHalfLife"
else:
cmd = f"show {entity} {name} format=Name,TresDecayHalfLife"
out = atf.run_command_output(
f"sacctmgr -n -P {cmd}",
user=atf.properties["slurm-user"],
fatal=True,
)
for line in out.splitlines():
parts = [p.strip() for p in line.split("|")]
if parts[0] != name:
continue
# An account owns the association with no user on it; the rest
# belong to its users.
if entity == "account" and parts[1]:
continue
return parts[-1]
pytest.fail(f"no {entity} row for {name} in: {out!r}")
def _entity_name(entity):
"""The name of the fixture-created record for the given entity."""
return acct if entity == "account" else qos
def _as_dict(shown):
"""Parse 'cpu=3600,mem=7200' into {'cpu': '3600', 'mem': '7200'}.
Compared whole rather than searched, so a value that grew a unit suffix
on its way out - 7200 printed as the 7200M a memory TRES would get - is
a mismatch rather than a substring hit.
"""
return dict(part.split("=", 1) for part in shown.split(",") if part)
@pytest.mark.parametrize(
"written, expected",
[
# A bare number is seconds, the same as the REST API reads it and the
# same as it is stored, so the two clients cannot disagree.
("cpu=3600", "cpu=3600"),
# The time forms are accepted and converted, never stored as written.
("cpu=1-0", "cpu=86400"),
("cpu=12:00:00", "cpu=43200"),
("cpu=60:00", "cpu=3600"),
# 0 is the value that matters: that TRES never decays.
("cpu=0", "cpu=0"),
# More than one TRES, each with its own rate.
("cpu=3600,mem=7200", "cpu=3600,mem=7200"),
],
)
def test_set_and_show(written, expected):
"""sacctmgr stores what was meant, in seconds, and reports it back."""
_set("account", acct, written)
got = _show("account", acct)
assert _as_dict(got) == _as_dict(
expected
), f"{written} should store {expected}, showed {got!r}"
def test_bare_number_is_seconds_not_minutes():
"""A bare number is seconds.
time_str2secs() reads a bare number as minutes, so a half-life parsed
that way would be stored 60x too large. Pin the value rather than the
parse, since that is what an admin and the REST API both see.
"""
_set("account", acct, "cpu=3600")
got = _show("account", acct)
assert _as_dict(got) == {"cpu": "3600"}, "3600 should mean 3600 seconds"
@pytest.mark.parametrize("entity", ["account", "qos"])
@pytest.mark.parametrize(
"written, why",
[
# The amend arithmetic clamps an underflow to 0, and 0 means never
# decay, so a '-=' that went too far would freeze the TRES silently.
("cpu-=7200", "'-=' underflows to 0, which means never decay"),
# '+=' is the same trap facing the other way: on a TRES sitting at 0
# it quietly restores decay to a quota meant to be permanent.
("cpu+=3600", "'+=' on a frozen TRES restores decay"),
# Both words reach strtoull() as 0, so they would store the most
# constrictive value the field has while saying the least.
("cpu=INFINITE", "INFINITE would store 0, not 'no limit'"),
("cpu=UNLIMITED", "UNLIMITED would store 0, not 'no limit'"),
# A truncated value took the same path, since strspn("") == strlen("").
("cpu=", "an empty value would store 0"),
# time_str2secs() reads the leading '-' as the days separator, so -5
# came back as -432000 rather than an error and wrapped through
# strtoull() to a count just short of INFINITE64 - large enough to
# round to never decay, but not the removal sentinel, so it stored.
("cpu=-5", "a negative other than -1 would store never decay"),
# -0 reaches the same place by converting to 0 outright.
("cpu=-0", "-0 would store 0, which means never decay"),
],
)
def test_refused_values_leave_the_stored_value_alone(entity, written, why):
"""Inputs that would store the reverse of what they say are refused.
Every case here used to be accepted and stored 0 - never decay - which
is the single most destructive value the field has. Each must fail, say
something, and change nothing. doc/man/man1/sacctmgr.1's QOS section
points back at these same refusals, so an account and a QOS are both
exercised rather than trusting that the shared call site keeps them
in sync.
"""
name = _entity_name(entity)
_set(entity, name, "cpu=3600")
result = _set(entity, name, written)
assert result["exit_code"] != 0, f"{written} should be refused: {why}"
assert result["stderr"].strip(), f"{written} should say why it failed"
assert _as_dict(_show(entity, name)) == {
"cpu": "3600"
}, f"a refused modify must leave the stored value alone ({written})"
@pytest.mark.parametrize("entity", ["account", "qos"])
def test_minus_one_removes(entity):
"""TRES=-1 removes an override, leaving the TRES on the global."""
name = _entity_name(entity)
_set(entity, name, "cpu=3600,mem=7200")
_set(entity, name, "cpu=-1")
got = _show(entity, name)
assert _as_dict(got) == {"mem": "7200"}, f"only cpu should go, showed {got!r}"
def test_child_inherits_from_parent():
"""A child with no value of its own reports the parent's."""
_set("account", acct, "cpu=0,mem=3600")
atf.run_command(
f"sacctmgr -i add account {child} parent={acct}",
user=atf.properties["slurm-user"],
fatal=True,
)
got = _show("account", child)
assert _as_dict(got) == {
"cpu": "0",
"mem": "3600",
}, f"child should inherit both, showed {got!r}"
def test_child_value_wins_over_parent():
"""A child's own value beats the one it would inherit."""
_set("account", acct, "cpu=3600,mem=3600")
atf.run_command(
f"sacctmgr -i add account {child} parent={acct}",
user=atf.properties["slurm-user"],
fatal=True,
)
_set("account", child, "cpu=0")
got = _show("account", child)
assert _as_dict(got) == {
"cpu": "0",
"mem": "3600",
}, f"child's own cpu should win, mem should still be inherited: {got!r}"
def test_nearest_ancestor_wins():
"""With two levels above it, a child takes the nearer value per TRES.
get_parent_limits walks up appending each level, and the merge keeps the
first entry it sees per TRES, so this is what says the walk is ordered
nearest-first rather than merely reaching the top.
"""
_set("account", acct, "cpu=3600,mem=1800")
atf.run_command(
f"sacctmgr -i add account {child} parent={acct}",
user=atf.properties["slurm-user"],
fatal=True,
)
_set("account", child, "cpu=7200")
atf.run_command(
f"sacctmgr -i add account {grandchild} parent={child}",
user=atf.properties["slurm-user"],
fatal=True,
)
got = _show("account", grandchild)
assert _as_dict(got) == {
# cpu from the parent, which overrode the grandparent's 3600.
"cpu": "7200",
# mem was only ever set two levels up, so it still arrives.
"mem": "1800",
}, f"cpu should come from the parent and mem from the grandparent: {got!r}"
# A coordinator may not end up less constrictive than what it inherits, and
# for this field that is inverted from every other limit: a longer half-life
# replenishes the quota more slowly, and 0 never replenishes it at all, so a
# shorter one is what has to be refused.
def test_coordinator_may_lengthen(coordinator):
"""A longer half-life replenishes more slowly, so it is allowed."""
_set("account", acct, "cpu=3600")
result = _coord_set(child, "cpu=7200", coordinator)
assert result["exit_code"] == 0, (
f"lengthening is more constrictive and must be allowed: "
f"{result['stderr']!r}"
)
assert _as_dict(_show("account", child)) == {
"cpu": "7200"
}, "the lengthened value should have been stored"
def test_coordinator_may_freeze(coordinator):
"""0 never replenishes, the most constrictive value there is."""
_set("account", acct, "cpu=3600")
result = _coord_set(child, "cpu=0", coordinator)
assert result["exit_code"] == 0, (
f"0 is the most constrictive value and must be allowed: "
f"{result['stderr']!r}"
)
assert _as_dict(_show("account", child)) == {
"cpu": "0"
}, "the frozen value should have been stored"
def test_coordinator_may_not_shorten(coordinator):
"""A shorter half-life replenishes faster, so it is refused."""
_set("account", acct, "cpu=3600")
result = _coord_set(child, "cpu=1800", coordinator)
assert result["exit_code"] != 0, "shortening should be refused"
assert _as_dict(_show("account", child)) == {
"cpu": "3600"
}, "a refused modify must leave the inherited value in place"
def test_coordinator_may_not_unfreeze(coordinator):
"""Moving a TRES off never-decay replenishes faster, so it is refused."""
_set("account", acct, "cpu=0")
result = _coord_set(child, "cpu=3600", coordinator)
assert result["exit_code"] != 0, "leaving never-decay should be refused"
assert _as_dict(_show("account", child)) == {
"cpu": "0"
}, "a refused modify must leave the inherited value in place"
def test_coordinator_may_not_set_where_no_ancestor_has_one(coordinator):
"""With nothing above to compare against, there is no ceiling to check.
What the TRES inherits then is PriorityDecayHalfLife, which slurmdbd does
not have, so the value is refused rather than compared against something
unknown.
"""
_set("account", acct, "cpu=3600")
result = _coord_set(child, "mem=1800", coordinator)
assert result["exit_code"] != 0, "mem has no ancestor value, so refuse it"
assert _as_dict(_show("account", child)) == {
"cpu": "3600"
}, "only the inherited cpu should remain"
def test_coordinator_may_not_remove(coordinator):
"""Removing an override falls back to whatever an ancestor set, which may
be shorter (or unset), so a coordinator may not remove one either."""
_set("account", acct, "cpu=3600")
_set("account", child, "cpu=7200")
result = _coord_set(child, "cpu=-1", coordinator)
assert result["exit_code"] != 0, "removing should be refused"
assert _as_dict(_show("account", child)) == {
"cpu": "7200"
}, "a refused modify must leave the override in place"
def test_qos_set_and_show():
"""A QOS carries the same value, with no parent to inherit from."""
_set("qos", qos, "cpu=0,mem=1800")
got = _show("qos", qos)
assert _as_dict(got) == {"cpu": "0", "mem": "1800"}, f"showed {got!r}"
def test_dump_and_load_round_trip():
"""sacctmgr dump writes the value and load reads it back unchanged."""
cluster = atf.get_config_parameter("ClusterName")
dump = f"{atf.module_tmp_path}/tres_decay.dump"
_set("account", acct, "cpu=0,mem=3600")
atf.run_command(
f"sacctmgr -i dump {cluster} file={dump}",
user=atf.properties["slurm-user"],
fatal=True,
)
# Raw seconds in the file: no colon, which the flat file would split on,
# and no unit suffix on mem, which 'sacctmgr load' cannot read back.
atf.assert_file_contents(dump, "TresDecayHalfLife=cpu=0,mem=3600", contains=True)
_set("account", acct, "cpu=-1,mem=-1")
assert not _show("account", acct), "value should be cleared before reload"
atf.run_command(
f"sacctmgr -i load {dump}",
user=atf.properties["slurm-user"],
fatal=True,
)
got = _show("account", acct)
assert _as_dict(got) == {
"cpu": "0",
"mem": "3600",
}, f"both should survive the round trip, showed {got!r}"