blob: 40012da71c4fa20beb5ddb285c44afc382543b00 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test the billable usage sacct reports for a job (ticket 24176).
The Billing and BillingRaw fields of sacct and the billing_raw field its data
parser dumps report the raw, un-decayed billable usage of a job: the billing
TRES of the allocation multiplied by the elapsed seconds of the job. That is
the same quantity the multifactor priority plugin accumulates into the usage
of an association, so with decay turned off (PriorityDecayHalfLife=0) the
value sacct reports for a job has to be exactly the RawUsage that sshare adds
for the association of the user that submitted it.
The jobs run under an account created by the test so that the association
starts without usage and only the jobs of this test contribute to it, and
under a QOS created with a UsageFactor of 1, since the plugin scales the usage
it adds by the UsageFactor of the QOS of the job.
"""
import json
import os
from datetime import datetime, timedelta
import pytest
import atf
pytestmark = pytest.mark.slow
test_name = os.path.splitext(os.path.basename(__file__))[0]
acct_name = f"{test_name}_acct"
qos_name = f"{test_name}_qos"
part_name = f"{test_name}_billing_weights"
user_name = atf.properties["test-user"]
# Seconds the jobs sleep. Long enough to get an unambiguous non-zero usage,
# short enough to keep the test quick.
job_sleep = 10
# Billing weight of the partition the jobs run in. Kept integral so that the
# billing TRES of the allocation is not rounded, and above 1 so that it
# differs from the CPU count.
cpu_billing_weight = 2
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"bin/sacct",
reason="Ticket 24176: sacct Billing and BillingRaw added in 26.11+",
)
atf.require_accounting(modify=True)
atf.require_config_parameter("PriorityType", "priority/multifactor")
# Without decay the usage sshare reports is the raw billable usage of the
# jobs. A reset period has to be given explicitly when the half-life is 0.
atf.require_config_parameter("PriorityDecayHalfLife", ["00:00:00", "0"])
atf.require_config_parameter("PriorityUsageResetPeriod", "NONE")
# The usage of the last (and for short jobs, only) part of a job is added
# when the job ends, which acct_policy_job_fini() only does when limits
# are enforced.
atf.require_config_parameter_includes("AccountingStorageEnforce", "limits")
# A requeued job is only eligible again after requeue_delay, which defaults
# to the AuthInfo cred_expire (120 seconds), and is only started by the
# next scheduling cycle. The (key, value) form replaces a value the site
# config already sets; a "key=value" string would only append, and
# slurmctld reads the first token for the key.
atf.require_config_parameter_includes("SchedulerParameters", ("requeue_delay", 0))
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
atf.require_slurm_running()
@pytest.fixture(scope="module")
def account(setup):
"""Add an association for the submitting user that has no usage yet"""
# The plugin multiplies the usage it adds by the UsageFactor of the QOS of
# the job, which BillingRaw leaves out. Give the association its own QOS
# with a UsageFactor of 1 instead of relying on the default QOS of the
# cluster, so that the usage sshare adds is the one sacct reports.
atf.run_command(
f"sacctmgr -i add qos {qos_name} UsageFactor=1",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add account {acct_name}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {user_name} account={acct_name} "
f"qos={qos_name} DefaultQOS={qos_name}",
user=atf.properties["slurm-user"],
fatal=True,
)
# sacctmgr commits to slurmdbd, which sends the association on to
# slurmctld asynchronously. sshare and the job submissions below read it
# from slurmctld, so wait for it to arrive before a test uses the account.
for _ in atf.timer(fatal=True):
output = atf.run_command_output(
f"sshare -n -P -U -u {user_name} -A {acct_name} -o User",
fatal=True,
quiet=True,
)
if user_name in [line.strip() for line in output.splitlines()]:
break
yield acct_name
atf.run_command(
f"sacctmgr -i remove user {user_name} where account={acct_name}",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i remove account {acct_name}",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"sacctmgr -i remove qos {qos_name}",
user=atf.properties["slurm-user"],
quiet=True,
)
def _sshare_raw_usage(account):
"""Return the RawUsage sshare reports for the submitting user in account"""
output = atf.run_command_output(
f"sshare -n -P -U -u {user_name} -A {account} -o User,RawUsage",
fatal=True,
quiet=True,
)
usages = []
for line in output.splitlines():
fields = [field.strip() for field in line.split("|")]
if len(fields) == 2 and fields[0] == user_name:
assert atf.is_integer(
fields[1]
), f"sshare should report a numeric RawUsage, got {fields[1]!r}"
usages.append(int(fields[1]))
assert len(usages) == 1, (
f"sshare should report one association for user {user_name} in account "
f"{account}, got {output!r}"
)
return usages[0]
def _wait_for_raw_usage(account, usage_before, expected_usage):
"""Return the RawUsage of the association once the job usage was added
The priority plugin adds the usage of a job in chunks (one per
PriorityCalcPeriod while it runs plus the remainder when it ends), so wait
for the usage to reach the expected one instead of taking the first change
seen. The last value read is returned when it never does, letting the
caller report how much usage was added instead of failing the timer.
"""
usage = usage_before
for _ in atf.timer():
usage = _sshare_raw_usage(account)
if usage - usage_before >= expected_usage:
break
return usage
def _wait_completed(job_id, runs=1):
"""Wait for a job that sleeps once per run to complete"""
# The job sleeps to accrue usage, so it needs that long on top of the
# usual time to be scheduled and to complete.
atf.wait_for_job_state(
job_id,
"COMPLETED",
fatal=True,
timeout=runs * job_sleep + atf.default_polling_timeout,
)
def _submit_and_run(sbatch_args):
"""Submit a sleeping job and return its id once it ran for a second"""
job_id = atf.submit_job_sbatch(
f'{sbatch_args} --wrap "sleep {job_sleep}"', fatal=True
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
# Only a run of at least a second gives its record a billable usage. Poll
# every second instead of at the default rate, a tenth of the timeout, so
# that the caller still has most of the run left to act on the job.
for _ in atf.timer(poll_interval=1, fatal=True):
run_time = atf.get_job_parameter(job_id, "RunTime", default="", quiet=True)
if run_time not in ("", "00:00:00"):
break
return job_id
def _submit_and_wait(sbatch_args):
"""Submit a sleeping job and return its id once it completed"""
job_id = atf.submit_job_sbatch(
f'{sbatch_args} --wrap "sleep {job_sleep}"', fatal=True
)
_wait_completed(job_id)
return job_id
def _sacct_job_records(job_id, fields, options=""):
"""Return the sacct values of fields for every record of the job
A requeued job is accounted as one record per run, all of them reported
by sacct with --duplicates. options are added to the sacct command line.
"""
atf.wait_for_job_accounted(job_id, "State", "COMPLETED", fatal=True)
output = atf.run_command_output(
f"sacct -j {job_id} -X -D -n -P {options} -o {','.join(fields)}",
fatal=True,
)
records = []
for line in output.splitlines():
values = [value.strip() for value in line.split("|")]
assert len(values) == len(
fields
), f"sacct should report {fields} for job {job_id}, got {line!r}"
records.append(dict(zip(fields, values)))
return records
def _sacct_job_fields(job_id, fields, options=""):
"""Return a dict with the sacct values of fields for the job (not steps)"""
records = _sacct_job_records(job_id, fields, options)
assert (
len(records) == 1
), f"sacct should report one record for job {job_id}, got {len(records)}"
return records[0]
def _sacct_job_json(job_id):
"""Return the entry the sacct data parser dumps for the job"""
atf.wait_for_job_accounted(job_id, "State", "COMPLETED", fatal=True)
output = atf.run_command_output(f"sacct -j {job_id} --json", fatal=True)
jobs = [job for job in json.loads(output)["jobs"] if job["job_id"] == job_id]
assert (
len(jobs) == 1
), f"sacct --json should dump one entry for job {job_id}, got {len(jobs)}"
return jobs[0]
def _tres_count(tres_string, tres_name):
"""Return the count of tres_name in a comma separated TRES string"""
for tres in tres_string.split(","):
name, _, count = tres.partition("=")
if name == tres_name:
return int(count)
pytest.fail(f"No {tres_name} TRES found in {tres_string!r}")
def _json_tres_count(tres_list, tres_name):
"""Return the count of tres_name in a list of TRES dumped by the parser"""
for tres in tres_list:
if tres["type"] == tres_name:
return tres["count"]
pytest.fail(f"No {tres_name} TRES found in {tres_list}")
@pytest.fixture(scope="module")
def billing_weights_partition(setup):
"""Create a partition that bills each allocated CPU more than once
Every job runs in it so that its billing TRES differs from its CPU count
and does not depend on the TRESBillingWeights of the default partition.
"""
atf.run_command(
f"scontrol create PartitionName={part_name} Nodes=ALL "
f"TRESBillingWeights=CPU={cpu_billing_weight}",
user=atf.properties["slurm-user"],
fatal=True,
)
yield part_name
atf.run_command(
f"scontrol delete PartitionName={part_name}",
user=atf.properties["slurm-user"],
quiet=True,
)
@pytest.fixture(scope="module")
def completed_job(account, billing_weights_partition):
"""Run a job and return it with the usage of the account before it ran"""
usage_before = _sshare_raw_usage(account)
job_id = _submit_and_wait(f"--account={account} -p {billing_weights_partition}")
alloc_tres = _sacct_job_fields(job_id, ["AllocTRES"])["AllocTRES"]
billing = _tres_count(alloc_tres, "billing")
cpus = _tres_count(alloc_tres, "cpu")
assert billing == cpu_billing_weight * cpus, (
f"TRESBillingWeights=CPU={cpu_billing_weight} should bill job {job_id} "
f"{cpu_billing_weight} per allocated CPU, got {alloc_tres}"
)
return job_id, usage_before
def test_billing_matches_sshare_raw_usage(account, completed_job):
"""Verify BillingRaw and the dumped billing_raw are the RawUsage sshare adds"""
job_id, usage_before = completed_job
billing_raw = int(_sacct_job_fields(job_id, ["BillingRaw"])["BillingRaw"])
dumped = _sacct_job_json(job_id)["billing_raw"]
assert (
billing_raw > 0
), f"sacct should report a non-zero BillingRaw for job {job_id}"
usage_after = _wait_for_raw_usage(account, usage_before, billing_raw)
added = usage_after - usage_before
assert added == billing_raw, (
f"The sacct BillingRaw of job {job_id} ({billing_raw}) should be "
f"the RawUsage sshare added for user {user_name} "
f"({usage_after} - {usage_before} = {added})"
)
assert added == dumped, (
f"The dumped billing_raw of job {job_id} ({dumped}) should be "
f"the RawUsage sshare added for user {user_name} "
f"({usage_after} - {usage_before} = {added})"
)
def test_billing_matches_billing_tres_and_elapsed(completed_job):
"""Verify BillingRaw is the billing TRES times the elapsed seconds"""
job_id, _ = completed_job
values = _sacct_job_fields(job_id, ["AllocTRES", "ElapsedRaw", "BillingRaw"])
expected = _tres_count(values["AllocTRES"], "billing") * int(values["ElapsedRaw"])
assert int(values["BillingRaw"]) == expected, (
f"The sacct BillingRaw of job {job_id} should be the billing TRES "
f"of {values['AllocTRES']} times {values['ElapsedRaw']} elapsed seconds "
f"({expected}), got {values['BillingRaw']}"
)
def test_billing_is_truncated_to_the_time_window(completed_job):
"""Verify -T narrows BillingRaw to the part of the job in the time window"""
job_id, _ = completed_job
full = _sacct_job_fields(job_id, ["Start", "ElapsedRaw", "BillingRaw"])
# Open the window half way through the run so that -T cuts off the first
# half. sacct prints and parses times in local time, so shift the Start it
# reports instead of building a time of our own.
window_start = datetime.fromisoformat(full["Start"]) + timedelta(
seconds=job_sleep // 2
)
truncated = _sacct_job_fields(
job_id,
["AllocTRES", "ElapsedRaw", "BillingRaw"],
f"-T -S {window_start.isoformat()}",
)
expected = _tres_count(truncated["AllocTRES"], "billing") * int(
truncated["ElapsedRaw"]
)
assert int(truncated["ElapsedRaw"]) == int(full["ElapsedRaw"]) - job_sleep // 2, (
f"-T -S {window_start.isoformat()} should cut the first "
f"{job_sleep // 2} of the {full['ElapsedRaw']} elapsed seconds of job "
f"{job_id}, got {truncated['ElapsedRaw']}"
)
assert int(truncated["BillingRaw"]) == expected, (
f"The truncated sacct BillingRaw of job {job_id} should be the billing "
f"TRES of {truncated['AllocTRES']} times its {truncated['ElapsedRaw']} "
f"truncated elapsed seconds ({expected}), got {truncated['BillingRaw']}"
)
assert int(truncated["BillingRaw"]) < int(full["BillingRaw"]), (
f"-T -S {window_start.isoformat()} should narrow the sacct BillingRaw "
f"of job {job_id} below its untruncated {full['BillingRaw']}, got "
f"{truncated['BillingRaw']}"
)
def test_json_billing_matches_billing_tres_and_elapsed(completed_job):
"""Verify the dumped billing_raw usage is its billing TRES times its elapsed"""
job_id, _ = completed_job
job = _sacct_job_json(job_id)
billing = _json_tres_count(job["tres"]["allocated"], "billing")
expected = billing * job["time"]["elapsed"]
assert job["billing_raw"] == expected, (
f"The billing_raw sacct --json dumps for job {job_id} should be "
f"its {billing} billing TRES times its {job['time']['elapsed']} elapsed "
f"seconds ({expected}), got {job['billing_raw']}"
)
def _assert_records_match_sshare_raw_usage(account, job_id, records, usage_before):
"""Assert the records of a job add up to the RawUsage sshare added"""
billing_raws = [int(record["BillingRaw"]) for record in records]
assert all(billing_raw > 0 for billing_raw in billing_raws), (
f"Every record of job {job_id} should have a non-zero BillingRaw, "
f"got {billing_raws}"
)
total = sum(billing_raws)
usage_after = _wait_for_raw_usage(account, usage_before, total)
added = " + ".join(str(billing_raw) for billing_raw in billing_raws)
assert usage_after - usage_before == total, (
f"The BillingRaw of the records of job {job_id} ({added} = {total}) "
f"should be the RawUsage sshare added for user {user_name} "
f"({usage_after} - {usage_before} = {usage_after - usage_before})"
)
@pytest.fixture(scope="module")
def requeued_job(account, billing_weights_partition):
"""Requeue a running job, returning the usage of the account before it"""
usage_before = _sshare_raw_usage(account)
job_id = _submit_and_run(
f"--account={account} --requeue -p {billing_weights_partition}"
)
atf.run_command(
f"scontrol requeue {job_id}",
user=atf.properties["slurm-user"],
fatal=True,
)
_wait_completed(job_id, runs=2)
return job_id, usage_before
def test_requeued_job_billing_matches_sshare_raw_usage(account, requeued_job):
"""Verify both runs of a requeued job add their billable usage to RawUsage"""
job_id, usage_before = requeued_job
records = _sacct_job_records(job_id, ["BillingRaw"])
assert len(records) == 2, (
f"sacct should report the run before and the run after the requeue of "
f"job {job_id}, got {len(records)} record(s)"
)
_assert_records_match_sshare_raw_usage(account, job_id, records, usage_before)