blob: 3b0ddb50cf18937efd32ad1aa769f27dfb97df52 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""test_130_13.py - ResetAccrueTime and accrue limits across restarts.
ResetAccrueTime sets the AccrueTime of a pending job to now, so it loses its
accrued age. Accrue limits and times survive a reconfigure and a restart.
test_130_13 covers the accrue limits themselves.
A job accrues when squeue shows its AccrueTime. The scontrol show assoc_mgr
count is also checked where possible.
Ticket 13223: ResetAccrueTime was undone at the next priority recalculation.
"""
import re
import time
import pytest
import atf
pytestmark = pytest.mark.slow
test_user = atf.properties["test-user"]
# LIMIT jobs may accrue. Each case submits one more job per account.
LIMIT = 2
JOBS_PER_ACCOUNT = LIMIT + 1
acct_free = "acct_free_130_13"
acct_free2 = "acct_free2_130_13"
acct_grp = "acct_grp_130_13"
acct_max = "acct_max_130_13"
acct_parent = "acct_parent_130_13"
acct_children = ["acct_child1_130_13", "acct_child2_130_13"]
acct_newlimit = "acct_newlimit_130_13"
qos_free = "qos_free_130_13"
qos_grp = "qos_grp_130_13"
qos_pu = "qos_pu_130_13"
qos_pa = "qos_pa_130_13"
# The weighted age grows by one point per second of accrual.
AGE_WEIGHT = 3600
MAX_AGE_MINUTES = 60
CALC_PERIOD_MINUTES = 1
CALC_PERIOD = CALC_PERIOD_MINUTES * 60
AGE_GAP = 10
# A priority recalculation can take a full PriorityCalcPeriod.
PRIORITY_TIMEOUT = 3 * CALC_PERIOD
# The unset AccrueTime value is not documented: no timestamp means unset.
ACCRUE_TIME = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
GRP_ASSOC_COUNT = (f"accounts={acct_grp} flags=assoc", "GrpJobsAccrue")
GRP_QOS_COUNT = (f"qos={qos_grp} flags=qos", "GrpJobsAccrue")
# Each case: accounts, QOS, accruing jobs, accruing jobs in each account when
# the limit is per account, and the assoc_mgr filter and label.
# The per-user and per-account QOS cases only check AccrueTime. QOS cases use
# two accounts: PerAccount allows LIMIT jobs in each one.
LIMITS = [
([acct_grp], qos_free, LIMIT, None, GRP_ASSOC_COUNT),
(
[acct_max],
qos_free,
LIMIT,
None,
(f"accounts={acct_max} users={test_user} flags=assoc", "MaxJobsAccrue"),
),
(
acct_children,
qos_free,
LIMIT,
None,
(f"accounts={acct_parent} flags=assoc", "GrpJobsAccrue"),
),
([acct_free, acct_free2], qos_grp, LIMIT, None, GRP_QOS_COUNT),
([acct_free, acct_free2], qos_pu, LIMIT, None, None),
([acct_free, acct_free2], qos_pa, 2 * LIMIT, LIMIT, None),
]
LIMIT_IDS = [
"assoc_GrpJobsAccrue",
"assoc_MaxJobsAccrue",
"parent_GrpJobsAccrue",
"qos_GrpJobsAccrue",
"qos_MaxJobsAccruePerUser",
"qos_MaxJobsAccruePerAccount",
]
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_accounting(modify=True)
atf.require_config_parameter_includes("AccountingStorageEnforce", "limits")
atf.require_config_parameter_includes("AccountingStorageEnforce", "qos")
atf.require_config_parameter("PriorityType", "priority/multifactor")
atf.require_config_parameter_excludes("PriorityFlags", "ACCRUE_ALWAYS")
atf.require_config_parameter("PriorityWeightAge", AGE_WEIGHT)
atf.require_config_parameter("PriorityMaxAge", MAX_AGE_MINUTES)
atf.require_config_parameter("PriorityCalcPeriod", CALC_PERIOD_MINUTES)
atf.require_slurm_running()
su = atf.properties["slurm-user"]
qos_limits = {
qos_free: "",
qos_grp: f"GrpJobsAccrue={LIMIT}",
qos_pu: f"MaxJobsAccruePerUser={LIMIT}",
qos_pa: f"MaxJobsAccruePerAccount={LIMIT}",
}
for qos, limits in qos_limits.items():
atf.run_command(f"sacctmgr -i add qos {qos} {limits}", user=su, fatal=True)
atf.run_command(
f"sacctmgr -i add account {acct_free},{acct_free2},{acct_max},"
f"{acct_newlimit}",
user=su,
fatal=True,
)
for account in (acct_grp, acct_parent):
atf.run_command(
f"sacctmgr -i add account {account} GrpJobsAccrue={LIMIT}",
user=su,
fatal=True,
)
atf.run_command(
f"sacctmgr -i add account {','.join(acct_children)} parent={acct_parent}",
user=su,
fatal=True,
)
accounts = ",".join(
[acct_free, acct_free2, acct_grp, acct_max, acct_newlimit, *acct_children]
)
qos_list = ",".join([qos_free, qos_grp, qos_pu, qos_pa])
atf.run_command(
f"sacctmgr -i add user {test_user} account={accounts} qos={qos_list}",
user=su,
fatal=True,
)
atf.run_command(
f"sacctmgr -i modify user where name={test_user} account={acct_max} "
f"set MaxJobsAccrue={LIMIT}",
user=su,
fatal=True,
)
# Load the new accounts, QOS and limits before any job.
atf.restart_slurmctld()
@pytest.fixture
def blocker():
"""Keep one node busy. Returns the job ID and the node name."""
job_id = atf.submit_job_sbatch(
f"-A {acct_free} --qos={qos_free} -N1 --exclusive --wrap 'sleep infinity'",
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
return job_id, atf.get_job_parameter(job_id, "NodeList", fatal=True)
def submit(node, account, qos, options=""):
"""Submit a job that stays pending on node."""
return atf.submit_job_sbatch(
f"-A {account} --qos={qos} -w {node} --exclusive {options} "
"--wrap 'sleep infinity'",
fatal=True,
)
def submit_jobs(node, accounts, qos, array=False):
"""Submit JOBS_PER_ACCOUNT pending jobs, or array tasks, in each account."""
job_ids = []
for account in accounts:
if array:
options = f"--array=0-{JOBS_PER_ACCOUNT - 1}"
job_ids.append(submit(node, account, qos, options))
else:
job_ids += [submit(node, account, qos) for _ in range(JOBS_PER_ACCOUNT)]
return job_ids
def accrue_times(job_ids):
"""Return the AccrueTime of each pending job or array task.
Array tasks use the "<array job ID>_<task ID>" key.
"""
ids = {str(job_id) for job_id in job_ids}
output = atf.run_command_output(
f"squeue -h -r -t PD -u {test_user} -O ArrayJobID,ArrayTaskID,AccrueTime",
fatal=True,
)
times = {}
for line in output.splitlines():
fields = line.split()
if len(fields) < 2 or fields[0] not in ids:
continue
key = f"{fields[0]}_{fields[1]}" if fields[1].isdigit() else fields[0]
times[key] = fields[2] if len(fields) > 2 else ""
return times
def accruing(job_ids):
"""Return the AccrueTime of the pending jobs in job_ids that accrue."""
return {
key: value
for key, value in accrue_times(job_ids).items()
if ACCRUE_TIME.fullmatch(value)
}
def assert_accruing_per_account(job_ids, accounts, expected):
"""Check that expected pending jobs in job_ids accrue in each account."""
ids = {str(job_id) for job_id in job_ids}
output = atf.run_command_output(
f"squeue -h -r -t PD -u {test_user} -O ArrayJobID,Account,AccrueTime",
fatal=True,
)
counts = {account: 0 for account in accounts}
for line in output.splitlines():
fields = line.split()
if len(fields) == 3 and fields[0] in ids and ACCRUE_TIME.fullmatch(fields[2]):
counts[fields[1]] = counts.get(fields[1], 0) + 1
assert counts == {account: expected for account in accounts}, (
f"{expected} pending jobs should accrue in each account, but the "
f"accruing jobs per account are {counts}"
)
def wait_for_marker(node):
"""Wait until a new job with no accrue limit accrues.
Then the controller has also checked all earlier jobs.
"""
marker = submit(node, acct_free, qos_free)
for _ in atf.timer(fatal=True):
if accruing([marker]):
break
def assert_accrue_count(filters, label, expected=LIMIT):
"""Check the used count of "label=LIMIT(used)" in assoc_mgr."""
output = atf.run_command_output(
f"scontrol show assoc_mgr {filters}",
user=atf.properties["slurm-user"],
fatal=True,
)
match = re.search(rf"\b{label}={LIMIT}\((\d+)\)", output)
count = int(match.group(1)) if match else None
assert count == expected, (
f"scontrol show assoc_mgr {filters} should show {label}={LIMIT}"
f"({expected}), but the count is {count}"
)
def accrue_epoch(value):
"""Return an AccrueTime timestamp as seconds since the epoch."""
return time.mktime(time.strptime(value, "%Y-%m-%dT%H:%M:%S"))
def ages(job_ids):
"""Return the sprio weighted age of each job."""
ids = {str(job_id) for job_id in job_ids}
# sprio -j with several jobs fails before 25.11.3 (Ticket 24625).
if len(ids) > 1 and atf.get_version("bin/sprio") < (25, 11, 3):
jobs = f"-u {test_user}"
else:
jobs = f"-j {','.join(map(str, job_ids))}"
output = atf.run_command_output(f"sprio -h {jobs} -o '%i %A'", fatal=True)
job_ages = {}
for line in output.splitlines():
fields = line.split()
if len(fields) == 2 and fields[0] in ids:
job_ages[int(fields[0])] = int(float(fields[1]))
return job_ages
def test_reset_accrue_time(blocker):
"""Verify ResetAccrueTime makes a job lose its accrued age.
The new AccrueTime must survive the next priority recalculation (Ticket
13223).
"""
_, node = blocker
su = atf.properties["slurm-user"]
reset_job = submit(node, acct_free, qos_free)
other_job = submit(node, acct_free, qos_free)
for _ in atf.timer(fatal=True):
if len(accruing([reset_job, other_job])) == 2:
break
accrued = accrue_epoch(accrue_times([reset_job])[str(reset_job)])
# Give the other job some age first. This can take a recalculation.
for _ in atf.timer(timeout=PRIORITY_TIMEOUT, fatal=True):
if ages([other_job]).get(other_job, 0) >= AGE_GAP:
break
before = int(time.time())
atf.run_command(
f"scontrol update jobid={reset_job} ResetAccrueTime", user=su, fatal=True
)
value = ""
for _ in atf.timer(fatal=True):
value = accrue_times([reset_job]).get(str(reset_job), "")
if ACCRUE_TIME.fullmatch(value):
break
reset = accrue_epoch(value)
assert reset >= before and reset > accrued, (
f"ResetAccrueTime should set the AccrueTime of job {reset_job} to "
f"now, but it is {value}"
)
# Wait for at least one priority recalculation after the reset.
start_age = ages([other_job])[other_job]
job_ages = {}
for _ in atf.timer(timeout=PRIORITY_TIMEOUT, fatal=True):
job_ages = ages([reset_job, other_job])
if job_ages.get(other_job, 0) >= start_age + CALC_PERIOD:
break
after = accrue_times([reset_job]).get(str(reset_job))
assert after == value, (
f"The AccrueTime of job {reset_job} should still be {value} after a "
f"priority recalculation, but it is {after}"
)
reset_age = job_ages.get(reset_job)
assert reset_age is not None and reset_age < job_ages[other_job], (
f"Job {reset_job} should be younger than job {other_job} after the "
f"reset, but the ages are {job_ages}"
)
@pytest.mark.xfail(
reason="Issue 51241: ResetAccrueTime frees the accrue slot of a job that "
"keeps accruing"
)
@pytest.mark.parametrize(
"accounts,qos,expected,per_account,count", LIMITS, ids=LIMIT_IDS
)
def test_reset_accrue_time_under_limit(
blocker, accounts, qos, expected, per_account, count
):
"""Verify ResetAccrueTime on an accruing job keeps the limit."""
_, node = blocker
job_ids = submit_jobs(node, accounts, qos)
wait_for_marker(node)
jobs = accruing(job_ids)
assert len(jobs) == expected, (
f"{expected} of the {len(accounts) * JOBS_PER_ACCOUNT} pending jobs "
f"should accrue, but {sorted(jobs)} do"
)
if per_account:
assert_accruing_per_account(job_ids, accounts, per_account)
reset_job = min(jobs)
atf.run_command(
f"scontrol update jobid={reset_job} ResetAccrueTime",
user=atf.properties["slurm-user"],
fatal=True,
)
wait_for_marker(node)
jobs = accruing(job_ids)
assert len(jobs) == expected, (
f"After ResetAccrueTime on job {reset_job}, {expected} jobs should "
f"still accrue, but {sorted(jobs)} do"
)
if per_account:
assert_accruing_per_account(job_ids, accounts, per_account)
if count:
assert_accrue_count(*count)
@pytest.mark.xfail(
reason="Issue 51241: ResetAccrueTime frees the accrue slot of a job that "
"keeps accruing"
)
def test_reset_accrue_time_new_limit(blocker):
"""Verify ResetAccrueTime brings a queue under a new accrue limit.
This is the use case scontrol.1 gives for ResetAccrueTime.
"""
_, node = blocker
su = atf.properties["slurm-user"]
job_ids = submit_jobs(node, [acct_newlimit], qos_free)
wait_for_marker(node)
jobs = accruing(job_ids)
assert len(jobs) == JOBS_PER_ACCOUNT, (
f"All {JOBS_PER_ACCOUNT} pending jobs should accrue before the limit "
f"is added, but {sorted(jobs)} do"
)
atf.run_command(
f"sacctmgr -i modify account {acct_newlimit} set GrpJobsAccrue={LIMIT}",
user=su,
fatal=True,
)
# The limit reaches slurmctld later, through slurmdbd.
for _ in atf.timer(fatal=True):
output = atf.run_command_output(
f"scontrol show assoc_mgr accounts={acct_newlimit} flags=assoc",
user=su,
fatal=True,
)
if re.search(rf"\bGrpJobsAccrue={LIMIT}\(", output):
break
wait_for_marker(node)
jobs = accruing(job_ids)
assert len(jobs) == JOBS_PER_ACCOUNT, (
f"All {JOBS_PER_ACCOUNT} jobs should still accrue after the limit is "
f"added and before the reset, but {sorted(jobs)} do"
)
for job_id in job_ids:
atf.run_command(
f"scontrol update jobid={job_id} ResetAccrueTime", user=su, fatal=True
)
wait_for_marker(node)
jobs = accruing(job_ids)
assert len(jobs) == LIMIT, (
f"After ResetAccrueTime under the new limit, {LIMIT} of the "
f"{JOBS_PER_ACCOUNT} jobs should accrue, but {sorted(jobs)} do"
)
@pytest.mark.parametrize("event", ["reconfigure", "restart"])
def test_accrue_limit_after_controller_event(blocker, event):
"""Verify accrue limits and times survive a reconfigure or restart.
The jobs are under an association and a QOS limit.
"""
_, node = blocker
job_ids = submit_jobs(node, [acct_grp], qos_grp)
wait_for_marker(node)
jobs = accruing(job_ids)
assert len(jobs) == LIMIT, (
f"{LIMIT} of the {JOBS_PER_ACCOUNT} pending jobs should accrue, "
f"but {sorted(jobs)} do"
)
if event == "reconfigure":
atf.run_command(
"scontrol reconfigure", user=atf.properties["slurm-user"], fatal=True
)
else:
atf.restart_slurmctld()
wait_for_marker(node)
after = accruing(job_ids)
assert after == jobs, (
f"After the {event}, the jobs {jobs} should still accrue from the "
f"same time, but {after} do"
)
assert_accrue_count(*GRP_ASSOC_COUNT)
assert_accrue_count(*GRP_QOS_COUNT)
cancelled = min(jobs)
atf.run_command(f"scancel {cancelled}", fatal=True)
wait_for_marker(node)
jobs = accruing(job_ids)
assert len(jobs) == LIMIT, (
f"After the {event}, a waiting job should accrue in place of cancelled "
f"job {cancelled}, but {sorted(jobs)} do"
)
assert_accrue_count(*GRP_ASSOC_COUNT)
assert_accrue_count(*GRP_QOS_COUNT)