blob: e9766e50c55f5736f21576d6a02b282477e6df61 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test 115.2: Accounting storage rollup arithmetic correctness.
Seeds a synthetic cluster with deterministic rows directly in MySQL, triggers
sacctmgr rollup for an explicit time window, then verifies rollup output
through the documented sreport interface.
Data is pulled from the rollup table with the longest interval that can
satisfy the requested report period (sreport.1, DESCRIPTION), so the hour,
day and month rows are each checked by asking for the matching window.
Per-TRES values come from a single -T per report, which keeps one TRES per
row and needs no TresName column.
Skipped outside auto-config mode; the fixtures seed rows through the mysql
helper that conftest only provides there.
Run this only against a throwaway accounting DB. sacctmgr rollup takes no
cluster argument, so every rollup here recomputes and overwrites usage rows
for every cluster in the DB over the window it covers -- and a window whose
source jobs were already purged recomputes to empty.
"""
import re
from datetime import datetime, timedelta
import pytest
import atf
pytestmark = pytest.mark.slow
_CLUSTER = "test1152rollup"
_NO_VAL = 0xFFFFFFFE
_ACCOUNT = "test1152acct"
_ACCOUNT2 = "test1152acct2"
_USER = "root"
_WCKEY = "test1152wck"
_QOS = "test1152qos"
_RESV_ID = 1
# Reservation flags (slurm.h)
_RESV_FLAG_MAINT = 1 # SLURM_BIT(0)
_RESV_FLAG_IGN_JOBS = 64 # SLURM_BIT(6)
_RESV_IDLE_REASON = (
"Ticket 24919: reservation idle was charged the reservation's TRES count "
"instead of the job's before 26.11"
)
_ENERGY_REASON = (
"Ticket 24919: energy from a zero-elapsed row was charged to the previous "
"row's assoc/QOS/wckey before 26.11"
)
# Slurm job states (enum job_states)
_JOB_RUNNING = 1
_JOB_COMPLETE = 3
# Slurm node states (slurm.h). FUTURE is a NODE_STATE_BASE value, POWERED_DOWN
# a NODE_STATE_FLAGS bit; the rollup credits pdown_secs on either.
_NODE_STATE_FUTURE = 6
_NODE_STATE_POWERED_DOWN = 0x1000 # SLURM_BIT(12)
# Standard Slurm TRES IDs
_CPU = 1
_MEM = 2
_ENERGY = 3
_NODE = 4
_BILLING = 5
# Job allocation: 4 CPUs, 256 MB, 1 node, billing=4
_TRES_ALLOC = "1=4,2=256,4=1,5=4"
_CPU_COUNT = 4
_MEM_COUNT = 256
_NODE_COUNT = 1
_BILLING_COUNT = 4
# Cluster registration: 8 CPUs, 512 MB, 2 nodes, billing=8
_CLUSTER_TRES = "1=8,2=512,4=2,5=8"
_CLUSTER_CPU = 8
_CLUSTER_MEM = 512
# (tres_id, per-job count, label) for the standard job TRES.
_STD_TRES = [
(_CPU, _CPU_COUNT, "cpu"),
(_MEM, _MEM_COUNT, "mem"),
(_NODE, _NODE_COUNT, "node"),
(_BILLING, _BILLING_COUNT, "billing"),
]
_NONCPU_TRES = _STD_TRES[1:]
# Per-cluster tables the tests seed directly, plus every usage table the rollup
# writes. All of these have to be cleared between tests -- see the db fixture.
_RESET_TABLES = ["event_table", "job_table", "resv_table", "suspend_table"] + [
f"{obj}usage_{period}_table"
for period in ("hour", "day", "month")
for obj in ("", "assoc_", "qos_", "wckey_")
]
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_accounting(modify=True)
atf.require_config_parameter_includes("AccountingStorageEnforce", "associations")
atf.require_config_parameter("AllowNoDefAcct", None, source="slurmdbd")
atf.require_config_parameter("TrackWCKey", "yes", source="slurmdbd")
atf.require_config_parameter("PurgeJobAfter", None, source="slurmdbd")
atf.require_config_parameter("PurgeEventAfter", None, source="slurmdbd")
atf.require_config_parameter("PurgeUsageAfter", None, source="slurmdbd")
atf.require_config_parameter("PurgeResvAfter", None, source="slurmdbd")
atf.require_config_parameter("PurgeStepAfter", None, source="slurmdbd")
atf.require_config_parameter("PurgeSuspendAfter", None, source="slurmdbd")
atf.require_slurm_running()
@pytest.fixture(scope="module")
def qos_id(setup):
"""Create the test QOS and return its id.
QOS objects are not cluster-scoped, so this outlives the per-test cluster
the db fixture adds and removes.
"""
slurm_user = atf.properties["slurm-user"]
atf.run_command(f"sacctmgr -i add qos {_QOS}", user=slurm_user, fatal=True)
out = atf.run_command_output(
f"sacctmgr -nP list qos names={_QOS} format=id",
user=slurm_user,
fatal=True,
)
if not out.strip():
pytest.fail(f"no qos {_QOS} after adding it")
yield int(out.strip().splitlines()[0].rstrip("|"))
atf.run_command(f"sacctmgr -i remove qos {_QOS}", user=slurm_user, fatal=True)
@pytest.fixture(scope="function")
def db(sql_statement_repeat):
"""Create cluster with test account/user/wckey; yield context; teardown."""
if not sql_statement_repeat:
pytest.skip(
"Ticket 24919: needs the conftest mysql helper, which requires "
"auto-config"
)
slurm_user = atf.properties["slurm-user"]
# Truncate before the remove. sacctmgr remove cluster marks the cluster
# deleted but leaves the per-cluster tables, and re-adding reuses the same
# assoc and wckey ids, so stale usage rows would sit at exactly the
# (id, id_tres, time_start) keys the next test reads. Running rows
# (time_end=0, state=JOB_RUNNING) left by an interrupted run also make the
# remove refuse. The tables do not exist on a fresh database, so this is
# best effort.
for _t in _RESET_TABLES:
_sql(
sql_statement_repeat,
f"TRUNCATE TABLE {_CLUSTER}_{_t}",
fatal=False,
quiet=True,
)
atf.run_command(
f"sacctmgr -i remove cluster {_CLUSTER}",
user=slurm_user,
)
atf.run_command(
f"sacctmgr -i add cluster {_CLUSTER}",
user=slurm_user,
fatal=True,
)
atf.run_command(
f"sacctmgr -i add account {_ACCOUNT} cluster={_CLUSTER}",
user=slurm_user,
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {_USER} account={_ACCOUNT} "
f"cluster={_CLUSTER} wckey={_WCKEY}",
user=slurm_user,
fatal=True,
)
assoc_id = _assoc_id_of(slurm_user, _ACCOUNT)
out = atf.run_command_output(
f"sacctmgr -nP list wckeys users={_USER} wckeys={_WCKEY} "
f"cluster={_CLUSTER} format=id",
user=slurm_user,
fatal=True,
)
if not out.strip():
pytest.fail(f"no wckey {_WCKEY} for {_USER} on {_CLUSTER}; is TrackWCKey on?")
wckey_id = int(out.strip().splitlines()[0].rstrip("|"))
# Use 2 days ago at midnight so synthetic data doesn't collide with
# any real rollup pass currently in progress.
ws = None
for _days in (2, 3, 4):
ws = _local_midnight(datetime.now() - timedelta(days=_days))
if ws:
break
if not ws:
pytest.fail("no usable local midnight in the last 4 days")
yield sql_statement_repeat, ws, assoc_id, wckey_id
# Running rows (time_end=0, state=JOB_RUNNING) count as active jobs and
# make sacctmgr refuse to remove the cluster, so drop them first.
_sql(sql_statement_repeat, f"TRUNCATE TABLE {_CLUSTER}_job_table")
# 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 {_CLUSTER}",
user=slurm_user,
timeout=120,
fatal=True,
)
# Accounts are not cluster-scoped, so removing the cluster leaves them
# behind in acct_table.
atf.run_command(
f"sacctmgr -i remove account {_ACCOUNT}",
user=slurm_user,
fatal=True,
)
# Only the second_assoc fixture adds this one, so it is absent for most
# tests and the remove is expected to fail there.
atf.run_command(
f"sacctmgr -i remove account {_ACCOUNT2}",
user=slurm_user,
)
@pytest.fixture(scope="function")
def second_assoc(db):
"""Add a second account/association on the test cluster; return its id."""
slurm_user = atf.properties["slurm-user"]
atf.run_command(
f"sacctmgr -i add account {_ACCOUNT2} cluster={_CLUSTER}",
user=slurm_user,
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {_USER} account={_ACCOUNT2} cluster={_CLUSTER}",
user=slurm_user,
fatal=True,
)
return _assoc_id_of(slurm_user, _ACCOUNT2)
def _assoc_id_of(slurm_user, account):
"""Return the association id for account/{_USER} on the test cluster."""
out = atf.run_command_output(
f"sacctmgr -nP list assoc cluster={_CLUSTER} account={account} "
f"user={_USER} format=id",
user=slurm_user,
fatal=True,
)
return int(out.strip().splitlines()[0].rstrip("|"))
def _account_assoc_id_of(slurm_user, account):
"""Return the account-level (no user) association id on the test cluster."""
out = atf.run_command_output(
f"sacctmgr -nP list assoc cluster={_CLUSTER} account={account} "
f"format=id,user",
user=slurm_user,
fatal=True,
)
for line in out.strip().splitlines():
assoc_id, _, user = line.partition("|")
if not user.strip("|"):
return int(assoc_id)
pytest.fail(f"no account-level assoc for {account} on {_CLUSTER}:\n{out}")
# ---------------------------------------------------------------------------
# SQL helpers
# ---------------------------------------------------------------------------
def _sql(mysql_cmd, stmt, fatal=True, quiet=False):
atf.run_command(
f'{mysql_cmd} -e "{stmt.replace("`", r"\`")}"',
user=atf.properties["slurm-user"],
fatal=fatal,
quiet=quiet,
)
def _local_midnight(dt):
"""Epoch of local midnight on dt's date, or None if it does not exist.
A few zones shift 00:00 -> 01:00. Python's fold rule and the plugin's
slurm_mktime(tm_isdst=-1) can resolve that missing hour to different
instants, so callers pick another date rather than key off it.
"""
ws = int(dt.replace(hour=0, minute=0, second=0, microsecond=0).timestamp())
return ws if datetime.fromtimestamp(ws).hour == 0 else None
def _day_start(ws, days=0):
"""Epoch of the local midnight `days` days after the local midnight at ws.
The day and month rollups walk the calendar rather than adding 86400
(as_mysql_rollup.c as_mysql_nonhour_rollup), so day rows are keyed on
local midnights. Adding 86400 drifts by an hour across a daylight
saving transition, which both misses those keys and can leave a
"one day" window inside a single 25 hour local day.
"""
return int((datetime.fromtimestamp(ws) + timedelta(days=days)).timestamp())
def _rollup(ws, we):
start = datetime.fromtimestamp(ws).strftime("%Y-%m-%dT%H:%M:%S")
end = datetime.fromtimestamp(we).strftime("%Y-%m-%dT%H:%M:%S")
# A rollup over a multi-day/month window can run long on a loaded DB, so
# allow more than the default command timeout.
atf.run_command(
f"sacctmgr -i rollup {start} {end}",
user=atf.properties["slurm-user"],
fatal=True,
timeout=120,
)
def _add_cluster_event(mysql_cmd, ws, tres=_CLUSTER_TRES):
"""Insert a cluster registration (empty node_name) event."""
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_event_table` "
f"(node_name, state, tres, time_start, time_end, reason, reason_uid) "
f"VALUES ('', 0, '{tres}', {ws}, 0, 'Cluster registration', 0)",
)
def _add_node_event(mysql_cmd, node_name, state, ws, we, tres):
"""Insert a node-state event (down, powered-down, etc.)."""
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_event_table` "
f"(node_name, state, tres, time_start, time_end, reason, reason_uid) "
f"VALUES ('{node_name}', {state}, '{tres}', {ws}, {we}, 'down', 0)",
)
def _add_job(
mysql_cmd,
*,
assoc_id,
time_start,
time_end,
time_eligible,
db_inx=1,
qos_id=0,
wckey_id=0,
resv_id=0,
tres_alloc=_TRES_ALLOC,
cpus_req=_CPU_COUNT,
state=_JOB_COMPLETE,
time_suspended=0,
array_task_pending=0,
):
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_job_table` "
f"(job_db_inx, id_assoc, id_qos, id_wckey, id_resv, id_job, "
f"id_user, id_group, het_job_id, het_job_offset, state_reason_prev, "
f"nodes_alloc, `partition`, priority, state, "
f"time_start, time_end, time_eligible, time_submit, time_suspended, "
f"cpus_req, array_task_pending, tres_alloc, tres_req, "
f"job_name, nodelist, node_inx, account, env_hash_inx, script_hash_inx) "
f"VALUES ({db_inx}, {assoc_id}, {qos_id}, {wckey_id}, {resv_id}, "
f"{db_inx}, 0, 0, 0, {_NO_VAL}, 0, "
f"1, 'part1', 0, {state}, "
f"{time_start}, {time_end}, {time_eligible}, {time_eligible - 1}, "
f"{time_suspended}, {cpus_req}, {array_task_pending}, "
f"'{tres_alloc}', '{tres_alloc}', "
f"'test1152', 'n1', 0, 'root', 0, 0)",
)
# ---------------------------------------------------------------------------
# sreport helpers - each runs one documented report and returns the value it
# published for the requested TRES.
# ---------------------------------------------------------------------------
def _sreport(ws, report, fmt, we=None, tres=None):
"""Run one sreport cluster report over [ws, we), defaulting to one hour.
A single -T keeps one TRES per report, so every row the caller matches
belongs to that TRES and no TresName column is needed. The window also
picks the usage table slurmdbd reads (sreport.1, DESCRIPTION): hour rows
off a day boundary, day rows for a day-aligned window, month rows for a
window that runs 1st-of-month to 1st-of-month.
"""
start = datetime.fromtimestamp(ws).strftime("%Y-%m-%dT%H:%M:%S")
end = datetime.fromtimestamp(we if we else ws + 3600).strftime("%Y-%m-%dT%H:%M:%S")
cmd = (
f"sreport --local cluster {report} cluster={_CLUSTER} "
f"start={start} end={end} -tSeconds -P -n format={fmt}"
)
if tres:
cmd += f" -T {tres}"
return atf.run_command_output(
cmd,
user=atf.properties["slurm-user"],
fatal=True,
)
def _sreport_value(ws, report, fmt, row_regex, we=None, tres=None, default=None):
"""Run an sreport cluster report and return the int captured by row_regex.
A missing row fails the test unless default is given. sreport omits an
object entirely when it has no usage, which is the expected result where a
test asserts that nothing was credited.
"""
out = _sreport(ws, report, fmt, we=we, tres=tres)
m = re.search(row_regex, out)
if not m:
if default is not None:
return default
pytest.fail(f"no row matching {row_regex} in {report} output:\n{out}")
return int(m.group(1))
def _sreport_account_used(
ws, account=_ACCOUNT, user=_USER, tres=None, we=None, default=None
):
"""alloc_secs for account/user via AccountUtilizationByUser."""
return _sreport_value(
ws,
"AccountUtilizationByUser",
"cluster,account,login,used",
rf"{_CLUSTER}\|{account}\|{user}\|(\d+)",
we=we,
tres=tres,
default=default,
)
def _sreport_qos_used(ws, qos, account=_ACCOUNT, tres=None, we=None, default=None):
"""alloc_secs for account/qos via AccountUtilizationByQOS."""
return _sreport_value(
ws,
"AccountUtilizationByQOS",
"cluster,account,qos,used",
rf"{_CLUSTER}\|{account}\|{qos}\|(\d+)",
we=we,
tres=tres,
default=default,
)
def _sreport_wckey_used(ws, user=_USER, wckey=_WCKEY, tres=None, we=None, default=None):
"""alloc_secs for user/wckey via UserUtilizationByWckey."""
return _sreport_value(
ws,
"UserUtilizationByWckey",
"cluster,login,wckey,used",
rf"{_CLUSTER}\|{user}\|{wckey}\|(\d+)",
we=we,
tres=tres,
default=default,
)
def _sreport_cluster_util(ws, field, tres=None, we=None):
"""Value of a cluster-utilization field (alloc, down, PlannedDown...).
NOTE: 'reserved' is an undocumented alias for 'planned' in sreport, not a
reservation figure; both print plan_secs.
"""
return _sreport_value(
ws,
"utilization",
f"cluster,{field}",
rf"{_CLUSTER}\|(\d+)",
we=we,
tres=tres,
)
def _sreport_resv_util(ws, tres="cpu", we=None, resv_name=None):
"""(Allocated, Idle) TRES-seconds from the reservation's own report.
This is not a cluster report, so it takes the cluster as a keyword of its
own; --local resolves to the local cluster and returns nothing here.
"""
start = datetime.fromtimestamp(ws).strftime("%Y-%m-%dT%H:%M:%S")
end = datetime.fromtimestamp(we if we else ws + 3600).strftime("%Y-%m-%dT%H:%M:%S")
out = atf.run_command_output(
f"sreport reservation utilization cluster={_CLUSTER} "
f"start={start} end={end} -tSeconds -P -n "
f"format=Name,Allocated,Idle -T {tres}",
user=atf.properties["slurm-user"],
fatal=True,
)
name = resv_name if resv_name else f"resv{_RESV_ID}"
m = re.search(rf"{name}\|(\d+)\|(\d+)", out)
if not m:
pytest.fail(f"no {name} row in reservation utilization output:\n{out}")
return int(m.group(1)), int(m.group(2))
# CPU alloc_secs readers for the standard test objects, so a test that seeds
# one window can check every per-object usage table the rollup writes.
_OBJECT_READERS = {
"assoc": lambda ws, we: _sreport_account_used(ws, we=we),
"qos": lambda ws, we: _sreport_qos_used(ws, _QOS, we=we),
"wckey": lambda ws, we: _sreport_wckey_used(ws, we=we),
}
def _assert_cluster_reconciles(ws, count, label="cpu", window=3600):
"""count * window must equal alloc + idle + planned + down + pdown.
OverCommitted is not a share of the window. sreport.1 defines it as the
demand past the Reported time, which clamping has already taken back out
of planned, so summing it here would count that excess twice.
"""
parts = {
f: _sreport_cluster_util(ws, f, tres=label)
for f in ("alloc", "idle", "planned", "down", "PlannedDown")
}
total = sum(parts.values())
assert total == count * window, (
f"cluster {label} utilization does not reconcile: {parts} sums to "
f"{total}, expected {count * window}"
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_assoc_alloc_secs(db):
"""alloc_secs must accumulate across multiple jobs for all standard TRES."""
mysql_cmd, ws, assoc_id, _ = db
elapsed1, elapsed2 = 1200, 1500
_add_cluster_event(mysql_cmd, ws)
_add_job(
mysql_cmd,
db_inx=1,
assoc_id=assoc_id,
time_start=ws + 100,
time_end=ws + 100 + elapsed1,
time_eligible=ws,
)
_add_job(
mysql_cmd,
db_inx=2,
assoc_id=assoc_id,
time_start=ws + 200,
time_end=ws + 200 + elapsed2,
time_eligible=ws,
)
_rollup(ws, ws + 3600)
total = elapsed1 + elapsed2
for _tres_id, count, label in _STD_TRES:
used = _sreport_account_used(ws, tres=label)
assert (
used == count * total
), f"assoc {label} alloc={used}, expected {count * total}"
def test_qos_alloc_secs(db, qos_id):
"""QOS rollup must accumulate alloc_secs across multiple jobs."""
mysql_cmd, ws, assoc_id, _ = db
elapsed1, elapsed2 = 1200, 1500
_add_cluster_event(mysql_cmd, ws)
_add_job(
mysql_cmd,
db_inx=1,
assoc_id=assoc_id,
qos_id=qos_id,
time_start=ws + 100,
time_end=ws + 100 + elapsed1,
time_eligible=ws,
)
_add_job(
mysql_cmd,
db_inx=2,
assoc_id=assoc_id,
qos_id=qos_id,
time_start=ws + 200,
time_end=ws + 200 + elapsed2,
time_eligible=ws,
)
_rollup(ws, ws + 3600)
total = elapsed1 + elapsed2
for _tres_id, count, label in _STD_TRES:
used = _sreport_qos_used(ws, _QOS, tres=label)
assert (
used == count * total
), f"qos {label} alloc={used}, expected {count * total}"
def test_wckey_alloc_secs(db):
"""WCKey rollup must accumulate alloc_secs across multiple jobs."""
mysql_cmd, ws, assoc_id, wckey_id = db
elapsed1, elapsed2 = 1200, 1500
_add_cluster_event(mysql_cmd, ws)
_add_job(
mysql_cmd,
db_inx=1,
assoc_id=assoc_id,
wckey_id=wckey_id,
time_start=ws + 100,
time_end=ws + 100 + elapsed1,
time_eligible=ws,
)
_add_job(
mysql_cmd,
db_inx=2,
assoc_id=assoc_id,
wckey_id=wckey_id,
time_start=ws + 200,
time_end=ws + 200 + elapsed2,
time_eligible=ws,
)
_rollup(ws, ws + 3600)
total = elapsed1 + elapsed2
for _tres_id, count, label in _STD_TRES:
used = _sreport_wckey_used(ws, tres=label)
assert (
used == count * total
), f"wckey {label} alloc={used}, expected {count * total}"
def test_suspend_cpu_deduction(db):
"""CPU alloc_secs must deduct suspend time; memory must not.
One suspended job and one unsuspended job share the same assoc to
verify accumulation is correct for both."""
mysql_cmd, ws, assoc_id, _ = db
elapsed = 1800
suspend_start = ws + 300
suspend_secs = 300
_add_cluster_event(mysql_cmd, ws)
# Job 1: suspended.
_add_job(
mysql_cmd,
db_inx=1,
assoc_id=assoc_id,
time_start=ws + 100,
time_end=ws + 100 + elapsed,
time_eligible=ws,
time_suspended=1,
)
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_suspend_table` "
f"(job_db_inx, id_assoc, time_start, time_end) "
f"VALUES (1, {assoc_id}, {suspend_start}, {suspend_start + suspend_secs})",
)
# Job 2: not suspended.
_add_job(
mysql_cmd,
db_inx=2,
assoc_id=assoc_id,
time_start=ws + 200,
time_end=ws + 200 + elapsed,
time_eligible=ws,
)
_rollup(ws, ws + 3600)
cpu = _sreport_account_used(ws, tres="cpu")
mem = _sreport_account_used(ws, tres="mem")
expected_cpu = _CPU_COUNT * (elapsed - suspend_secs) + _CPU_COUNT * elapsed
expected_mem = _MEM_COUNT * elapsed * 2
assert cpu == expected_cpu, (
f"CPU alloc_secs={cpu}, expected {expected_cpu} "
f"(suspended job deducts {suspend_secs}s, unsuspended job does not)"
)
assert (
mem == expected_mem
), f"Mem alloc_secs={mem}, expected {expected_mem} (suspend must not affect mem)"
def test_cluster_alloc_secs(db):
"""Cluster time_alloc must equal the sum of all job TRES-seconds."""
mysql_cmd, ws, assoc_id, _ = db
elapsed1, elapsed2 = 1200, 1500
_add_cluster_event(mysql_cmd, ws)
_add_job(
mysql_cmd,
db_inx=1,
assoc_id=assoc_id,
time_start=ws + 100,
time_end=ws + 100 + elapsed1,
time_eligible=ws,
)
_add_job(
mysql_cmd,
db_inx=2,
assoc_id=assoc_id,
time_start=ws + 200,
time_end=ws + 200 + elapsed2,
time_eligible=ws,
)
_rollup(ws, ws + 3600)
total = elapsed1 + elapsed2
# TRES node usage is not reported in Cluster Utilization (sreport.1), so
# mem is the only non-CPU TRES tracked here.
for count, label in ((_CPU_COUNT, "cpu"), (_MEM_COUNT, "mem")):
used = _sreport_cluster_util(ws, "alloc", tres=label)
assert (
used == count * total
), f"cluster {label} alloc={used}, expected {count * total}"
def test_cluster_down_secs(db):
"""Node-down events must accumulate into cluster down_secs per TRES."""
mysql_cmd, ws, _, _ = db
down_start = ws + 2000
down_secs = 1000
node_tres = "1=4,2=256,4=1"
_add_cluster_event(mysql_cmd, ws)
# NODE_STATE_DOWN = 1
_add_node_event(mysql_cmd, "n1", 1, down_start, down_start + down_secs, node_tres)
_rollup(ws, ws + 3600)
for count, label in ((4, "cpu"), (256, "mem")):
down = _sreport_cluster_util(ws, "down", tres=label)
assert (
down == count * down_secs
), f"cluster {label} down={down}, expected {count * down_secs}"
@pytest.mark.parametrize(
"state",
[
pytest.param(_NODE_STATE_POWERED_DOWN, id="powered_down"),
pytest.param(_NODE_STATE_FUTURE, id="future"),
],
)
def test_cluster_pdown_secs(db, state):
"""Powered-down and future node events must accumulate into cluster
pdown_secs per TRES."""
mysql_cmd, ws, _, _ = db
pdown_start = ws + 2000
pdown_secs = 1000
node_tres = "1=4,2=256,4=1"
_add_cluster_event(mysql_cmd, ws)
# The rollup credits pdown_secs on both of these; NODE_STATE_POWER_DOWN
# (bit 23) is a separate transition-request flag and rolls up as down_secs
# instead.
_add_node_event(
mysql_cmd, "n1", state, pdown_start, pdown_start + pdown_secs, node_tres
)
_rollup(ws, ws + 3600)
for count, label in ((4, "cpu"), (256, "mem")):
pdown = _sreport_cluster_util(ws, "PlannedDown", tres=label)
assert (
pdown == count * pdown_secs
), f"cluster {label} pdown={pdown}, expected {count * pdown_secs}"
def test_cluster_down_secs_hour_boundary(db):
"""A node-down event spanning an hour boundary must be split across both
hour rows, each clamped to its own window."""
mysql_cmd, ws, _, _ = db
node_tres = "1=4,2=256,4=1"
# Down from 45 min into hour 1 to 15 min into hour 2: 900s in each hour.
_add_cluster_event(mysql_cmd, ws)
# NODE_STATE_DOWN = 1
_add_node_event(mysql_cmd, "n1", 1, ws + 2700, ws + 4500, node_tres)
_rollup(ws, ws + 7200)
for hour_ws in (ws, ws + 3600):
for count, label in ((4, "cpu"), (256, "mem")):
down = _sreport_cluster_util(hour_ws, "down", tres=label)
assert (
down == count * 900
), f"hour {hour_ws} {label} down={down}, expected {count * 900}"
def test_cluster_pdown_secs_hour_boundary(db):
"""A powered-down event spanning an hour boundary must be split across both
hour rows, each clamped to its own window."""
mysql_cmd, ws, _, _ = db
node_tres = "1=4,2=256,4=1"
# Powered down from 45 min into hour 1 to 15 min into hour 2: 900s each.
_add_cluster_event(mysql_cmd, ws)
_add_node_event(
mysql_cmd, "n1", _NODE_STATE_POWERED_DOWN, ws + 2700, ws + 4500, node_tres
)
_rollup(ws, ws + 7200)
for hour_ws in (ws, ws + 3600):
for count, label in ((4, "cpu"), (256, "mem")):
pdown = _sreport_cluster_util(hour_ws, "PlannedDown", tres=label)
assert (
pdown == count * 900
), f"hour {hour_ws} {label} pdown={pdown}, expected {count * 900}"
def test_cluster_plan_secs(db):
"""plan_secs must accumulate cpus_req * (time_start - time_eligible) across jobs."""
mysql_cmd, ws, assoc_id, _ = db
_add_cluster_event(mysql_cmd, ws)
# Job 1: waited 100s before starting.
_add_job(
mysql_cmd,
db_inx=1,
assoc_id=assoc_id,
time_start=ws + 150,
time_end=ws + 150 + 1800,
time_eligible=ws + 50,
)
# Job 2: waited 300s before starting.
_add_job(
mysql_cmd,
db_inx=2,
assoc_id=assoc_id,
time_start=ws + 400,
time_end=ws + 400 + 1800,
time_eligible=ws + 100,
)
_rollup(ws, ws + 3600)
expected = _CPU_COUNT * 100 + _CPU_COUNT * 300
actual = _sreport_cluster_util(ws, "planned", tres="cpu")
assert actual == expected, f"cluster planned={actual}, expected {expected}"
def test_cluster_overcommitted(db):
"""Planned demand past the window must clamp, leaving the excess in over.
sreport.1: if Allocated plus Planned time exceeds the Reported time, the
excess is reported as OverCommitted.
"""
mysql_cmd, ws, assoc_id, _ = db
wait, elapsed = 3500, 100
_add_cluster_event(mysql_cmd, ws)
# Each job asks for the whole cluster after waiting nearly the full hour,
# so plan demand alone is several times the window.
for db_inx in (1, 2):
_add_job(
mysql_cmd,
db_inx=db_inx,
assoc_id=assoc_id,
time_start=ws + wait,
time_end=ws + wait + elapsed,
time_eligible=ws,
cpus_req=_CLUSTER_CPU,
)
_rollup(ws, ws + 3600)
reported = _CLUSTER_CPU * 3600
demand = 2 * _CLUSTER_CPU * wait
expected_alloc = 2 * _CPU_COUNT * elapsed
alloc = _sreport_cluster_util(ws, "alloc", tres="cpu")
assert (
alloc == expected_alloc
), f"cluster cpu alloc={alloc}, expected {expected_alloc}"
planned = _sreport_cluster_util(ws, "planned", tres="cpu")
assert planned == reported - alloc, (
f"cluster cpu planned={planned}, expected {reported - alloc}: "
f"{demand} of demand must clamp to what the window has left"
)
over = _sreport_cluster_util(ws, "OverCommitted", tres="cpu")
assert (
over == demand - planned
), f"cluster cpu over={over}, expected {demand - planned}"
idle = _sreport_cluster_util(ws, "idle", tres="cpu")
assert idle == 0, f"cluster cpu idle={idle}, expected 0"
_assert_cluster_reconciles(ws, _CLUSTER_CPU)
def test_cluster_utilization_invariant(db):
"""Cluster utilization fields must satisfy the identity across multiple jobs:
count * window == alloc + idle + plan (when down/pdown/over are 0)."""
mysql_cmd, ws, assoc_id, _ = db
elapsed1, elapsed2 = 1200, 1500
_add_cluster_event(mysql_cmd, ws)
_add_job(
mysql_cmd,
db_inx=1,
assoc_id=assoc_id,
time_start=ws + 100,
time_end=ws + 100 + elapsed1,
time_eligible=ws,
)
_add_job(
mysql_cmd,
db_inx=2,
assoc_id=assoc_id,
time_start=ws + 200,
time_end=ws + 200 + elapsed2,
time_eligible=ws,
)
_rollup(ws, ws + 3600)
total = elapsed1 + elapsed2
# plan_secs = cpu_count * (time_start - time_eligible) per job; CPU only.
expected_plan_by_tres = {
_CPU: _CPU_COUNT * 100 + _CPU_COUNT * 200,
_MEM: 0,
}
for tres_id, cluster_count, job_count, label in [
(_CPU, _CLUSTER_CPU, _CPU_COUNT, "cpu"),
(_MEM, _CLUSTER_MEM, _MEM_COUNT, "mem"),
]:
count = _sreport_cluster_util(ws, "TresCount", tres=label)
alloc = _sreport_cluster_util(ws, "alloc", tres=label)
idle = _sreport_cluster_util(ws, "idle", tres=label)
over = _sreport_cluster_util(ws, "OverCommitted", tres=label)
down = _sreport_cluster_util(ws, "down", tres=label)
pdown = _sreport_cluster_util(ws, "PlannedDown", tres=label)
plan = _sreport_cluster_util(ws, "planned", tres=label)
expected_alloc = job_count * total
expected_plan = expected_plan_by_tres[tres_id]
expected_idle = cluster_count * 3600 - expected_alloc - expected_plan
assert (
count == cluster_count
), f"cluster {label} count={count}, expected {cluster_count}"
assert over == 0, f"cluster {label} over_secs={over}, expected 0"
assert down == 0, f"cluster {label} down_secs={down}, expected 0"
assert pdown == 0, f"cluster {label} pdown_secs={pdown}, expected 0"
assert (
plan == expected_plan
), f"cluster {label} plan_secs={plan}, expected {expected_plan}"
assert (
alloc == expected_alloc
), f"cluster {label} alloc_secs={alloc}, expected {expected_alloc}"
assert (
idle == expected_idle
), f"cluster {label} idle_secs={idle}, expected {expected_idle}"
@pytest.mark.skipif(
atf.get_version("sbin/slurmdbd") < (26, 11), reason=_RESV_IDLE_REASON
)
def test_reservation_idle_redistribution(db):
"""Idle reservation TRES-seconds must be credited to eligible assocs.
The reservation holds two TRES and the job takes a different fraction of
each, so an implementation that credits the first and stops is
distinguishable from one that credits every TRES the reservation holds.
"""
mysql_cmd, ws, assoc_id, _ = db
job_elapsed = 1800
resv_cpu, resv_mem = 8, 256
_add_cluster_event(mysql_cmd, ws)
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_resv_table` "
f"(id_resv, assoclist, nodelist, resv_name, tres, "
f"time_start, time_end, flags, unused_wall) "
f"VALUES ({_RESV_ID}, '{assoc_id}', 'n1', 'resv{_RESV_ID}', "
f"'1={resv_cpu},2={resv_mem}', {ws}, {ws + 3600}, 0, 0.0)",
)
# Use fewer TRES than the reservation holds, a quarter of the CPUs and an
# eighth of the memory. At exactly half, crediting the job's own TRES
# seconds and crediting the reservation's full width give the same total,
# so the assertion would not tell them apart; differing fractions also
# catch a fix that reuses the first TRES's proportion for the rest.
job_cpu, job_mem = 2, 32
_add_job(
mysql_cmd,
assoc_id=assoc_id,
resv_id=_RESV_ID,
time_start=ws + 100,
time_end=ws + 100 + job_elapsed,
time_eligible=ws,
tres_alloc=f"1={job_cpu},2={job_mem}",
cpus_req=job_cpu,
)
_rollup(ws, ws + 3600)
# The reservation is charged what the job actually used, so the idle it
# redistributes is everything else. With one eligible assoc that assoc
# ends up with the whole reservation: its own job usage plus that idle.
for resv_count, job_count, label in (
(resv_cpu, job_cpu, "cpu"),
(resv_mem, job_mem, "mem"),
):
job_secs = job_count * job_elapsed
resv_idle_secs = resv_count * 3600 - job_secs
expected = job_secs + resv_idle_secs
used = _sreport_account_used(ws, tres=label)
assert used == expected, (
f"assoc {label} alloc={used}, expected {expected} "
f"(job={job_secs} + resv_idle={resv_idle_secs})"
)
# The reservation's own report has to agree with what it handed out: it is
# charged the job's TRES seconds, and the rest is the idle it redistributed.
for resv_count, job_count, label in (
(resv_cpu, job_cpu, "cpu"),
(resv_mem, job_mem, "mem"),
):
job_secs = job_count * job_elapsed
alloc, idle = _sreport_resv_util(ws, tres=label)
assert alloc == job_secs, f"resv {label} Allocated={alloc}, expected {job_secs}"
assert idle == resv_count * 3600 - job_secs, (
f"resv {label} Idle={idle}, expected " f"{resv_count * 3600 - job_secs}"
)
# Reservation time still has to land somewhere in the cluster row.
_assert_cluster_reconciles(ws, _CLUSTER_CPU)
_assert_cluster_reconciles(ws, _CLUSTER_MEM, "mem")
@pytest.mark.parametrize(
"flags,in_alloc,in_pdown",
[
pytest.param(0, True, False, id="normal"),
pytest.param(_RESV_FLAG_MAINT, False, True, id="maint"),
pytest.param(_RESV_FLAG_IGN_JOBS, False, False, id="ignore_jobs"),
pytest.param(
_RESV_FLAG_MAINT | _RESV_FLAG_IGN_JOBS, False, False, id="maint_ignore_jobs"
),
],
)
def test_reservation_time_in_cluster_usage(db, flags, in_alloc, in_pdown):
"""A reservation's TRES time must reach the cluster row even with no jobs.
Nodes held by a reservation are unavailable to everyone else, so the
reservation is charged to the cluster as allocated time, or as planned
down time when it is a maintenance reservation. A reservation carrying
IGNORE_JOBS is not tracked in the cluster utilization report at all, so
its time stays idle and must reach neither column.
"""
mysql_cmd, ws, assoc_id, _ = db
resv_cpu = 4
_add_cluster_event(mysql_cmd, ws)
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_resv_table` "
f"(id_resv, assoclist, nodelist, resv_name, tres, "
f"time_start, time_end, flags, unused_wall) "
f"VALUES ({_RESV_ID}, '{assoc_id}', 'n1', 'resv{_RESV_ID}', "
f"'1={resv_cpu}', {ws}, {ws + 3600}, {flags}, 0.0)",
)
_rollup(ws, ws + 3600)
# No job ran, so the whole reservation is the only thing that can put time
# in either column.
resv_secs = resv_cpu * 3600
for field, expected in (
("alloc", resv_secs if in_alloc else 0),
("PlannedDown", resv_secs if in_pdown else 0),
):
used = _sreport_cluster_util(ws, field, tres="cpu")
assert used == expected, f"cluster {field}={used}, expected {expected}"
_assert_cluster_reconciles(ws, _CLUSTER_CPU)
@pytest.mark.skipif(
atf.get_version("sbin/slurmdbd") < (26, 11), reason=_RESV_IDLE_REASON
)
def test_reservation_idle_whole_account(db):
"""A reservation held by a whole account credits the account association.
Idle time from a reservation assigned to accounts rather than users is
counted in the account's own association, so a parent account's usage can
exceed the sum of its children.
"""
mysql_cmd, ws, _, _ = db
slurm_user = atf.properties["slurm-user"]
acct_assoc_id = _account_assoc_id_of(slurm_user, _ACCOUNT)
resv_cpu = 8
_add_cluster_event(mysql_cmd, ws)
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_resv_table` "
f"(id_resv, assoclist, nodelist, resv_name, tres, "
f"time_start, time_end, flags, unused_wall) "
f"VALUES ({_RESV_ID}, '{acct_assoc_id}', 'n1', 'resv{_RESV_ID}', "
f"'1={resv_cpu}', {ws}, {ws + 3600}, 0, 0.0)",
)
_rollup(ws, ws + 3600)
# No job ran, so the whole reservation is idle and the single eligible
# association takes all of it.
expected = resv_cpu * 3600
account_used = _sreport_account_used(ws, account=_ACCOUNT, user="")
assert (
account_used == expected
), f"account assoc cpu alloc={account_used}, expected {expected}"
user_used = _sreport_account_used(ws, account=_ACCOUNT, default=0)
assert user_used == 0, (
f"user assoc cpu alloc={user_used}, expected 0; whole-account "
f"reservation idle must not be credited to the user associations"
)
_assert_cluster_reconciles(ws, _CLUSTER_CPU)
def test_daily_rollup_aggregation(db, qos_id):
"""Day rows must equal the sum of the contributing hour rows.
Checked for every object type the rollup writes, so a mistake that only
affects one of them does not escape.
"""
mysql_cmd, ws, assoc_id, wckey_id = db
elapsed = 1800
_add_cluster_event(mysql_cmd, ws)
for h in range(3):
hour_ws = ws + h * 3600
_add_job(
mysql_cmd,
db_inx=h + 1,
assoc_id=assoc_id,
qos_id=qos_id,
wckey_id=wckey_id,
time_start=hour_ws + 100,
time_end=hour_ws + 100 + elapsed,
time_eligible=hour_ws,
)
# Window must cross midnight to trigger the daily rollup pass.
day_end = _day_start(ws, 1)
_rollup(ws, day_end)
# An hour-aligned window under a day reads the hour rows; the day-aligned
# window reads the day row.
expected = _CPU_COUNT * elapsed * 3
for obj, read in _OBJECT_READERS.items():
hour_total = sum(read(ws + h * 3600, ws + (h + 1) * 3600) for h in range(3))
day_total = read(ws, day_end)
assert (
hour_total == expected
), f"{obj} hourly sum={hour_total}, expected {expected}"
assert (
day_total == expected
), f"{obj} day alloc={day_total}, expected {expected}"
def test_monthly_rollup_aggregation(db, qos_id):
"""Month rows must equal the sum of the contributing day rows.
Checked for every object type the rollup writes, so a mistake that only
affects one of them does not escape.
"""
mysql_cmd, _, assoc_id, wckey_id = db
elapsed = 1800
# First of the month two months ago gives a complete, past month boundary.
month_ws = None
for _days in (60, 90, 120):
month_ws = _local_midnight(
(datetime.now() - timedelta(days=_days)).replace(day=1)
)
if month_ws:
break
if not month_ws:
pytest.fail("no usable month start in the last 4 months")
_add_cluster_event(mysql_cmd, month_ws)
for d in range(3):
day_ws = _day_start(month_ws, d)
_add_job(
mysql_cmd,
db_inx=d + 1,
assoc_id=assoc_id,
qos_id=qos_id,
wckey_id=wckey_id,
time_start=day_ws + 100,
time_end=day_ws + 100 + elapsed,
time_eligible=day_ws,
)
# Window must cross into the next month to trigger the monthly rollup pass.
month_dt = datetime.fromtimestamp(month_ws)
next_month = (
month_dt.replace(day=1, month=month_dt.month % 12 + 1)
if month_dt.month < 12
else month_dt.replace(day=1, month=1, year=month_dt.year + 1)
)
# The monthly pass aggregates the whole month out of the day table, so it
# does not need a month-wide hourly pass. Roll up the seeded days, then a
# one-hour window at the end of the month to trigger the monthly pass:
# ~73 hourly iterations per cluster instead of ~720.
_rollup(month_ws, _day_start(month_ws, 3))
month_end_ws = int(next_month.timestamp())
_rollup(month_end_ws - 3600, month_end_ws)
# A day-aligned window reads the day rows; the whole-month window reads
# the month row.
expected = _CPU_COUNT * elapsed * 3
for obj, read in _OBJECT_READERS.items():
day_total = sum(
read(_day_start(month_ws, d), _day_start(month_ws, d + 1)) for d in range(3)
)
month_total = read(month_ws, month_end_ws)
assert (
day_total == expected
), f"{obj} daily sum={day_total}, expected {expected}"
assert (
month_total == expected
), f"{obj} month alloc={month_total}, expected {expected}"
def test_boundary_spanning_job(db):
"""Jobs spanning the whole window are each clamped to 3600s; totals accumulate."""
mysql_cmd, ws, assoc_id, _ = db
_add_cluster_event(mysql_cmd, ws)
# Both jobs start before the window and end after it: each must clamp to
# the full 3600s window, not its true elapsed time.
_add_job(
mysql_cmd,
db_inx=1,
assoc_id=assoc_id,
time_start=ws - 500,
time_end=ws + 3600 + 500,
time_eligible=ws - 500,
)
_add_job(
mysql_cmd,
db_inx=2,
assoc_id=assoc_id,
time_start=ws - 100,
time_end=ws + 3600 + 100,
time_eligible=ws - 100,
)
_rollup(ws, ws + 3600)
used = _sreport_account_used(ws)
assert (
used == _CPU_COUNT * 3600 * 2
), f"spanning-job cpu alloc={used}, expected {_CPU_COUNT * 3600 * 2}"
def test_running_job_alloc(db):
"""Still-running jobs (time_end=0) are each credited from their start to the
window end; contributions accumulate across jobs."""
mysql_cmd, ws, assoc_id, _ = db
_add_cluster_event(mysql_cmd, ws)
# time_end=0 marks each job still running; the rollup clamps its end to the
# window end, so each contributes cpus * (3600 - start_offset).
_add_job(
mysql_cmd,
db_inx=1,
assoc_id=assoc_id,
time_start=ws + 100,
time_end=0,
time_eligible=ws + 100,
state=_JOB_RUNNING,
)
_add_job(
mysql_cmd,
db_inx=2,
assoc_id=assoc_id,
time_start=ws + 200,
time_end=0,
time_eligible=ws + 200,
state=_JOB_RUNNING,
)
_rollup(ws, ws + 3600)
expected = _CPU_COUNT * (3600 - 100) + _CPU_COUNT * (3600 - 200)
used = _sreport_account_used(ws)
assert used == expected, f"running-job cpu alloc={used}, expected {expected}"
@pytest.mark.skipif(
atf.get_version("sbin/slurmdbd") < (26, 11), reason=_RESV_IDLE_REASON
)
def test_reservation_idle_multi_assoc(db, second_assoc):
"""Reservation idle TRES-seconds must be split evenly across every
eligible association, on top of each assoc's own in-reservation usage."""
mysql_cmd, ws, assoc_id, _ = db
assoc_id2 = second_assoc
job_elapsed = 1800
resv_cpu = 8
_add_cluster_event(mysql_cmd, ws)
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_resv_table` "
f"(id_resv, assoclist, nodelist, resv_name, tres, "
f"time_start, time_end, flags, unused_wall) "
f"VALUES ({_RESV_ID}, '{assoc_id},{assoc_id2}', 'n1', 'resv{_RESV_ID}', "
f"'1={resv_cpu}', {ws}, {ws + 3600}, 0, 0.0)",
)
# Fewer CPUs than the reservation holds; see the comment in
# test_reservation_idle_redistribution about why half does not work.
job_cpu = 2
_add_job(
mysql_cmd,
assoc_id=assoc_id,
resv_id=_RESV_ID,
time_start=ws + 100,
time_end=ws + 100 + job_elapsed,
time_eligible=ws,
tres_alloc=f"1={job_cpu}",
cpus_req=job_cpu,
)
_rollup(ws, ws + 3600)
# The reservation is charged what the job actually used, and the rest is
# split evenly across the two eligible assocs.
job_cpu_secs = job_cpu * job_elapsed
idle_share = (resv_cpu * 3600 - job_cpu_secs) // 2
running_assoc = _sreport_account_used(ws, account=_ACCOUNT)
idle_assoc = _sreport_account_used(ws, account=_ACCOUNT2)
assert running_assoc == job_cpu_secs + idle_share, (
f"job-running assoc cpu alloc={running_assoc}, "
f"expected {job_cpu_secs + idle_share} (job {job_cpu_secs} + "
f"idle share {idle_share})"
)
assert (
idle_assoc == idle_share
), f"idle-only assoc cpu alloc={idle_assoc}, expected {idle_share}"
# No QOS is connected with idle time, so it is counted against the
# account's default QOS, or 'normal' where there is none. Neither test
# account has a default.
idle_qos = _sreport_qos_used(ws, "normal", account=_ACCOUNT2)
assert (
idle_qos == idle_share
), f"idle-only assoc normal-QOS alloc={idle_qos}, expected {idle_share}"
# Reservation time still has to land somewhere in the cluster row.
_assert_cluster_reconciles(ws, _CLUSTER_CPU)
@pytest.mark.skipif(atf.get_version("sbin/slurmdbd") < (26, 11), reason=_ENERGY_REASON)
def test_zero_duration_row_energy_attribution(db, second_assoc, qos_id):
"""A row with no elapsed time must not charge its energy to another id.
ENERGY is the one TRES the rollup does not scale by elapsed time, so a job
row that contributes no time to the window still carries a non-zero energy
value into the accumulators. Such a row reaches calc_cluster without
setting them, so its energy lands on whichever assoc/QOS/wckey the
preceding row left there.
Both rows sit in the same hour and the assertion is on the victim: the
running job's assoc, QOS and wckey must be charged its own energy and
nothing more. The rollup selects running rows (time_end=0) and completed
rows in separate UNION ALL branches in that order, so the running job
below reaches the accumulators before the zero-elapsed row does.
"""
mysql_cmd, ws, assoc_id, wckey_id = db
assoc_id2 = second_assoc
hour2 = ws + 3600
energy1 = 1000
energy2 = 5000
_add_cluster_event(mysql_cmd, ws)
# Still running, and eligible only from hour 2, so it is the first row that
# hour selects and it leaves the accumulators pointing at its own ids.
_add_job(
mysql_cmd,
db_inx=1,
assoc_id=assoc_id,
qos_id=qos_id,
wckey_id=wckey_id,
time_start=ws + 3700,
time_end=0,
time_eligible=ws + 3700,
state=_JOB_RUNNING,
tres_alloc=f"1={_CPU_COUNT},{_ENERGY}={energy1}",
)
# Ends exactly on the hour boundary: real elapsed time in hour 1, none in
# hour 2, where it is still selected and carries its energy in.
_add_job(
mysql_cmd,
db_inx=2,
assoc_id=assoc_id2,
time_start=ws + 200,
time_end=hour2,
time_eligible=ws,
tres_alloc=f"1={_CPU_COUNT},{_ENERGY}={energy2}",
)
_rollup(ws, ws + 7200)
# Hour 1 is ordinary accounting: the completed job's energy is its own.
assert (
_sreport_account_used(ws, account=_ACCOUNT2, tres="energy") == energy2
), f"hour 1 assoc2 energy != {energy2}"
for label, got in (
("assoc", _sreport_account_used(hour2, tres="energy")),
("qos", _sreport_qos_used(hour2, _QOS, tres="energy")),
("wckey", _sreport_wckey_used(hour2, tres="energy")),
):
assert got == energy1, (
f"hour 2 {label} energy={got}, expected {energy1}; a zero-elapsed "
f"row charged its energy to the running job's {label}"
)
assert (
_sreport_account_used(hour2, account=_ACCOUNT2, tres="energy", default=0) == 0
), "hour 2 assoc2 energy != 0; a zero-elapsed row was credited its own energy"