blob: 5fd1ded1fc1fc272ee56ddca8031e83bc415a4e3 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test sacct --state=SUSPENDED selection against the suspend history.
sacct.1 says --state "selects jobs based on their state during the time
period given". For SUSPENDED that period is matched against each job's
suspend records, not against the job's current state, so a job that was
suspended inside the window and has since resumed or finished is still
selected, and one that is suspended now but was not during the window is
not.
This pins down which jobs are selected for every placement of a suspend
period relative to the window, including the exact-second boundaries. Both
window ends are matched inclusively against the suspend period: one that
ends exactly at --starttime, or begins exactly at --endtime, is selected.
The job itself must also have been around during the window, as for the
default time window: ended no earlier than --starttime, and eligible before
--endtime. So a suspend record still open on a job that has since ended
does not match windows after the job's end, and a job that became eligible
in the window's last second does not match it. Releases before 26.11 did
not apply that bound (ticket 26019); those cases have their own test.
Rows are seeded directly in MySQL because suspend periods landing on exact
seconds relative to a window cannot be produced reliably with real jobs.
Seeding proves the selection; it does not prove slurmdbd writes these
shapes.
Run this only against a throwaway accounting DB.
"""
import collections
from datetime import datetime, timedelta
import pytest
import atf
_CLUSTER = "test10111susp"
_NODES = "t10111n[1-2]"
_NODE1 = "t10111n1"
_NODE1_INX = "0"
_NODE2 = "t10111n2"
_NODE2_INX = "1"
_NO_VAL = 0xFFFFFFFE
# Slurm job states (enum job_states and slurm.h flags)
_JOB_RUNNING = 1
_JOB_SUSPENDED = 2
_JOB_COMPLETE = 3
_JOB_REQUEUE = 0x400 # SLURM_BIT(10)
# Every offset below is in seconds from the base time chosen at seed time.
_RUN_END = 10000
# Windows (start, end). None means the option is not given.
_W = (1000, 2000)
_LATE = (5000, 6000)
_START_ONLY = (1500, None)
_NOW = (None, None)
# Around _RUN_END, where most seeded jobs complete.
_END = (9500, 10500)
# job id -> (state, start, end, node index, [(suspend start, suspend end)]).
# A suspend end of 0 is a suspend record that was never closed. An end of
# 0 for the job itself is a job that has not finished.
# The job became eligible at "eligible", 10 seconds before the base time
# unless given.
_Job = collections.namedtuple(
"_Job", "state start end node suspends eligible", defaults=[-10]
)
_JOBS = {
1: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE1_INX, [(100, 500)]),
2: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE1_INX, [(800, 1200)]),
3: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE2_INX, [(1200, 1500)]),
4: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE1_INX, [(1800, 2500)]),
5: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE1_INX, [(2500, 3000)]),
6: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE1_INX, [(500, 3000)]),
# Ends exactly at the window start.
7: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE1_INX, [(500, 1000)]),
# Begins exactly at the window end.
8: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE2_INX, [(2000, 2500)]),
# One second clear of the window on either side.
9: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE1_INX, [(500, 999)]),
10: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE1_INX, [(2001, 2500)]),
# Suspended twice inside the window; selected once.
11: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE1_INX, [(1100, 1200), (1300, 1400)]),
# Still suspended.
12: _Job(_JOB_SUSPENDED, 0, 0, _NODE1_INX, [(1500, 0)]),
# Finished at 3000 with its suspend record left open, as a runaway fix
# before 26.11 left a job that was suspended when it was fixed.
13: _Job(_JOB_COMPLETE, 0, 3000, _NODE1_INX, [(1500, 0)]),
# Never suspended.
14: _Job(_JOB_COMPLETE, 0, _RUN_END, _NODE2_INX, []),
# Suspended now, but only since after the window.
15: _Job(_JOB_SUSPENDED, 0, 0, _NODE1_INX, [(2500, 0)]),
# Suspended inside the window, running again now.
16: _Job(_JOB_RUNNING, 0, 0, _NODE2_INX, [(1200, 1300)]),
# First run of a requeued job, suspended inside the window. Its second
# run (_REQUEUED_SECOND_RUN) shares the job id and was never suspended.
17: _Job(_JOB_REQUEUE, 0, 1600, _NODE1_INX, [(1100, 1200)]),
# Eligible, started and suspended in the window's last second.
18: _Job(_JOB_COMPLETE, 2000, _RUN_END, _NODE1_INX, [(2000, 2500)], 2000),
# Ended exactly at the window start, while suspended.
19: _Job(_JOB_COMPLETE, 0, 1000, _NODE1_INX, [(500, 1000)]),
# Ended a second before the window start, with its suspend record open.
20: _Job(_JOB_COMPLETE, 0, 999, _NODE1_INX, [(500, 0)]),
}
_REQUEUED_JOB = 17
_REQUEUED_SECOND_RUN = 117 # job_db_inx of the second run
# Jobs selected or not depending on the job's own lifetime; checked only by
# test_selection_bounded_by_job_lifetime and ignored everywhere else.
_LIFETIME_JOBS = {13, 18, 19, 20}
_EXPECTED = {
_W: {2, 3, 4, 6, 7, 8, 11, 12, 16, 17},
_LATE: {12, 15},
# With --starttime alone the window is that single second.
_START_ONLY: {3, 6, 12},
# With neither, it is the current second.
_NOW: {12, 15},
}
xfail_t26019 = pytest.mark.xfail(
atf.get_version("sbin/slurmdbd") < (26, 11),
reason="Ticket 26019: SUSPENDED selection was not bounded by the job's "
"own lifetime before 26.11",
)
_Row = collections.namedtuple("_Row", "db_index jobid")
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_accounting(modify=True)
# Seeded rows sit in the past; a purge pass mid-test would delete them.
atf.require_config_parameter("PurgeJobAfter", None, source="slurmdbd")
atf.require_config_parameter("PurgeSuspendAfter", None, source="slurmdbd")
atf.require_config_parameter("PurgeEventAfter", None, source="slurmdbd")
atf.require_slurm_running()
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 _base_time():
"""Local noon three days ago.
Noon keeps every window clear of a DST transition, so the local time
strings passed to sacct map back to exactly these epochs.
"""
day = datetime.now() - timedelta(days=3)
return int(day.replace(hour=12, minute=0, second=0, microsecond=0).timestamp())
@pytest.fixture(scope="module")
def base(request, setup, sql_statement_repeat):
"""Create the test cluster, seed every case once, return the base time."""
slurm_user = atf.properties["slurm-user"]
# Best effort: the per-cluster tables do not exist on a fresh database,
# and a previous interrupted run may have left rows at these keys.
for table in ("job_table", "suspend_table", "event_table"):
_sql(
sql_statement_repeat,
f"TRUNCATE TABLE {_CLUSTER}_{table}",
fatal=False,
quiet=True,
)
# Not fatal: on a fresh database there is no cluster to remove yet.
atf.run_command(
f"sacctmgr -i remove cluster {_CLUSTER}", user=slurm_user, quiet=True
)
atf.run_command(f"sacctmgr -i add cluster {_CLUSTER}", user=slurm_user, fatal=True)
def _teardown():
# Records with time_end=0 count as active jobs and make the cluster
# removal refuse, so drop the jobs first.
_sql(
sql_statement_repeat,
f"TRUNCATE TABLE {_CLUSTER}_job_table",
fatal=False,
)
atf.run_command(
f"sacctmgr -i remove cluster {_CLUSTER}", user=slurm_user, fatal=True
)
request.addfinalizer(_teardown)
b = _base_time()
# Node layout for --nodelist, valid from a day before any job started.
_sql(
sql_statement_repeat,
f"INSERT INTO `{_CLUSTER}_event_table` "
f"(time_start, time_end, node_name, cluster_nodes, reason, state, tres) "
f"VALUES ({b - 86400}, 0, '', '{_NODES}', '', 0, '1=8')",
)
runs = [(job_id, job_id, b + job.eligible, job) for job_id, job in _JOBS.items()]
runs.append(
(
_REQUEUED_SECOND_RUN,
_REQUEUED_JOB,
b + 1600,
_Job(_JOB_COMPLETE, 1700, _RUN_END, _NODE1_INX, []),
)
)
for db_inx, job_id, submit, job in runs:
nodename = _NODE2 if job.node == _NODE2_INX else _NODE1
end = b + job.end if job.end else 0
# A suspended job's time_suspended is when it was suspended; for any
# other job it is the total seconds it spent suspended.
if job.state == _JOB_SUSPENDED:
suspended = b + job.suspends[-1][0]
else:
suspended = sum(e - s for s, e in job.suspends if e)
_sql(
sql_statement_repeat,
f"INSERT INTO `{_CLUSTER}_job_table` "
f"(job_db_inx, id_assoc, id_wckey, id_resv, id_job, "
f"id_user, id_group, het_job_id, het_job_offset, "
f"state_reason_prev, nodes_alloc, `partition`, priority, state, "
f"time_submit, time_eligible, time_start, time_end, time_suspended, "
f"cpus_req, job_name, account, nodelist, node_inx) "
f"VALUES ({db_inx}, 0, 0, 0, {job_id}, "
f"0, 0, 0, {_NO_VAL}, "
f"0, 1, 'part1', 0, {job.state}, "
f"{submit}, {submit}, {b + job.start}, {end}, {suspended}, "
f"1, 'test10111', 'root', '{nodename}', '{job.node}')",
)
for s, e in job.suspends:
_sql(
sql_statement_repeat,
f"INSERT INTO `{_CLUSTER}_suspend_table` "
f"(job_db_inx, id_assoc, time_start, time_end) "
f"VALUES ({db_inx}, 0, {b + s}, {b + e if e else 0})",
)
return b
def _sacct(b, window, *opts, duplicates=True):
"""Return the sacct records selected for window as a list of _Row.
--duplicates keeps every matching job record (sacct.1, -D), so a query
that matched one record more than once, or matched a requeued job's other
record, shows up here instead of being collapsed into one row.
"""
start, end = window
cmd = (
f"sacct -M {_CLUSTER} --allusers -X -n -P {'-D ' if duplicates else ''}"
f"--format=DBIndex,JobIDRaw {' '.join(opts)}"
)
if start is not None:
cmd += f" -S {datetime.fromtimestamp(b + start):%Y-%m-%dT%H:%M:%S}"
if end is not None:
cmd += f" -E {datetime.fromtimestamp(b + end):%Y-%m-%dT%H:%M:%S}"
out = atf.run_command_output(cmd, user=atf.properties["slurm-user"], fatal=True)
rows = []
for line in out.splitlines():
fields = line.split("|")
assert len(fields) == len(_Row._fields), f"unparsable sacct row: {line!r}"
rows.append(_Row(int(fields[0]), int(fields[1])))
return rows
def _suspended_jobs(b, window, *opts):
"""Return the set of job ids sacct selects as SUSPENDED in window.
Every job here has a single record matching any SUSPENDED query (the
requeued job's second run was never suspended), so each job id must
appear exactly once even with --duplicates. _LIFETIME_JOBS are left
out; test_selection_bounded_by_job_lifetime covers them.
"""
rows = _sacct(b, window, "--state=SUSPENDED", *opts)
ids = [r.jobid for r in rows]
assert len(ids) == len(set(ids)), f"a job was selected more than once: {rows}"
assert _REQUEUED_SECOND_RUN not in {
r.db_index for r in rows
}, "the never-suspended run of the requeued job was selected"
return set(ids) - _LIFETIME_JOBS
@pytest.mark.parametrize(
"window",
[_W, _LATE, _START_ONLY, _NOW],
ids=["window", "late_window", "starttime_only", "now"],
)
def test_suspended_selection(base, window):
"""Exactly the jobs with a suspend period touching the window."""
got = _suspended_jobs(base, window)
expected = _EXPECTED[window]
assert (
got == expected
), f"missing {sorted(expected - got)}, unexpected {sorted(got - expected)}"
def test_short_state_name(base):
"""The short form S selects the same jobs as SUSPENDED."""
rows = _sacct(base, _W, "--state=S")
got = {r.jobid for r in rows} - _LIFETIME_JOBS
assert got == _EXPECTED[_W], f"--state=S selected {sorted(got)}"
_RUNNING_LIST = "SUSPENDED,RUNNING"
_COMPLETED_LIST = "SUSPENDED,COMPLETED"
_TERMINAL_LIST = "BF,CA,CD,DL,F,NF,OOM,PR,S,TO"
@pytest.mark.parametrize(
"window, states, others_match",
[
(_W, _RUNNING_LIST, True),
(_W, _COMPLETED_LIST, False),
(_W, _TERMINAL_LIST, False),
(_LATE, _RUNNING_LIST, True),
(_LATE, _COMPLETED_LIST, False),
(_LATE, _TERMINAL_LIST, False),
(_END, _RUNNING_LIST, True),
(_END, _COMPLETED_LIST, True),
(_END, _TERMINAL_LIST, True),
],
ids=[
f"{w}-{s}"
for w in ("window", "late", "end")
for s in ("running", "completed", "terminal")
],
)
def test_state_list_is_union(base, window, states, others_match):
"""A state list selects exactly the records its states select one by one.
others_match says whether the states other than SUSPENDED select records
of their own in the window, so that the list has to return more than
SUSPENDED alone. Jobs complete mostly at _RUN_END, and the only job
completing in _W (19) was also suspended there.
"""
both = {r.db_index for r in _sacct(base, window, f"--state={states}")}
each = set()
for state in states.split(","):
each |= {r.db_index for r in _sacct(base, window, f"--state={state}")}
assert (
both == each
), f"--state={states} selected {sorted(both)}, its states one by one {sorted(each)}"
suspended = {r.db_index for r in _sacct(base, window, "--state=SUSPENDED")}
assert (
bool(both - suspended) == others_match
), f"--state={states} added {sorted(both - suspended)} to SUSPENDED's records"
@pytest.mark.parametrize(
"window, jobs, expected",
[(_W, "3,5,14", {3}), (_NOW, "1,5,14", {1, 5})],
ids=["window", "default_window"],
)
def test_jobs_filter(base, window, jobs, expected):
"""--jobs narrows the SUSPENDED selection to the listed jobs.
With no --starttime or --endtime, --jobs makes the window run from Epoch
to now (sacct.1, DEFAULT TIME WINDOW).
"""
got = _suspended_jobs(base, window, f"--jobs={jobs}")
assert got == expected, f"--jobs={jobs} selected {sorted(got)}"
@pytest.mark.parametrize(
"window, expected",
[(_W, {3, 8, 16}), (_NOW, set())],
ids=["window", "now"],
)
def test_nodelist_filter(base, window, expected):
"""--nodelist narrows the SUSPENDED selection to jobs on that node.
The "now" case: a node with a running job and nothing suspended returns
no jobs, and returns successfully.
"""
got = _suspended_jobs(base, window, f"--nodelist={_NODE2}")
assert got == expected, f"--nodelist={_NODE2} selected {sorted(got)}"
def test_truncate_keeps_selection(base):
"""--truncate changes reported times, not which jobs are selected."""
got = _suspended_jobs(base, _W, "--truncate")
assert got == _EXPECTED[_W], f"--truncate selected {sorted(got)}"
@pytest.mark.parametrize(
"states, db_index",
[("SUSPENDED", _REQUEUED_JOB), ("SUSPENDED,RUNNING", _REQUEUED_SECOND_RUN)],
ids=["suspended", "suspended_running"],
)
def test_requeued_job_without_duplicates(base, states, db_index):
"""Without --duplicates, a requeued job shows one record.
It is the most recent of its records the query matched (sacct.1, -D):
the suspended first run for SUSPENDED alone, and the later second run
once RUNNING also matches that one.
"""
rows = _sacct(base, _W, f"--state={states}", duplicates=False)
got = [r for r in rows if r.jobid == _REQUEUED_JOB]
assert got == [
_Row(db_index, _REQUEUED_JOB)
], f"--state={states} without --duplicates showed {got}"
@pytest.mark.parametrize(
"window, expected",
[
pytest.param(_W, {13, 19}, marks=xfail_t26019),
pytest.param(_LATE, set(), marks=xfail_t26019),
pytest.param(_START_ONLY, {13}, marks=xfail_t26019),
pytest.param(_NOW, set(), marks=xfail_t26019),
],
ids=["window", "late_window", "starttime_only", "now"],
)
def test_selection_bounded_by_job_lifetime(base, window, expected):
"""A job is selected only for windows it was around in.
Job 13 ended at 3000 with its suspend record left open, so it matches
windows up to its end and none after. Job 18 became eligible in the
window's last second, so it does not match that window. Job 19 ended
exactly at the window start and matches it; job 20 ended a second
earlier with its record open and does not.
"""
rows = _sacct(base, window, "--state=SUSPENDED")
got = {r.jobid for r in rows} & _LIFETIME_JOBS
assert (
got == expected
), f"of jobs {sorted(_LIFETIME_JOBS)}, --state=SUSPENDED selected {got}"