blob: 1dc904cf109718ec665f7bf39c7b76ba20b378ff [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""test_130_7.py - TresDecayHalfLife with PriorityDecayHalfLife turned off.
Ticket 50920: a per-TRES half-life overrides PriorityDecayHalfLife for that
TRES, and it has to keep doing so when PriorityDecayHalfLife is 0.
That case needs its own module because it needs its own PriorityDecayHalfLife,
and it is the one the decay thread has a shortcut for: a global half-life of 0
makes the global factor exactly 1, which leaves every counter where it was, so
the pass is skipped. It may only be skipped when nothing asked for a rate of
its own, which is what is asserted here.
Two accounts share one window. The account with an override has to lose usage
even though the global decay is off, and the account without one has to hold,
because 1 is what the global factor is. A shortcut that skipped the pass
outright would leave both holding and only the first assertion would notice.
"""
import pytest
import tres_decay as td
import atf
# 340s+: tox.ini defines the marker for anything over 60s.
pytestmark = pytest.mark.slow
# Short enough that several ticks land inside the window below.
OVERRIDE_HALF_LIFE_SEC = 60
# PriorityDecayHalfLife=0 is only a legal configuration together with a
# PriorityUsageResetPeriod, so pick the one furthest out: a reset zeroes
# usage_tres_raw regardless of any half-life, which would look exactly like
# the decay this module is trying to measure.
RESET_PERIOD = "Quarterly"
# Usage accrues as cpus * seconds / 60, so a wide job reaches the same
# TRES-minutes in a fraction of the wall time; the nodes are Slurm's own
# configuration rather than the host's, so the width costs nothing.
JOB_RUN_SEC = 40
DECAY_WAIT_SEC = 300
JOB_CPUS = 32
VERSION_REASON = "Ticket 50920: TresDecayHalfLife requires Slurm 26.11"
# The override has to decay, the plain account has to hold.
ACCTS = {
"override": f"cpu={OVERRIDE_HALF_LIFE_SEC}",
"global": None,
}
@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_version((26, 11), "sbin/slurmctld", reason=VERSION_REASON)
atf.require_auto_config("sets PriorityDecayHalfLife to 0")
atf.require_config_parameter("PriorityType", "priority/multifactor")
atf.require_config_parameter("PriorityCalcPeriod", 1)
atf.require_config_parameter("PriorityDecayHalfLife", 0)
atf.require_config_parameter("PriorityUsageResetPeriod", RESET_PERIOD)
atf.require_config_parameter_includes("AccountingStorageEnforce", "limits")
atf.require_accounting(modify=True)
atf.require_nodes(len(ACCTS), [("CPUs", JOB_CPUS)])
atf.require_slurm_running()
def _acct_name(key):
return f"test_acct_130_7_{key}"
@pytest.fixture(scope="module")
def accrued_usage():
"""Create the accounts, accrue usage on both, then stop."""
user = atf.get_user_name()
for key, value in ACCTS.items():
name = _acct_name(key)
atf.run_command(
f"sacctmgr -i add account {name}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {user} account={name}",
user=atf.properties["slurm-user"],
fatal=True,
)
if value:
atf.run_command(
f"sacctmgr -i modify account {name} set TresDecayHalfLife={value}",
user=atf.properties["slurm-user"],
fatal=True,
)
job_ids = [
atf.submit_job_sbatch(
f"-A {_acct_name(key)} -N1 -n{JOB_CPUS} -t5 "
f"--wrap 'sleep {JOB_RUN_SEC}'",
fatal=True,
)
for key in ACCTS
]
for job_id in job_ids:
atf.wait_for_job_state(job_id, "RUNNING", timeout=120, fatal=True)
# Both must finish: an account still accruing would mask the hold below.
for job_id in job_ids:
atf.wait_for_job_state(job_id, "DONE", timeout=JOB_RUN_SEC + 120, fatal=True)
yield
atf.cancel_all_jobs()
# The user first: one of these accounts is its default, and neither the
# user nor the account can be removed while that is true.
atf.run_command(
f"sacctmgr -i remove user {user}",
user=atf.properties["slurm-user"],
)
accts = ",".join(_acct_name(key) for key in ACCTS)
atf.run_command(
f"sacctmgr -i remove account {accts}",
user=atf.properties["slurm-user"],
)
@pytest.fixture(scope="module")
def decay_window(accrued_usage):
"""Sample both accounts before and after one shared stretch of decay."""
sampled = {k: td.account_usage(_acct_name(k)) for k in ACCTS}
before = {k: v[0] for k, v in sampled.items()}
raw_before = {k: v[1] for k, v in sampled.items()}
for key, value in before.items():
assert value > 0, (
f"{key} accrued no usage, so nothing below can mean anything; "
f"the jobs did not run long enough or accounting is not recording"
)
# Close the window once the override has visibly moved. fatal= so a window
# that never opens fails here, naming the cause, rather than surfacing as
# an equality further down that could also mean the feature works.
for _ in atf.timer(DECAY_WAIT_SEC, 10, quiet=True, fatal=True):
if td.cpu_usage(_acct_name("override")) < before["override"]:
break
sampled = {k: td.account_usage(_acct_name(k)) for k in ACCTS}
after = {k: v[0] for k, v in sampled.items()}
raw_after = {k: v[1] for k, v in sampled.items()}
return {
"before": before,
"after": after,
"raw_before": raw_before,
"raw_after": raw_after,
}
def test_global_decay_is_off(decay_window):
"""With PriorityDecayHalfLife=0 an account with no override holds.
This is the control. If it moved, the global decay is not actually off and
nothing else in this module would be measuring what it claims to.
"""
before = decay_window["before"]["global"]
after = decay_window["after"]["global"]
assert after == before, (
f"PriorityDecayHalfLife=0 must leave GrpTRESMins usage alone, "
f"moved from {before} to {after}"
)
def test_override_still_decays(decay_window):
"""A TRES with its own half-life decays even with the global off.
The decay thread skips a pass when the global factor is 1, which is what
PriorityDecayHalfLife=0 produces. It may only skip when nothing asked for
a rate of its own, so this account has to lose usage over a window the
account above held through.
"""
before = decay_window["before"]["override"]
after = decay_window["after"]["override"]
assert after < before, (
f"cpu={OVERRIDE_HALF_LIFE_SEC} must decay even with "
f"PriorityDecayHalfLife=0, went from {before} to {after}"
)
def test_fairshare_does_not_decay(decay_window):
"""The override moves GrpTRESMins usage only, never RawUsage.
A per-TRES half-life is for the limit counter. Fairshare stays on
PriorityDecayHalfLife, which here is off, so RawUsage must hold on both
accounts - including the one whose GrpTRESMins usage just decayed.
"""
for key in ACCTS:
before = decay_window["raw_before"][key]
after = decay_window["raw_after"][key]
assert after == pytest.approx(before), (
f"{key} RawUsage must not decay with PriorityDecayHalfLife=0, "
f"moved from {before} to {after}"
)