blob: bf836d76888f0611b24802ac17e653e81985265e [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Performance test: sacct --state=SUSPENDED must not scan the job table.
Ticket 26019: "sacct --state=SUSPENDED" read every record in the cluster's
job table to answer a question about a short window, so on a site with years
of job history it made sacct time out waiting for slurmdbd while
"--state=RUNNING" for the same window returned at once.
Wall-clock limits need a table large enough to make a scan slow on any
machine, which takes far too long to seed. Instead this seeds a modest
table of old, finished jobs and measures how many rows the database reads
to answer each query, from the sum of the server's Handler_read_* counters,
which every index lookup and table scan advances (MySQL and MariaDB both
keep them). A query
that uses an index to find the window reads a handful of rows; one that
scans the table reads all of them. That difference does not depend on
hardware or load, so the threshold can sit far from both outcomes.
The counter is server-wide, so other clients' reads land in it too; each
query is measured twice and the smaller reading is used.
SUSPENDED is also measured inside state lists, where every state's
condition has to be bounded by time for the database to use an index: with
RUNNING, and with the terminal states of a typical accounting query.
RUNNING and the default (no --state) query are measured the same way, so a
change that fixes SUSPENDED by slowing another state is caught here too.
Run this only against a throwaway accounting DB.
"""
import logging
from datetime import datetime, timedelta
import pytest
import atf
_CLUSTER = "test10112perf"
_NO_VAL = 0xFFFFFFFE
_JOB_RUNNING = 1
_JOB_SUSPENDED = 2
_JOB_COMPLETE = 3
_NODE = "n1"
# Old finished jobs that no query here should need to look at.
_OLD_JOBS = 100000
# A query that reads more than this has scanned the table.
_MAX_ROWS_READ = _OLD_JOBS // 10
_SUSPENDED_JOB = _OLD_JOBS + 1
_RUNNING_JOB = _OLD_JOBS + 2
# Suspended now, and eligible only after _W, so no query over _W selects it.
_SUSPENDED_NOW_JOB = _OLD_JOBS + 3
# Window, in seconds from the base time.
_W = (1000, 2000)
xfail_t26019 = pytest.mark.xfail(
atf.get_version("sbin/slurmdbd") < (26, 11),
reason="Ticket 26019: sacct --state=SUSPENDED scanned the whole job "
"table before 26.11",
)
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_accounting(modify=True)
atf.require_config_parameter("PurgeJobAfter", None, source="slurmdbd")
atf.require_config_parameter("PurgeSuspendAfter", None, source="slurmdbd")
atf.require_slurm_running()
def _sql(mysql_cmd, stmt, fatal=True, quiet=False):
return atf.run_command_output(
f'{mysql_cmd} -N -B -e "{stmt.replace("`", r"\`")}"',
user=atf.properties["slurm-user"],
fatal=fatal,
quiet=quiet,
)
def _rows_read(mysql_cmd):
"""Return the total rows the server has read through any access path."""
out = _sql(mysql_cmd, "SHOW GLOBAL STATUS LIKE 'Handler_read_%'", quiet=True)
counters = [line.split() for line in out.splitlines() if line.strip()]
if not counters:
pytest.fail(f"no Handler_read_* status counters:\n{out}")
return sum(int(value) for _name, value in counters)
def _base_time():
"""Local noon three days ago, clear of any DST transition."""
day = datetime.now() - timedelta(days=3)
return int(day.replace(hour=12, minute=0, second=0, microsecond=0).timestamp())
def _digits(alias):
union = " union all ".join(f"select {d} d" for d in range(10))
return f"({union}) {alias}"
@pytest.fixture(scope="module")
def seeded(request, setup, sql_statement_repeat):
"""Create the test cluster and seed it; return (mysql command, base time)."""
slurm_user = atf.properties["slurm-user"]
mysql_cmd = sql_statement_repeat
for table in ("job_table", "suspend_table", "event_table"):
_sql(mysql_cmd, 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(mysql_cmd, 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)
# Mark usage as already rolled up to now. Otherwise slurmdbd's next
# rollup pass would walk every hour since the oldest seeded job, reading
# the whole job table and blocking slurmdbd for the duration.
hour = datetime.now().replace(minute=0, second=0, microsecond=0)
day = hour.replace(hour=0)
month = day.replace(day=1)
_sql(mysql_cmd, f"DELETE FROM {_CLUSTER}_last_ran_table")
_sql(
mysql_cmd,
f"INSERT INTO {_CLUSTER}_last_ran_table "
f"(hourly_rollup, daily_rollup, monthly_rollup) VALUES "
f"({int(hour.timestamp())}, {int(day.timestamp())}, "
f"{int(month.timestamp())})",
)
b = _base_time()
# Old jobs: one a second, each running ten minutes, all over well before
# the window.
old = b - 10 * 86400
seq = "a.d + 10 * b.d + 100 * c.d + 1000 * d.d + 10000 * e.d + 1"
assert _OLD_JOBS == 100000, "the digit cross join below makes 10^5 rows"
columns = (
"job_db_inx, id_assoc, id_wckey, id_resv, id_job, id_user, id_group, "
"het_job_id, het_job_offset, state_reason_prev, nodes_alloc, "
"`partition`, priority, state, time_submit, time_eligible, "
"time_start, time_end, time_suspended, cpus_req, job_name, account, "
"nodelist, node_inx"
)
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_job_table` ({columns}) "
f"SELECT n, 0, 0, 0, n, 0, 0, 0, {_NO_VAL}, 0, 1, 'part1', 0, "
f"{_JOB_COMPLETE}, {old} + n, {old} + n, {old} + n + 60, "
f"{old} + n + 660, 0, 1, 'test10112', 'root', 'n1', '0' "
f"FROM (SELECT {seq} AS n FROM {_digits('a')}, {_digits('b')}, "
f"{_digits('c')}, {_digits('d')}, {_digits('e')}) s",
)
# One job suspended inside the window and one running through it.
for job_id, state, end, suspended in [
(_SUSPENDED_JOB, _JOB_COMPLETE, b + 10000, 300),
(_RUNNING_JOB, _JOB_RUNNING, 0, 0),
]:
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_job_table` ({columns}) VALUES "
f"({job_id}, 0, 0, 0, {job_id}, 0, 0, 0, {_NO_VAL}, 0, 1, "
f"'part1', 0, {state}, {b - 10}, {b - 10}, {b}, {end}, "
f"{suspended}, 1, 'test10112', 'root', '{_NODE}', '0')",
)
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_job_table` ({columns}) VALUES "
f"({_SUSPENDED_NOW_JOB}, 0, 0, 0, {_SUSPENDED_NOW_JOB}, 0, 0, 0, "
f"{_NO_VAL}, 0, 1, 'part1', 0, {_JOB_SUSPENDED}, {b + 2500}, "
f"{b + 2500}, {b + 2600}, 0, {b + 3000}, 1, 'test10112', 'root', "
f"'{_NODE}', '0')",
)
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_suspend_table` "
f"(job_db_inx, id_assoc, time_start, time_end) "
f"VALUES ({_SUSPENDED_JOB}, 0, {b + 1200}, {b + 1500}), "
f"({_SUSPENDED_NOW_JOB}, 0, {b + 3000}, 0)",
)
# Node layout for --nodelist, valid from before the oldest job.
_sql(
mysql_cmd,
f"INSERT INTO `{_CLUSTER}_event_table` "
f"(time_start, time_end, node_name, cluster_nodes, reason, state, tres) "
f"VALUES ({old - 86400}, 0, '', '{_NODE}', '', 0, '1=8')",
)
_sql(mysql_cmd, f"ANALYZE TABLE {_CLUSTER}_job_table", quiet=True)
# A table scan must register in the counter, or every assertion below
# would pass without measuring anything.
before = _rows_read(mysql_cmd)
_sql(mysql_cmd, f"SELECT SUM(cpus_req) FROM {_CLUSTER}_job_table", quiet=True)
if _rows_read(mysql_cmd) - before < _OLD_JOBS:
pytest.fail(
"Ticket 26019: Handler_read_* did not count a table scan, so "
"rows read cannot be measured on this server"
)
return mysql_cmd, b
def _measure(seeded, window, *opts):
"""Run sacct over window, or its default window if None.
Return (job ids, fewest rows read).
"""
mysql_cmd, b = seeded
cmd = f"sacct -M {_CLUSTER} --allusers -X -n -P --format=JobIDRaw"
if window:
cmd += (
f" -S {datetime.fromtimestamp(b + window[0]):%Y-%m-%dT%H:%M:%S}"
f" -E {datetime.fromtimestamp(b + window[1]):%Y-%m-%dT%H:%M:%S}"
)
cmd += f" {' '.join(opts)}"
jobs, reads = None, None
for _ in range(2):
before = _rows_read(mysql_cmd)
out = atf.run_command_output(cmd, user=atf.properties["slurm-user"], fatal=True)
read = _rows_read(mysql_cmd) - before
reads = read if reads is None else min(reads, read)
jobs = {int(line) for line in out.splitlines() if line}
logging.info(f"sacct {' '.join(opts) or '(no --state)'} read {reads} rows")
return jobs, reads
@xfail_t26019
@pytest.mark.parametrize(
"window, states, opts, expected",
[
(_W, "SUSPENDED", [], {_SUSPENDED_JOB}),
(None, "SUSPENDED", [f"--nodelist={_NODE}"], {_SUSPENDED_NOW_JOB}),
(_W, "SUSPENDED,RUNNING", [], {_SUSPENDED_JOB, _RUNNING_JOB}),
(_W, "BF,CA,CD,DL,F,NF,OOM,PR,S,TO", [], {_SUSPENDED_JOB}),
],
ids=["window", "now_nodelist", "with_running", "with_terminal"],
)
def test_suspended_reads_only_window(seeded, window, states, opts, expected):
"""--state=SUSPENDED finds its job without reading the old jobs.
The "now_nodelist" case is the default window, as in
"sacct -N <node> --state=S -X".
"""
jobs, reads = _measure(seeded, window, f"--state={states}", *opts)
assert jobs == expected, f"--state={states} selected {sorted(jobs)}"
assert reads < _MAX_ROWS_READ, (
f"--state={states} read {reads} rows to find {len(expected)} jobs "
f"among {_OLD_JOBS} old ones"
)
@pytest.mark.parametrize(
"opts, expected",
[
(["--state=RUNNING"], {_SUSPENDED_JOB, _RUNNING_JOB}),
([], {_SUSPENDED_JOB, _RUNNING_JOB}),
],
ids=["running", "no_state"],
)
def test_other_queries_read_only_window(seeded, opts, expected):
"""RUNNING and the default query stay index lookups too."""
jobs, reads = _measure(seeded, _W, *opts)
assert jobs == expected, f"sacct {' '.join(opts)} selected {sorted(jobs)}"
assert reads < _MAX_ROWS_READ, (
f"sacct {' '.join(opts) or '(no --state)'} read {reads} rows "
f"to find {len(expected)} jobs among {_OLD_JOBS} old ones"
)