blob: 0a0a509818426a14caf50a9dfe7d1139af49625f [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Tests for TRESRunMins usage when --time-min job time limits are trimmed."""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
# Global variables
account = "acct1"
qos1 = "qos1"
resv1 = "resv1"
test_user = atf.properties["test-user"]
# Filled in setup() with the two node names the partition is pinned to.
part_nodes = None
# The victim job asks for job_time_limit but will settle for job_time_min. Every
# other limit here is picked so that the limit the victim ends up with
# identifies which code path trimmed it.
job_time_limit = 60
job_time_min = 1
reserving_time_limit = 30
# What backfill's node_space map trims the victim down to.
blocker_time_limit = 20
# The reservation test's blocker outlasts resv_start_minutes but not
# job_time_limit, so backfill's node_space map trims to about
# resv_blocker_time_limit while the reservation trims further, to
# resv_start_minutes. Only the reservation can produce the limit that test
# asserts on.
resv_blocker_time_limit = 30
resv_start_minutes = 10
resv_duration_minutes = 600
# Trimming is to whole minutes remaining, so the trimmed limit is short by
# however long the test took to get the victim running.
trim_tolerance = 5
# Enforcement test limit, in cpu-minutes; every job here is exclusive on a two
# CPU node. It has to stay above what the whole-cluster job is tested against
# (blocker 2*20 + victim 2*60 + 4*30 = 280), or that job gets rejected by the
# limit instead of parked on the busy node and never reserves anything.
run_mins_limit = 300
# Fits under the limit, but not once the 2*(60-20) an unfixed slurmctld leaks is
# added to it.
probe_time_limit = 130
# Over the limit on its own.
oversized_probe_time_limit = 200
@pytest.fixture(scope="module", autouse=True)
def setup():
"""Test setup with required configurations."""
atf.require_version(
(26, 5, 5),
"sbin/slurmctld",
reason="Ticket 25339 TRESRunMins give-back when a job's time limit is "
"trimmed is fixed in 26.05.5",
)
atf.require_accounting(modify=True)
atf.require_config_parameter_includes("AccountingStorageEnforce", "limits")
atf.require_config_parameter_includes("AccountingStorageEnforce", "qos")
# Default to multifactor so PriorityCalcPeriod can suspend the decay thread
# for the tests that do not pick a plugin of their own. The parametrized
# backfill trim test also runs under priority/basic, which has no decay
# thread and therefore needs no such knob.
atf.require_config_parameter("PriorityType", "priority/multifactor")
# Keep the decay thread from lowering usage between snapshots.
atf.require_config_parameter("PriorityCalcPeriod", 9999)
atf.require_config_parameter("SchedulerType", "sched/backfill")
# Use includes so that any existing SchedulerParameters are preserved.
# These use the (key, value) form so that a value already set by the
# config being tested against is REPLACED rather than appended: Slurm
# honors the first occurrence of a key, so an appended duplicate is
# silently ignored.
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 5))
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
atf.require_config_parameter_includes("SchedulerParameters", "bf_continue")
# The victim is trimmed against the future allocation backfill reserves for
# the pending whole-cluster job. bf_min_prio_reserve suppresses that
# reservation for jobs below its threshold, and also lets the victim start
# immediately instead of being backfilled, so nothing trims it.
atf.require_config_parameter_includes(
"SchedulerParameters", ("bf_min_prio_reserve", 0)
)
# bf_window has to reach past the victim's requested limit, or backfill
# never plans far enough ahead to see the conflict.
atf.require_config_parameter_includes("SchedulerParameters", ("bf_window", 120))
# node_space entries snap to bf_resolution, so it has to be well under
# trim_tolerance for the trimmed limit to be assertable.
atf.require_config_parameter_includes("SchedulerParameters", ("bf_resolution", 60))
atf.require_nodes(2, [("CPUs", 2), ("RealMemory", 512)])
atf.require_config_parameter("DefMemPerNode", "128")
atf.require_slurm_running()
# require_nodes(2) only guarantees at least two nodes. Nodes=ALL would let
# the -N2 --exclusive reserving job start on extra idle nodes instead of
# staying PENDING, so backfill would never build the node_space reservation
# that trims the victim.
global part_nodes
part_nodes = list(atf.get_nodes().keys())[:2]
assert len(part_nodes) == 2, f"Need exactly two partition nodes, got {part_nodes}"
atf.require_config_parameter(
"PartitionName",
{
"part1": {
"Nodes": ",".join(part_nodes),
"Default": "YES",
"MaxTime": "INFINITE",
}
},
)
@pytest.fixture(scope="function")
def slurm_priority_type(request):
"""Switch PriorityType when a test parametrizes this fixture.
Tests that do not parametrize it keep the module default (multifactor).
priority/basic has no decay thread, so PriorityCalcPeriod is only restored
when switching back to multifactor.
The restart must be clean. Recovering running jobs across a plugin change
re-runs acct_policy_job_begin() without multifactor's elapsed-usage init,
which leaves TRESRunMins booked after those jobs are gone.
"""
wanted = request.param if hasattr(request, "param") else "priority/multifactor"
current = atf.get_config_parameter("PriorityType", live=False, quiet=True)
if str(current).casefold() != wanted.casefold():
atf.cancel_all_jobs(quiet=True)
atf.require_config_parameter("PriorityType", wanted)
if wanted == "priority/multifactor":
atf.require_config_parameter("PriorityCalcPeriod", 9999)
atf.restart_slurm(clean=True)
return wanted
@pytest.fixture(scope="function", autouse=True)
def setup_db(slurm_priority_type):
atf.run_command(
f"sacctmgr -i add qos {qos1}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add account {account}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {test_user} account={account} qos=normal,{qos1}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"scontrol update partitionname=part1 qos={qos1}",
user=atf.properties["slurm-user"],
fatal=True,
)
yield
atf.cancel_all_jobs(quiet=True)
atf.run_command(
f"scontrol delete reservationname={resv1}",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
"scontrol update partitionname=part1 qos=",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i remove user {test_user} where account={account}",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i remove account {account}",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i remove qos {qos1}",
user=atf.properties["slurm-user"],
quiet=True,
)
def time_limit_minutes(job_id):
"""Return a job's TimeLimit in minutes."""
time_limit = atf.get_job_parameter(job_id, "TimeLimit")
assert time_limit is not None, f"Job {job_id} should still report a TimeLimit"
match = re.match(r"(?:(\d+)-)?(\d+):(\d+):(\d+)$", time_limit)
assert match is not None, f"Unable to parse TimeLimit ({time_limit})"
days, hours, minutes, seconds = (int(g or 0) for g in match.groups())
return ((days * 24 + hours) * 60) + minutes + (seconds / 60)
def used_run_mins(entity, limit="GrpTRESRunMins"):
"""Return the non-zero usage the qos or the test assoc booked for limit.
GrpTRESRunMins and MaxTRESRunMinsPU are separate counters that slurmctld
maintains independently, so they are never reported together. Folding them
would hide an over-removal in one behind the other's larger value.
"""
if entity == "qos":
command = f"scontrol show assoc_mgr qos={qos1} flags=qos"
else:
command = f"scontrol show assoc_mgr flags=assoc user={test_user}"
output = atf.run_command_output(
command, user=atf.properties["slurm-user"], fatal=True
)
usage = {}
for line in output.splitlines():
line = line.strip()
if not line.startswith(f"{limit}="):
continue
for tres, used in re.findall(r"([\w/:]+)=[^=,]*?\((\d+)\)", line):
usage[tres] = max(usage.get(tres, 0), int(used))
return {tres: used for tres, used in usage.items() if used}
def get_test_nodes():
"""Return the two node names the partition is pinned to."""
return part_nodes[0], part_nodes[1]
def submit_blocker(node, time_limit):
"""Start a job holding a whole node, so the job below cannot start."""
job_id = atf.submit_job_sbatch(
f"-w {node} --exclusive -t{time_limit} -A {account} --wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING", fatal=True
), "Blocker job should be running"
return job_id
def submit_reserving_job():
"""Submit the whole-cluster job that backfill has to plan around.
While it waits it both makes the main scheduler give up on the partition
and gives backfill a future allocation to trim the victim against.
"""
job_id = atf.submit_job_sbatch(
f"-N2 --exclusive -t{reserving_time_limit} -A {account} "
f"--wrap 'sleep infinity'",
fatal=True,
)
# PENDING is already true right after submit, so wait for a Reason instead.
reason = None
for _ in atf.timer(timeout=60):
reason = atf.get_job_parameter(job_id, "Reason")
if reason not in (None, "", "None"):
break
assert reason not in (
None,
"",
"None",
), "Reserving job should have been evaluated and blocked by the scheduler"
# Guards the enforcement test below: parked on the limit rather than on the
# busy node means nothing was reserved for the victim to be trimmed against.
assert "RunMinutes" not in reason, (
f"Reserving job was blocked by a TRESRunMins limit ({reason}) instead "
f"of by the busy node, so it never reserved the nodes the victim has "
f"to be trimmed against. run_mins_limit is too low."
)
return job_id
def submit_victim(node):
"""Submit the --time-min job whose time limit is trimmed after it starts."""
job_id = atf.submit_job_sbatch(
f"-w {node} --exclusive -t{job_time_limit} --time-min={job_time_min} "
f"-A {account} --wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING", timeout=150, fatal=True
), "Backfilled job should be running"
assert atf.get_job_parameter(job_id, "QOS") == "normal", (
f"The victim must hold normal as its job QOS so that {qos1} is reached "
f"only as the partition QOS; one shared QOS would stop these tests "
f"covering the partition QOS give-back"
)
return job_id
def assert_trimmed_by(job_id, expected, source):
"""Assert a job's time limit was trimmed to expected, naming what did it."""
trimmed_limit = time_limit_minutes(job_id)
assert trimmed_limit >= job_time_min, (
f"{source} must not trim the time limit below --time-min "
f"({job_time_min}), but it is {trimmed_limit}"
)
assert expected - trim_tolerance <= trimmed_limit <= expected, (
f"{source} should have trimmed the time limit to about {expected} "
f"minutes, but it is {trimmed_limit}. A limit of {job_time_limit} means "
f"nothing trimmed the job, {job_time_min} means it was clamped to "
f"--time-min, and any other value means a different code path trimmed "
f"it than the one this test covers"
)
return trimmed_limit
def assert_usage_grew(baseline, entity, limit="GrpTRESRunMins"):
"""Assert that a job starting pushed usage above the recorded baseline."""
current = used_run_mins(entity, limit)
assert all(current.get(tres, 0) > used for tres, used in baseline.items()), (
f"{limit} usage of the {entity} should have grown once the "
f"backfilled job started, but was {baseline} before and {current} after"
)
def assert_usage_restored(entity, baseline, limit="GrpTRESRunMins"):
"""Assert usage settles back to exactly the surviving job's baseline.
The counters floor at zero, so cancelling every job and asserting zero
cannot tell a correct give-back from one that removed more than was ever
booked. Measured against a job that is still running, over-removal shows up
as that job's own usage going missing.
"""
usage = None
for _ in atf.timer(timeout=90, poll_interval=5):
usage = used_run_mins(entity, limit)
if usage == baseline:
break
assert usage == baseline, (
f"{limit} usage of the {entity} should be back to the {baseline} "
f"still booked by the job that is left running, but it is {usage}. More "
f"than {baseline} means usage was leaked, less means the give-back "
f"removed usage that belongs to the surviving job"
)
@pytest.mark.parametrize(
"slurm_priority_type",
["priority/multifactor", "priority/basic"],
indirect=True,
)
def test_backfill_time_min_run_mins_usage(slurm_priority_type):
"""Verify that trimming a --time-min job's time limit does not leak usage.
Backfill raises a --time-min job's time limit back up to the requested limit
when it starts it, then trims it to whatever fits before the next reserved
job. The usage booked against the untrimmed limit has to be given back,
otherwise it accumulates for jobs that are long gone and blocks the user.
Here backfill's own node_space map does the trimming. The test below covers
an advance reservation doing it instead.
Run under both priority plugins. GrpTRESRunMins is documented without a
plugin caveat (unlike GrpTRESMins), and priority/basic gives the usage back
itself in priority_p_job_end(). The jobs run under normal as their own QOS
while part1 carries qos1, so this also checks that basic gives the
partition QOS usage back, not only the job QOS.
"""
node1, node2 = get_test_nodes()
submit_blocker(node1, blocker_time_limit)
reserving_job_id = submit_reserving_job()
# The blocker is the only job booking anything at this point, and it outlives
# the victim, so its usage is the baseline the give-back is measured against.
baseline_qos = used_run_mins("qos")
baseline_assoc = used_run_mins("assoc")
assert (
baseline_qos
), f"QOS {qos1} should show TRESRunMins usage while the blocker is running"
assert baseline_assoc, (
f"Association of {test_user} should show TRESRunMins usage while the "
f"blocker is running"
)
victim_job_id = submit_victim(node2)
assert atf.get_job_parameter(reserving_job_id, "JobState") == "PENDING", (
"Reserving job should still be pending, so that it holds the "
"allocation the backfilled job is trimmed against"
)
assert_trimmed_by(victim_job_id, blocker_time_limit, "Backfill's node_space map")
assert_usage_grew(baseline_qos, "qos")
assert_usage_grew(baseline_assoc, "assoc")
atf.cancel_jobs([victim_job_id, reserving_job_id], fatal=True)
assert_usage_restored("qos", baseline_qos)
assert_usage_restored("assoc", baseline_assoc)
atf.cancel_all_jobs(fatal=True)
assert_usage_restored("qos", {})
assert_usage_restored("assoc", {})
def test_time_min_blocks_start_when_window_is_shorter():
"""Verify a job does not start when --time-min exceeds the free window.
The give-back arithmetic is a function of the trimmed limit. If backfill
ignored --time-min and started the job in a window shorter than that
floor, the booked-vs-given-back difference would be wrong and none of the
usage tests above would see it, because they only look at jobs that ran.
"""
floor_time_min = 40
node1, node2 = get_test_nodes()
submit_blocker(node1, blocker_time_limit)
submit_reserving_job()
job_id = atf.submit_job_sbatch(
f"-w {node2} --exclusive -t{job_time_limit} --time-min={floor_time_min} "
f"-A {account} --wrap 'sleep infinity'",
fatal=True,
)
# Several bf_interval passes; the job must not slip through on a later one.
started = atf.wait_for_job_state(job_id, "RUNNING", timeout=60)
assert not started, (
f"A --time-min={floor_time_min} job should stay pending when the free "
f"window is about {blocker_time_limit} minutes, but it is "
f"{atf.get_job_parameter(job_id, 'JobState')} with TimeLimit "
f"{atf.get_job_parameter(job_id, 'TimeLimit')}"
)
assert atf.get_job_parameter(job_id, "JobState") == "PENDING", (
f"The job should remain PENDING, not "
f"{atf.get_job_parameter(job_id, 'JobState')}"
)
def test_backfill_time_min_max_run_mins_pu_usage():
"""Verify trimming does not leak the per-user MaxTRESRunMins counter.
MaxTRESRunMinsPU is tracked separately from GrpTRESRunMins, on a per-user
record of the QOS rather than on the QOS as a whole, and is the counter
ticket 25339 reports as stuck.
"""
node1, node2 = get_test_nodes()
atf.run_command(
f"sacctmgr -i modify qos {qos1} set MaxTRESRunMinsPU=node={run_mins_limit}",
user=atf.properties["slurm-user"],
fatal=True,
)
submit_blocker(node1, blocker_time_limit)
reserving_job_id = submit_reserving_job()
baseline = used_run_mins("qos", "MaxTRESRunMinsPU")
assert baseline, (
f"QOS {qos1} should show MaxTRESRunMinsPU usage for {test_user} while "
f"the blocker is running"
)
victim_job_id = submit_victim(node2)
assert_trimmed_by(victim_job_id, blocker_time_limit, "Backfill's node_space map")
assert_usage_grew(baseline, "qos", "MaxTRESRunMinsPU")
atf.cancel_jobs([victim_job_id, reserving_job_id], fatal=True)
assert_usage_restored("qos", baseline, "MaxTRESRunMinsPU")
atf.cancel_all_jobs(fatal=True)
assert_usage_restored("qos", {}, "MaxTRESRunMinsPU")
def test_reservation_time_min_run_mins_usage():
"""Verify a reservation trimming a --time-min job does not leak usage.
Backfill lowers a job's time limit so that it does not run into an advance
reservation. That happens after backfill has already booked usage against
the job's full time limit, so the difference has to be given back.
"""
node1, node2 = get_test_nodes()
submit_blocker(node1, resv_blocker_time_limit)
# Created only once the blocker is running, since a future reservation would
# otherwise keep the blocker from starting at all.
atf.run_command(
f"scontrol create reservation reservationname={resv1} "
f"starttime=now+{resv_start_minutes}minutes "
f"duration={resv_duration_minutes} "
f"user=root nodes={node2}",
user=atf.properties["slurm-user"],
fatal=True,
)
reserving_job_id = submit_reserving_job()
baseline_qos = used_run_mins("qos")
baseline_assoc = used_run_mins("assoc")
assert (
baseline_qos
), f"QOS {qos1} should show TRESRunMins usage while the blocker is running"
assert baseline_assoc, (
f"Association of {test_user} should show TRESRunMins usage while the "
f"blocker is running"
)
victim_job_id = submit_victim(node2)
assert atf.get_job_parameter(reserving_job_id, "JobState") == "PENDING", (
"Reserving job should still be pending, so that the reservation is "
"what the backfilled job ends up being trimmed against"
)
assert_trimmed_by(victim_job_id, resv_start_minutes, "The reservation")
assert_usage_grew(baseline_qos, "qos")
assert_usage_grew(baseline_assoc, "assoc")
atf.cancel_jobs([victim_job_id, reserving_job_id], fatal=True)
assert_usage_restored("qos", baseline_qos)
assert_usage_restored("assoc", baseline_assoc)
atf.cancel_all_jobs(fatal=True)
assert_usage_restored("qos", {})
assert_usage_restored("assoc", {})
def test_run_mins_limit_enforced_after_trim():
"""Verify leaked TRESRunMins usage does not go on blocking later jobs.
The tests above read slurmctld's usage counters. This one checks the
documented behavior those counters drive: usage that is never given back
keeps counting against GrpTRESRunMins and holds later jobs on
QOSGrpCPURunMinutesLimit long after the job that booked it is gone.
"""
node1, node2 = get_test_nodes()
atf.run_command(
f"sacctmgr -i modify qos {qos1} set GrpTRESRunMins=cpu={run_mins_limit}",
user=atf.properties["slurm-user"],
fatal=True,
)
# The update reaches slurmctld asynchronously through slurmdbd.
landed = False
for _ in atf.timer(timeout=60, poll_interval=2):
output = atf.run_command_output(
f"scontrol show assoc_mgr qos={qos1} flags=qos",
user=atf.properties["slurm-user"],
fatal=True,
)
if f"GrpTRESRunMins=cpu={run_mins_limit}" in output:
landed = True
break
assert landed, "slurmctld should have picked up the GrpTRESRunMins limit"
# Positive control, so that the check at the end cannot pass whether or not
# the limit is enforced at all.
oversized_job_id = atf.submit_job_sbatch(
f"-N1 --exclusive -t{oversized_probe_time_limit} -A {account} "
f"--wrap 'sleep infinity'",
fatal=True,
)
reason = None
for _ in atf.timer(timeout=60):
reason = atf.get_job_parameter(oversized_job_id, "Reason")
if reason == "QOSGrpCPURunMinutesLimit":
break
assert reason == "QOSGrpCPURunMinutesLimit", (
f"A job asking for more than GrpTRESRunMins=cpu={run_mins_limit} should "
f"be held on QOSGrpCPURunMinutesLimit, but it is held on {reason}"
)
atf.cancel_jobs([oversized_job_id], fatal=True)
# The same leak cycle as the first test.
submit_blocker(node1, blocker_time_limit)
submit_reserving_job()
victim_job_id = submit_victim(node2)
assert_trimmed_by(victim_job_id, blocker_time_limit, "Backfill's node_space map")
atf.cancel_all_jobs(fatal=True)
# Deliberately not asserted on: this test has to fail on the behavior below,
# not on the counters the tests above already cover.
for _ in atf.timer(timeout=90, poll_interval=5):
if not used_run_mins("qos"):
break
probe_job_id = atf.submit_job_sbatch(
f"-N1 --exclusive -t{probe_time_limit} -A {account} "
f"--wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(probe_job_id, "RUNNING", timeout=60), (
f"A job fitting under GrpTRESRunMins=cpu={run_mins_limit} should run "
f"once every other job is gone, but it is held on "
f"{atf.get_job_parameter(probe_job_id, 'Reason')} with "
f"{used_run_mins('qos')} still booked against the QOS"
)
def test_assoc_run_mins_limit_enforced_after_trim():
"""Verify leaked usage does not block later jobs through the association.
The association keeps its own GrpTRESRunMins counter, separate from the
QOS one, and holds jobs on AssocGrpCPURunMinutesLimit rather than on
QOSGrpCPURunMinutesLimit.
"""
node1, node2 = get_test_nodes()
atf.run_command(
f"sacctmgr -i modify user {test_user} where account={account} "
f"set GrpTRESRunMins=cpu={run_mins_limit}",
user=atf.properties["slurm-user"],
fatal=True,
)
landed = False
for _ in atf.timer(timeout=60, poll_interval=2):
output = atf.run_command_output(
f"scontrol show assoc_mgr flags=assoc user={test_user}",
user=atf.properties["slurm-user"],
fatal=True,
)
if f"GrpTRESRunMins=cpu={run_mins_limit}" in output:
landed = True
break
assert (
landed
), "slurmctld should have picked up the association GrpTRESRunMins limit"
oversized_job_id = atf.submit_job_sbatch(
f"-N1 --exclusive -t{oversized_probe_time_limit} -A {account} "
f"--wrap 'sleep infinity'",
fatal=True,
)
reason = None
for _ in atf.timer(timeout=60):
reason = atf.get_job_parameter(oversized_job_id, "Reason")
if reason == "AssocGrpCPURunMinutesLimit":
break
assert reason == "AssocGrpCPURunMinutesLimit", (
f"A job asking for more than the association "
f"GrpTRESRunMins=cpu={run_mins_limit} should be held on "
f"AssocGrpCPURunMinutesLimit, but it is held on {reason}"
)
atf.cancel_jobs([oversized_job_id], fatal=True)
submit_blocker(node1, blocker_time_limit)
submit_reserving_job()
victim_job_id = submit_victim(node2)
assert_trimmed_by(victim_job_id, blocker_time_limit, "Backfill's node_space map")
atf.cancel_all_jobs(fatal=True)
for _ in atf.timer(timeout=90, poll_interval=5):
if not used_run_mins("assoc"):
break
probe_job_id = atf.submit_job_sbatch(
f"-N1 --exclusive -t{probe_time_limit} -A {account} "
f"--wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(probe_job_id, "RUNNING", timeout=60), (
f"A job fitting under the association GrpTRESRunMins=cpu="
f"{run_mins_limit} should run once every other job is gone, but it is "
f"held on {atf.get_job_parameter(probe_job_id, 'Reason')} with "
f"{used_run_mins('assoc')} still booked against the association"
)