blob: 2730002004130a5d70da12ae63e3ab519bbc5470 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Ticket 26017: Het job components must each count as a submitted job.
Each component of a heterogeneous job counts as a "job" with respect to
resource limits (heterogeneous_jobs.shtml). Two limits are covered, each in
its own account and QOS so their counters cannot interfere:
- qos: QOS MaxSubmitJobsPerUser, observed as the user's MaxSubmitJobsPU and
MaxJobsPU usage in "scontrol show assoc_mgr flags=qos".
- assoc: association MaxSubmitJobs, observed as the user association's
GrpSubmitJobs and GrpJobs usage in "scontrol show assoc_mgr flags=assoc".
Since 26.05 the submit of components after the first is charged to the
temporary het job validation usage, which is freed at the end of the RPC,
while every component is uncharged on its own when it ends. A het job is then
counted once at submit, and cancelling it uncharges other jobs of the user.
The same cleanup also undid the simulated start of the later components
against the real usage, uncounting running jobs at submit.
A het job whose components stack over an association GrpTRES is rejected at
submit after the over-limit component was already created. That component
must not be left behind as a separate job.
A het job component submitted to several partitions is counted in the QOS of
each of them. The simulated start of the component during the het job
validation must not remove the submit count from the partition QOS the
component is not going to run in.
Het job components are submitted with --hold so that the counters only move
at submit and cancel, never because backfill started a component.
Requires: AccountingStorageEnforce=limits, accounting, SchedulerType=
sched/backfill (het jobs are rejected without it), one node for the running
job of the running-job test.
"""
import os
import re
import pytest
import atf
test_name = os.path.splitext(os.path.basename(__file__))[0]
# Jobs run as test-user. get_user_name() is the runner, a different
# account when slurmtestuser is configured.
test_user = atf.properties["test-user"]
acct_qos = f"{test_name}_acct_qos"
acct_assoc = f"{test_name}_acct_assoc"
acct_reject = f"{test_name}_acct_reject"
acct_parts = f"{test_name}_acct_parts"
qos_limited = f"{test_name}_qos_limited"
qos_plain = f"{test_name}_qos_plain"
rejected_name = f"{test_name}_rejected"
part_default = f"{test_name}_part"
part_a = f"{test_name}_part_a"
part_b = f"{test_name}_part_b"
qos_part_a = f"{test_name}_qos_part_a"
qos_part_b = f"{test_name}_qos_part_b"
het_components = 3
# Room for one het job plus one regular job, so the limit can be hit exactly.
submit_limit = het_components + 1
LIMITS = [
pytest.param(("qos", acct_qos, qos_limited), id="qos"),
pytest.param(("assoc", acct_assoc, qos_plain), id="assoc"),
]
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_accounting(modify=True)
atf.require_config_parameter_includes("AccountingStorageEnforce", "limits")
atf.require_config_parameter("SchedulerType", "sched/backfill")
atf.require_nodes(1)
# Replacing PartitionName drops the config's own default partition.
# Jobs that do not pass -p still need one.
atf.require_config_parameter(
"PartitionName",
{
part_default: {"Nodes": "ALL", "Default": "YES"},
part_a: {"Nodes": "ALL"},
part_b: {"Nodes": "ALL"},
},
)
atf.require_slurm_running()
@pytest.fixture(scope="module")
def setup_limits(setup):
"""Create one account and QOS per limit kind, then wait for slurmctld."""
user = test_user
atf.run_command(
f"sacctmgr -i add qos {qos_limited} MaxSubmitJobsPerUser={submit_limit}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add qos {qos_plain}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add account {acct_qos},{acct_assoc}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {user} account={acct_qos} qos={qos_limited}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {user} account={acct_assoc} qos={qos_plain} "
f"MaxSubmitJobs={submit_limit}",
user=atf.properties["slurm-user"],
fatal=True,
)
# slurmdbd pushes the QOS before the associations that reference it, so
# the associations being cached implies the QOS limit is too.
atf.repeat_until(
lambda: (_user_assoc_record(acct_qos), _user_assoc_record(acct_assoc)),
lambda records: records[0] is not None
and records[1] is not None
and f"MaxSubmitJobs={submit_limit}(" in records[1],
fatal=True,
)
yield
atf.run_command(
f"sacctmgr -i del user {user} account={acct_qos},{acct_assoc}",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i del account {acct_qos},{acct_assoc}",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i del qos {qos_limited},{qos_plain}",
user=atf.properties["slurm-user"],
quiet=True,
)
@pytest.fixture(scope="module")
def setup_reject_limit(setup_limits):
"""Create an account whose GrpTRES fits one 1 node component, not two."""
atf.require_version(
(26, 5),
"sbin/slurmctld",
reason="Ticket 24603: het job components are checked together against "
"the limits at submit since 26.05",
)
user = test_user
atf.run_command(
f"sacctmgr -i add account {acct_reject}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {user} account={acct_reject} qos={qos_plain} "
"GrpTRES=node=1",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.repeat_until(
lambda: _user_assoc_record(acct_reject),
lambda record: record is not None,
fatal=True,
)
yield
atf.run_command(
f"sacctmgr -i del user {user} account={acct_reject}",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i del account {acct_reject}",
user=atf.properties["slurm-user"],
quiet=True,
)
@pytest.fixture(scope="module")
def setup_part_qos(setup_limits):
"""Give each of two partitions its own QOS, in an account of its own."""
user = test_user
for qos in (qos_part_a, qos_part_b):
atf.run_command(
f"sacctmgr -i add qos {qos} MaxSubmitJobsPerUser=100",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add account {acct_parts}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {user} account={acct_parts} "
f"qos={qos_plain},{qos_part_a}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.repeat_until(
lambda: _user_assoc_record(acct_parts),
lambda record: record is not None,
fatal=True,
)
for part, qos in ((part_a, qos_part_a), (part_b, qos_part_b)):
# The QOS has to be known to slurmctld before the partition uses it
atf.repeat_until(
lambda: atf.run_command(
f"scontrol update partitionname={part} qos={qos}",
user=atf.properties["slurm-user"],
quiet=True,
)["exit_code"],
lambda exit_code: exit_code == 0,
fatal=True,
)
yield
for part in (part_a, part_b):
atf.run_command(
f"scontrol update partitionname={part} qos=",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i del user {user} account={acct_parts}",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i del account {acct_parts}",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i del qos {qos_part_a},{qos_part_b}",
user=atf.properties["slurm-user"],
quiet=True,
)
@pytest.fixture
def cancel_rejected():
"""Cancel jobs of a rejected het job, which the testsuite does not track."""
yield
atf.run_command(
f"scancel -n {rejected_name} -u {test_user}",
quiet=True,
)
@pytest.fixture
def limit(request, setup_limits):
"""Return (kind, account, qos) once the user's counters are back to zero."""
kind, acct, qos = request.param
_wait_for_usage(kind, acct, qos, submit=0, jobs=0)
return kind, acct, qos
def _user_assoc_record(acct):
"""Return the one-line assoc_mgr record of the user's association."""
output = atf.run_command_output(
f"scontrol -o show assoc_mgr flags=assoc accounts={acct} users={test_user}",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
for line in output.splitlines():
if f"Account={acct} UserName={test_user}(" in line:
return line
return None
def _usage(kind, acct, qos):
"""Return the user's {"submit": n, "jobs": n} usage for the limit kind."""
if kind == "assoc":
record = _user_assoc_record(acct)
if record is None:
pytest.fail(f"slurmctld has no association for account {acct}")
submit = re.search(r"GrpSubmitJobs=[^(\s]*\((\d+)\)", record)
jobs = re.search(r"GrpJobs=[^(\s]*\((\d+)\)", record)
if not submit or not jobs:
pytest.fail(f"Expected GrpSubmitJobs and GrpJobs in: {record}")
return {"submit": int(submit.group(1)), "jobs": int(jobs.group(1))}
output = atf.run_command_output(
f"scontrol -o show assoc_mgr flags=qos qos={qos}",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
record = next(
(line for line in output.splitlines() if line.startswith(f"QOS={qos}(")),
None,
)
if record is None:
pytest.fail(f"slurmctld has no record for QOS {qos}")
user_limits = re.search(
rf"\s{re.escape(test_user)}\(\d+\)=\{{"
r"MaxJobsPU=[^(\s]*\((\d+)\) MaxJobsAccruePU=[^(\s]*\(\d+\) "
r"MaxSubmitJobsPU=[^(\s]*\((\d+)\)",
record,
)
# The per-user entry is only created by the user's first job in the QOS
if not user_limits:
return {"submit": 0, "jobs": 0}
return {
"submit": int(user_limits.group(2)),
"jobs": int(user_limits.group(1)),
}
def _wait_for_usage(kind, acct, qos, submit, jobs):
"""Wait until the user's submitted and running job counts are as given."""
expected = {"submit": submit, "jobs": jobs}
atf.repeat_until(
lambda: _usage(kind, acct, qos),
lambda usage: usage == expected,
fatal=True,
)
def _job_args(acct, qos, hold=True, partitions=None):
"""sbatch arguments for one job (or het component) in acct and qos."""
hold_arg = " --hold" if hold else ""
partition_arg = f" -p {partitions}" if partitions else ""
return (
f"-J {test_name} -A {acct} --qos={qos} -t2 -o /dev/null{hold_arg}"
f"{partition_arg} --wrap 'sleep infinity'"
)
def _het_job_args(acct, qos):
"""sbatch arguments for a held het job, every component in acct and qos."""
return " : ".join([_job_args(acct, qos)] * het_components)
def _submit_het_job(acct, qos):
"""Submit a held het job and return (leader_id, component_ids)."""
leader_id = atf.submit_job_sbatch(_het_job_args(acct, qos), fatal=True)
component_ids = atf.range_to_list(
atf.get_job_parameter(leader_id, "HetJobIdSet", fatal=True)
)
assert len(component_ids) == het_components, (
f"het job {leader_id} should have {het_components} components, got "
f"{component_ids}"
)
return leader_id, component_ids
@pytest.mark.parametrize("limit", LIMITS, indirect=True)
def test_het_job_submit_counts_each_component(limit):
"""Every component of a submitted het job is counted as a submitted job."""
kind, acct, qos = limit
_submit_het_job(acct, qos)
usage = _usage(kind, acct, qos)
assert usage["submit"] == het_components, (
f"a {het_components} component het job should count as "
f"{het_components} submitted jobs, got {usage['submit']}"
)
@pytest.mark.parametrize("limit", LIMITS, indirect=True)
def test_het_job_submit_limit_enforced(limit):
"""A het job uses one submit slot per component, so the limit is reached.
The limit leaves room for one het job and one regular job. Once both are
submitted, neither another regular job nor another het job may be.
"""
kind, acct, qos = limit
_submit_het_job(acct, qos)
assert (
atf.submit_job_sbatch(_job_args(acct, qos)) != 0
), f"a regular job should fit in the remaining submit slot of {submit_limit}"
assert atf.submit_job_sbatch(_job_args(acct, qos), xfail=True) == 0, (
f"a regular job should be rejected once the het job and a regular job "
f"used all {submit_limit} submit slots"
)
assert atf.submit_job_sbatch(_het_job_args(acct, qos), xfail=True) == 0, (
f"a second het job should be rejected once {submit_limit} submit slots "
"are used"
)
@pytest.mark.parametrize("limit", LIMITS, indirect=True)
def test_het_job_cancel_keeps_other_jobs_counted(limit):
"""Cancelling a het job uncounts only its own components.
Without this, every submit and cancel of a het job lowers the counter by
the number of components minus one, and the user can submit past the
limit over and over.
"""
kind, acct, qos = limit
atf.submit_job_sbatch(_job_args(acct, qos), fatal=True)
leader_id, component_ids = _submit_het_job(acct, qos)
atf.run_command(f"scancel {leader_id}", fatal=True)
for job_id in component_ids:
atf.wait_for_job_state(job_id, "CANCELLED", fatal=True)
usage = _usage(kind, acct, qos)
assert usage["submit"] == 1, (
"the regular job should still be counted as submitted after the het "
f"job was cancelled, got {usage['submit']} submitted jobs"
)
@pytest.mark.parametrize("limit", LIMITS, indirect=True)
def test_het_job_submit_keeps_running_jobs_counted(limit):
"""Submitting a held het job does not uncount the user's running jobs.
The het job limit validation simulates the start of each component and
undoes it before the RPC returns. That undo must only touch the simulated
usage, so the running job stays counted.
"""
kind, acct, qos = limit
running_id = atf.submit_job_sbatch(_job_args(acct, qos, hold=False), fatal=True)
atf.wait_for_job_state(running_id, "RUNNING", fatal=True)
_wait_for_usage(kind, acct, qos, submit=1, jobs=1)
_submit_het_job(acct, qos)
usage = _usage(kind, acct, qos)
assert usage["jobs"] == 1, (
"submitting a held het job should leave the running job counted, got "
f"{usage['jobs']} running jobs"
)
@pytest.mark.xfail(
atf.get_version("sbin/slurmctld") < (26, 5, 6),
reason="Ticket 26017: a het job rejected at submit left its over-limit "
"component pending before 26.05.6",
)
@pytest.mark.parametrize("command", ["sbatch", "salloc"])
def test_het_job_rejected_component_not_left_behind(
command, setup_reject_limit, cancel_rejected
):
"""A het job rejected at its second component leaves no job behind.
With association GrpTRES=node=1, each 1 node component passes the limit
validation on its own and is created, then the het job limit check stacks
both and rejects the second one. Every created component must be removed
and uncounted, including the rejected one.
"""
_wait_for_usage("assoc", acct_reject, qos_plain, submit=0, jobs=0)
component = f"-J {rejected_name} -A {acct_reject} --qos={qos_plain} -N1 -t2 --hold"
if command == "sbatch":
batch_component = f"{component} -o /dev/null --wrap 'sleep infinity'"
args = f"{batch_component} : {batch_component}"
else:
args = f"{component} --no-shell : {component}"
result = atf.run_command(f"{command} {args}", timeout=30)
assert result["exit_code"] != 0, f"{command} of the het job should be rejected"
assert "accounting/QOS policy" in result["stderr"], (
f"{command} should be rejected by the het job limit check, got: "
f"{result['stderr']}"
)
left_behind = {
job_id: job.get("JobState")
for job_id, job in atf.get_jobs(quiet=True).items()
if job.get("JobName") == rejected_name and job.get("JobState") != "CANCELLED"
}
assert not left_behind, (
"no component of the rejected het job should be left behind, got "
f"{left_behind}"
)
usage = _usage("assoc", acct_reject, qos_plain)
assert usage["submit"] == 0, (
"no component of the rejected het job should stay counted as "
f"submitted, got {usage['submit']}"
)
def test_het_job_partition_qos_shared_with_job_qos_not_leaked(setup_part_qos):
"""Simulated het job usage is undone in a QOS used as partition and job QOS.
The first component runs in part_a, whose partition QOS is qos_part_a. The
second component uses qos_part_a as its job QOS. The het job validation
simulates the start of both components and then undoes it. The first
component's start in qos_part_a must be undone from the same usage it was
charged to, or the user's running job count in qos_part_a stays above zero
although the het job is only held.
"""
qos_names = (qos_part_a, qos_part_b)
for qos in qos_names:
_wait_for_usage("qos", acct_parts, qos, submit=0, jobs=0)
first = _job_args(acct_parts, qos_plain, partitions=part_a)
second = _job_args(acct_parts, qos_part_a, partitions=part_b)
leader_id = atf.submit_job_sbatch(f"{first} : {second}", fatal=True)
for qos in qos_names:
usage = _usage("qos", acct_parts, qos)
assert usage["jobs"] == 0, (
"submitting a held het job should not leave running jobs counted "
f"in QOS {qos}, got {usage['jobs']}"
)
atf.run_command(f"scancel {leader_id}", fatal=True)
for qos in qos_names:
_wait_for_usage("qos", acct_parts, qos, submit=0, jobs=0)
def test_het_job_multi_partition_keeps_partition_qos_counted(setup_part_qos):
"""A het job in several partitions counts in the QOS of each partition.
Each component is submitted to both partitions, whose QOS differ. At the
real start of a job the submit count is removed from the partition QOS it
does not run in. The het job validation only simulates that start, so the
submit counts must stay in both partition QOS until the het job ends.
"""
partitions = f"{part_a},{part_b}"
qos_names = (qos_part_a, qos_part_b)
for qos in qos_names:
_wait_for_usage("qos", acct_parts, qos, submit=0, jobs=0)
job_args = _job_args(acct_parts, qos_plain, partitions=partitions)
atf.submit_job_sbatch(job_args, fatal=True)
het_job_args = " : ".join([job_args] * het_components)
leader_id = atf.submit_job_sbatch(het_job_args, fatal=True)
expected = 1 + het_components
for qos in qos_names:
usage = _usage("qos", acct_parts, qos)
assert usage["submit"] == expected, (
f"the regular job and the {het_components} het job components "
f"should count as {expected} submitted jobs in partition QOS "
f"{qos}, got {usage['submit']}"
)
atf.run_command(f"scancel {leader_id}", fatal=True)
atf.repeat_until(
lambda: [_usage("qos", acct_parts, qos)["submit"] for qos in qos_names],
lambda counts: counts == [1, 1],
fatal=True,
)