blob: 882f62b6f4a3c5f440d40f279d8cdc41db5b2ce2 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test 115.3: sreport cluster reports with partition associations.
Tests that sreport cluster reports correctly account for users whose
associations include partitions (Ticket 23099).
A user's partition-based associations have their usage rolled up into the
user's non-partition (partition='') association through the lineage, so a
user that has both must be counted exactly once, while a user whose only
associations are partition-based must still be reported.
This test covers the six interesting association layouts:
user_part_only: only partition-based associations (dropped pre-fix)
user_both: partition='' and partition-based associations in
account1 (must not be double counted), but only a
partition-based association in account2 (must still
be reported for account2)
user_plain: only a partition='' association (control)
user_two_parts: two partition-based associations in account1 and no
partition='' one (must be one row per report, summed)
user_leak: a partition-based association in account1 whose
partition is named like account2 (must not show up
when filtering on account2)
user_stale: partition='' and partition-based associations in
account3; the partition-based one is deleted and
account3 is re-parented afterwards, so the deleted
association keeps a stale lineage that the partition=''
one no longer rolls up (its usage must still be counted)
Seeds its own cluster with 2008 usage and rolls that window up for every
cluster in the accounting database; run it against a throwaway DB.
"""
import datetime
import os
import re
import pytest
import atf
# Period Boundaries
# Sat Mar 1 00:00:00 2008
period_start_datetime = datetime.datetime(2008, 3, 1, 0, 0, 0)
period_start_epoch = int(period_start_datetime.timestamp())
period_start_string = period_start_datetime.strftime("%Y-%m-%dT%H:%M:%S")
# Sun Mar 2 00:00:00 2008
period_end_datetime = datetime.datetime(2008, 3, 2, 0, 0, 0)
period_end_epoch = int(period_end_datetime.timestamp())
period_end_string = period_end_datetime.strftime("%Y-%m-%dT%H:%M:%S")
# Identities
uid = os.geteuid()
gid = os.getegid()
# Entities
cluster1 = "test115_3_cluster"
account1 = "test115_3_acct1"
account2 = "test115_3_acct2"
account3 = "test115_3_acct3"
account4 = "test115_3_acct4"
partition1 = "test115_3_part"
partition2 = "test115_3_part2"
user_part_only = "test115_3_puser"
user_both = "test115_3_buser"
user_plain = "test115_3_nuser"
user_two_parts = "test115_3_tuser"
user_leak = "test115_3_luser"
user_stale = "test115_3_suser"
# Nodes
node_list = f"{cluster1}_node[0-1]"
cluster_cpus = 32
# Jobs: all run within the first hour of the period on 2 cpus each
job_cpus = 2
# job1: user_part_only, account1, partition association
job1_start_epoch = period_start_epoch
job1_duration = 1200
job1_end_epoch = job1_start_epoch + job1_duration
job1_usage = job1_duration * job_cpus
# job2: user_both, account1, partition association
job2_start_epoch = period_start_epoch
job2_duration = 600
job2_end_epoch = job2_start_epoch + job2_duration
job2_usage = job2_duration * job_cpus
# job3: user_both, account1, non-partition association
job3_start_epoch = period_start_epoch + job2_duration
job3_duration = 900
job3_end_epoch = job3_start_epoch + job3_duration
job3_usage = job3_duration * job_cpus
# job4: user_plain, account1, non-partition association
job4_start_epoch = period_start_epoch
job4_duration = 300
job4_end_epoch = job4_start_epoch + job4_duration
job4_usage = job4_duration * job_cpus
# job5: user_both, account2, partition association
job5_start_epoch = period_start_epoch
job5_duration = 450
job5_end_epoch = job5_start_epoch + job5_duration
job5_usage = job5_duration * job_cpus
# job6: user_two_parts, account1, partition1 association
job6_start_epoch = period_start_epoch
job6_duration = 660
job6_end_epoch = job6_start_epoch + job6_duration
job6_usage = job6_duration * job_cpus
# job7: user_two_parts, account1, partition2 association
job7_start_epoch = period_start_epoch
job7_duration = 240
job7_end_epoch = job7_start_epoch + job7_duration
job7_usage = job7_duration * job_cpus
# job8: user_leak, account1, association with the partition named account2
job8_start_epoch = period_start_epoch
job8_duration = 180
job8_end_epoch = job8_start_epoch + job8_duration
job8_usage = job8_duration * job_cpus
# job9: user_stale, account3, non-partition association
job9_start_epoch = period_start_epoch
job9_duration = 420
job9_end_epoch = job9_start_epoch + job9_duration
job9_usage = job9_duration * job_cpus
# job10: user_stale, account3, partition association (deleted afterwards)
job10_start_epoch = period_start_epoch
job10_duration = 360
job10_end_epoch = job10_start_epoch + job10_duration
job10_usage = job10_duration * job_cpus
# Expected report usage (in seconds)
# user_both's partition association usage (job2) is included in their
# non-partition association usage through the lineage and must only be
# counted once.
user_part_only_usage = job1_usage
user_both_account1_usage = job2_usage + job3_usage
user_both_account2_usage = job5_usage
user_plain_usage = job4_usage
user_two_parts_usage = job6_usage + job7_usage
user_leak_usage = job8_usage
user_stale_usage = job9_usage + job10_usage
account1_usage = (
user_part_only_usage
+ user_both_account1_usage
+ user_plain_usage
+ user_two_parts_usage
+ user_leak_usage
)
account2_usage = user_both_account2_usage
# account3 is re-parented after its deleted partition association got its
# usage, so only the root row (lineage "/") still includes all of it. The
# contract is user_stale_usage on the user row and job9_usage on the
# account3 row, which is the second NOTE under "cluster
# AccountUtilizationByUser" in sreport.1: an account can be smaller than
# the sum of its user rows.
root_usage = account1_usage + account2_usage + user_stale_usage
# Association id dictionary keyed on (user, account, partition)
# (populated in the create_entities fixture)
assoc_id = {}
# Setup
@pytest.fixture(scope="module", autouse=True)
def setup():
# sreport -V exits before parsing options unless accounting_storage/slurmdbd
# is configured, so probe sacctmgr, built from the same libslurmdb, instead.
# Lower this gate to the first maintenance releases that carry the fix
# once the backports land.
atf.require_version(
(26, 11),
"bin/sacctmgr",
reason="Ticket 23099: partition association usage fixed in 26.11",
)
atf.require_accounting(modify=True)
atf.require_slurm_running()
@pytest.fixture(scope="module")
def create_entities():
"""Populate accounting database with the clusters, accounts and users"""
# Clean up after an aborted earlier run; the removal is expected to fail
# when nothing was left behind. Removing a cluster cascades a delete
# across every per-cluster usage table, which can exceed the default
# command timeout on a loaded DB.
atf.run_command(
f"sacctmgr -i remove cluster {cluster1}",
user=atf.properties["slurm-user"],
timeout=120,
quiet=True,
)
atf.run_command(
f"sacctmgr -i add cluster {cluster1}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add account {account1},{account2},{account3},{account4} cluster={cluster1}",
user=atf.properties["slurm-user"],
fatal=True,
)
# user_part_only gets a single partition-based association
atf.run_command(
f"sacctmgr -i add user user={user_part_only} cluster={cluster1} account={account1} partition={partition1}",
user=atf.properties["slurm-user"],
fatal=True,
)
# user_both gets a non-partition and a partition-based association in
# account1 and only a partition-based association in account2
atf.run_command(
f"sacctmgr -i add user user={user_both} cluster={cluster1} account={account1}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user user={user_both} cluster={cluster1} account={account1},{account2} partition={partition1}",
user=atf.properties["slurm-user"],
fatal=True,
)
# user_plain gets a single non-partition association
atf.run_command(
f"sacctmgr -i add user user={user_plain} cluster={cluster1} account={account1}",
user=atf.properties["slurm-user"],
fatal=True,
)
# user_two_parts gets two partition-based associations in account1
atf.run_command(
f"sacctmgr -i add user user={user_two_parts} cluster={cluster1} account={account1} partition={partition1}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user user={user_two_parts} cluster={cluster1} account={account1} partition={partition2}",
user=atf.properties["slurm-user"],
fatal=True,
)
# user_leak gets a partition-based association in account1 whose
# partition is named like account2
atf.run_command(
f"sacctmgr -i add user user={user_leak} cluster={cluster1} account={account1} partition={account2}",
user=atf.properties["slurm-user"],
fatal=True,
)
# user_stale gets a non-partition and a partition-based association in
# account3
atf.run_command(
f"sacctmgr -i add user user={user_stale} cluster={cluster1} account={account3}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user user={user_stale} cluster={cluster1} account={account3} partition={partition1}",
user=atf.properties["slurm-user"],
fatal=True,
)
# Populate assoc_id dictionary keyed on (user, account, partition)
output = atf.run_command_output(
f'sacctmgr -n -P list assoc cluster={cluster1} format="user,account,partition,id"',
fatal=True,
)
for line in output.splitlines():
if match := re.search(r"^([^|]*)\|([^|]*)\|([^|]*)\|(\d+)$", line):
user, account, partition, aid = match.group(1, 2, 3, 4)
assoc_id[(user, account, partition)] = aid
# Verify that the associations we need were all created
for assoc_key in [
(user_part_only, account1, partition1),
(user_both, account1, ""),
(user_both, account1, partition1),
(user_both, account2, partition1),
(user_plain, account1, ""),
(user_two_parts, account1, partition1),
(user_two_parts, account1, partition2),
(user_leak, account1, account2),
(user_stale, account3, ""),
(user_stale, account3, partition1),
]:
if assoc_key not in assoc_id:
pytest.fail(f"Association {assoc_key} was not created")
# The bug only manifests for a user with no partition='' association, so
# make sure those anchors were not created behind our back
for assoc_key in [
(user_part_only, account1, ""),
(user_both, account2, ""),
(user_two_parts, account1, ""),
(user_leak, account1, ""),
]:
if assoc_key in assoc_id:
pytest.fail(
f"Association {assoc_key} must not exist for this test to be "
"meaningful"
)
yield
# Removing a cluster cascades a delete across every per-cluster usage
# table, which can exceed the default command timeout on a loaded DB
atf.run_command(
f"sacctmgr -i remove cluster {cluster1}",
user=atf.properties["slurm-user"],
timeout=120,
fatal=True,
)
# Users and accounts are not cluster-scoped, so removing the cluster
# leaves them behind
atf.run_command(
f"sacctmgr -i remove user {user_part_only},{user_both},{user_plain},{user_two_parts},{user_leak},{user_stale}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i remove account {account1},{account2},{account3},{account4}",
user=atf.properties["slurm-user"],
fatal=True,
)
@pytest.fixture(scope="module")
def archive_load(create_entities):
"""Populate the accounting database with usage for the associations"""
job_values = [
# (jobid, assoc_key, partition, start, end)
(
65536,
(user_part_only, account1, partition1),
partition1,
job1_start_epoch,
job1_end_epoch,
),
(
65537,
(user_both, account1, partition1),
partition1,
job2_start_epoch,
job2_end_epoch,
),
(
65538,
(user_both, account1, ""),
partition1,
job3_start_epoch,
job3_end_epoch,
),
(
65539,
(user_plain, account1, ""),
partition1,
job4_start_epoch,
job4_end_epoch,
),
(
65540,
(user_both, account2, partition1),
partition1,
job5_start_epoch,
job5_end_epoch,
),
(
65541,
(user_two_parts, account1, partition1),
partition1,
job6_start_epoch,
job6_end_epoch,
),
(
65542,
(user_two_parts, account1, partition2),
partition2,
job7_start_epoch,
job7_end_epoch,
),
(
65543,
(user_leak, account1, account2),
account2,
job8_start_epoch,
job8_end_epoch,
),
(
65544,
(user_stale, account3, ""),
partition1,
job9_start_epoch,
job9_end_epoch,
),
(
65545,
(user_stale, account3, partition1),
partition1,
job10_start_epoch,
job10_end_epoch,
),
]
sql_input_path = str(atf.module_tmp_path / "usage.sql")
with open(sql_input_path, "w") as f:
# Add the cluster processor count for the period
f.write(
f"insert into cluster_event_table (node_name, cluster, tres, period_start, period_end, reason, cluster_nodes) values ('', '{cluster1}', '1={cluster_cpus}', {period_start_epoch}, {period_end_epoch}, 'Cluster processor count', '{node_list}')"
)
f.write(
" on duplicate key update period_start=VALUES(period_start), period_end=VALUES(period_end);\n"
)
# Add a completed job for each association of interest
f.write(
"insert into job_table (jobid, associd, wckey, wckeyid, uid, gid, `partition`, blockid, cluster, account, eligible, submit, start, end, suspended, name, state, comp_code, priority, req_cpus, tres_alloc, nodelist, kill_requid, qos, deleted) values"
)
job_records = []
for jobid, assoc_key, partition, start, end in job_values:
account = assoc_key[1]
job_records.append(
f" ('{jobid}', '{assoc_id[assoc_key]}', '', '0', '{uid}', '{gid}', '{partition}', '', '{cluster1}', '{account}', {start}, {start}, {start}, {end}, '0', 'job_{jobid}', '3', '0', '{job_cpus}', {job_cpus}, '1={job_cpus}', '{cluster1}_node0', '0', '0', '0')"
)
f.write(",".join(job_records))
f.write(
" on duplicate key update id=LAST_INSERT_ID(id), eligible=VALUES(eligible), submit=VALUES(submit), start=VALUES(start), end=VALUES(end), associd=VALUES(associd), tres_alloc=VALUES(tres_alloc), wckey=VALUES(wckey), wckeyid=VALUES(wckeyid);\n"
)
# Perform archive load
atf.run_command(
f"sacctmgr -i -n archive load {sql_input_path}",
user=atf.properties["slurm-user"],
fatal=True,
)
# Use sacct to verify the jobs loaded
output = atf.run_command_output(
f"sacct -n -P -M {cluster1} --format=jobid,associd --start={period_start_string} --end={period_end_string}",
fatal=True,
)
for jobid, assoc_key, *_ in job_values:
if not re.search(rf"^{jobid}\|{assoc_id[assoc_key]}$", output, re.MULTILINE):
pytest.fail(f"Job {jobid} was not loaded correctly")
# Use sacctmgr to roll up the time period
atf.run_command(
f"sacctmgr -i roll {period_start_string} {period_end_string}",
user=atf.properties["slurm-user"],
fatal=True,
)
@pytest.fixture(scope="module")
def stale_lineage(archive_load):
"""Leave user_stale's deleted partition association with a stale lineage
Removing an association with jobs only marks it deleted, and moving an
account rewrites the lineage of its live descendants only, so afterwards
the partition='' association no longer rolls up the deleted one."""
atf.run_command(
f"sacctmgr -i delete user user={user_stale} cluster={cluster1} account={account3} partition={partition1}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i modify account {account3} cluster={cluster1} set parent={account4}",
user=atf.properties["slurm-user"],
fatal=True,
)
# Make sure the scenario is the one we want: the live association moved
# under account4 while the deleted one kept its old lineage
output = atf.run_command_output(
f"sacctmgr -n -P list assoc withdeleted cluster={cluster1} user={user_stale} format=partition,lineage",
fatal=True,
)
lineages = dict(line.split("|", 1) for line in output.splitlines() if "|" in line)
if f"/{account4}/{account3}/" not in lineages.get("", ""):
pytest.fail(f"{user_stale}'s live association was not moved: {output}")
if partition1 not in lineages:
pytest.fail(
f"{user_stale}'s deleted association was purged instead of being "
f"marked deleted: {output}"
)
if (f"/{account3}/" not in lineages[partition1]) or (
f"/{account4}/" in lineages[partition1]
):
pytest.fail(
f"{user_stale}'s deleted association did not keep its lineage: {output}"
)
def _assert_report_line(output, pattern, command):
assert (
re.search(pattern, output, re.MULTILINE) is not None
), f'Command output for "{command}" did not match expected pattern "{pattern}"'
@pytest.mark.usefixtures("create_entities", "archive_load", "stale_lineage")
class TestUserUtilizationByAccount:
"""Test cluster UserUtilizationByAccount with partition associations"""
command = f"sreport --local cluster UserUtilizationByAccount cluster='{cluster1}' start={period_start_string} end={period_end_string} -t Seconds -P -n format=Login,Account,Used"
def test_partition_only_user(self):
"""A user with only partition associations must be reported"""
output = atf.run_command_output(self.command, fatal=True)
_assert_report_line(
output,
rf"^{user_part_only}\|{account1}\|{user_part_only_usage}$",
self.command,
)
def test_user_with_both_associations_not_double_counted(self):
"""Partition association usage rolled up into the non-partition
association must only be counted once"""
output = atf.run_command_output(self.command, fatal=True)
_assert_report_line(
output,
rf"^{user_both}\|{account1}\|{user_both_account1_usage}$",
self.command,
)
def test_partition_only_association_in_second_account(self):
"""A user with a non-partition association in one account and only a
partition association in another account must be reported for both"""
output = atf.run_command_output(self.command, fatal=True)
_assert_report_line(
output,
rf"^{user_both}\|{account2}\|{user_both_account2_usage}$",
self.command,
)
def test_plain_user(self):
"""A user with only a non-partition association must be reported"""
output = atf.run_command_output(self.command, fatal=True)
_assert_report_line(
output,
rf"^{user_plain}\|{account1}\|{user_plain_usage}$",
self.command,
)
def test_two_partition_user(self):
"""A user with several partition associations in one account must be
reported once with their usage summed"""
output = atf.run_command_output(self.command, fatal=True)
_assert_report_line(
output,
rf"^{user_two_parts}\|{account1}\|{user_two_parts_usage}$",
self.command,
)
def test_stale_lineage_user(self):
"""A deleted partition association whose lineage the non-partition
association no longer covers must still have its usage counted"""
output = atf.run_command_output(self.command, fatal=True)
_assert_report_line(
output,
rf"^{user_stale}\|{account3}\|{user_stale_usage}$",
self.command,
)
def test_account_filter(self):
"""Users with only partition associations must be reported when
filtering on accounts (as in the original ticket report)"""
command = f"{self.command} accounts={account1}"
output = atf.run_command_output(command, fatal=True)
_assert_report_line(
output,
rf"^{user_part_only}\|{account1}\|{user_part_only_usage}$",
command,
)
_assert_report_line(
output,
rf"^{user_both}\|{account1}\|{user_both_account1_usage}$",
command,
)
_assert_report_line(
output,
rf"^{user_plain}\|{account1}\|{user_plain_usage}$",
command,
)
assert (
re.search(rf"^[^|]*\|{account2}\|", output, re.MULTILINE) is None
), f"{account2} should not be reported when filtering on {account1}"
def test_user_filter(self):
"""Users with only partition associations must be reported when
filtering on users"""
command = f"{self.command} users={user_part_only}"
output = atf.run_command_output(command, fatal=True)
_assert_report_line(
output,
rf"^{user_part_only}\|{account1}\|{user_part_only_usage}$",
command,
)
assert (
re.search(rf"^{user_plain}\|", output, re.MULTILINE) is None
), f"{user_plain} should not be reported when filtering on {user_part_only}"
def test_user_filter_sums_partitions(self):
"""A user with several partition associations must have them summed
when filtering on users"""
command = f"{self.command} users={user_two_parts}"
output = atf.run_command_output(command, fatal=True)
_assert_report_line(
output,
rf"^{user_two_parts}\|{account1}\|{user_two_parts_usage}$",
command,
)
assert (
len(re.findall(rf"^{user_two_parts}\|", output, re.MULTILINE)) == 1
), f"{user_two_parts} should be reported on exactly one row"
@pytest.mark.usefixtures("create_entities", "archive_load", "stale_lineage")
class TestAccountUtilizationByUser:
"""Test cluster AccountUtilizationByUser with partition associations"""
command = f"sreport --local cluster AccountUtilizationByUser cluster='{cluster1}' start={period_start_string} end={period_end_string} -t Seconds -P -n format=Account,Login,Used"
def test_account_totals(self):
"""Account and root rows must include partition association usage
exactly once"""
output = atf.run_command_output(self.command, fatal=True)
_assert_report_line(output, rf"^root\|\|{root_usage}$", self.command)
_assert_report_line(output, rf"^{account1}\|\|{account1_usage}$", self.command)
_assert_report_line(output, rf"^{account2}\|\|{account2_usage}$", self.command)
def test_user_rows(self):
"""Every user must be reported under their accounts with the usage
of all of their associations counted exactly once"""
output = atf.run_command_output(self.command, fatal=True)
_assert_report_line(
output,
rf"^{account1}\|{user_part_only}\|{user_part_only_usage}$",
self.command,
)
_assert_report_line(
output,
rf"^{account1}\|{user_both}\|{user_both_account1_usage}$",
self.command,
)
_assert_report_line(
output,
rf"^{account2}\|{user_both}\|{user_both_account2_usage}$",
self.command,
)
_assert_report_line(
output,
rf"^{account1}\|{user_plain}\|{user_plain_usage}$",
self.command,
)
_assert_report_line(
output,
rf"^{account1}\|{user_leak}\|{user_leak_usage}$",
self.command,
)
assert (
len(re.findall(rf"^{account1}\|{user_both}\|", output, re.MULTILINE)) == 1
), f"{user_both} should have exactly one row under {account1}"
def test_two_partition_user_single_row(self):
"""A user with several partition associations in one account must
get a single row with their usage summed"""
output = atf.run_command_output(self.command, fatal=True)
_assert_report_line(
output,
rf"^{account1}\|{user_two_parts}\|{user_two_parts_usage}$",
self.command,
)
assert (
len(re.findall(rf"^{account1}\|{user_two_parts}\|", output, re.MULTILINE))
== 1
), f"{user_two_parts} should have exactly one row under {account1}"
def test_stale_lineage_user_keeps_partition_usage(self):
"""A deleted partition association whose lineage the non-partition
association no longer covers must still have its usage counted"""
output = atf.run_command_output(self.command, fatal=True)
_assert_report_line(
output,
rf"^{account3}\|{user_stale}\|{user_stale_usage}$",
self.command,
)
def test_account_filter_ignores_partition_names(self):
"""Filtering on an account must not pick up associations whose
partition is named like that account"""
command = f"{self.command} accounts={account2}"
output = atf.run_command_output(command, fatal=True)
_assert_report_line(
output,
rf"^{account2}\|{user_both}\|{user_both_account2_usage}$",
command,
)
assert (
re.search(rf"^{account1}\|", output, re.MULTILINE) is None
), f"{account1} rows should not be reported when filtering on {account2}"
command = f"{self.command} accounts={account1}"
output = atf.run_command_output(command, fatal=True)
_assert_report_line(
output,
rf"^{account1}\|{user_leak}\|{user_leak_usage}$",
command,
)
def test_account_filter_includes_sub_accounts(self):
"""Filtering on an account must still report its sub-accounts and
their users; the deleted association kept its old lineage, so only
the live association's usage shows up under account4"""
command = f"{self.command} accounts={account4}"
output = atf.run_command_output(command, fatal=True)
_assert_report_line(output, rf"^{account4}\|\|{job9_usage}$", command)
_assert_report_line(output, rf"^{account3}\|\|{job9_usage}$", command)
_assert_report_line(
output, rf"^{account3}\|{user_stale}\|{job9_usage}$", command
)
assert (
re.search(rf"^{account1}\|", output, re.MULTILINE) is None
), f"{account1} rows should not be reported when filtering on {account4}"
def test_tree_spans_sub_accounts(self):
"""The tree option must indent a sub-account under its parent"""
command = f"{self.command} accounts={account4} tree"
output = atf.run_command_output(command, fatal=True)
parent = re.search(rf"^( *){account4}\|", output, re.MULTILINE)
child = re.search(rf"^( *){account3}\|", output, re.MULTILINE)
assert parent is not None, f"{account4} should be reported by {command}"
assert child is not None, f"{account3} should be reported by {command}"
assert len(child.group(1)) > len(
parent.group(1)
), f"{account3} should be indented under {account4} with the tree option"