blob: d802e164b64dacdfd062d8b7d64a20326b97611c [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Regression test for bugs 25695 and 25967: sacct Reservation resolution.
sacct resolves a job's reservation name by looking job_table.id_resv up in
resv_table, whose primary key is (id_resv, time_start), so the lookup has to
pick a record by time. A reservation is active from time_start inclusive:
job_test_resv() clamps a queued job's earliest start to exactly
resv.start_time, so a job whose start lands on that first second is
ordinary, and a lookup requiring "resv.time_start < job.time_start" strictly
drops it, leaving Reservation blank while ReservationId, ReqReservation and
squeue still show the reservation. That is the first defect this test holds
closed (bug 25695).
The second (bug 25967) is cardinality. The lookup was a join, and a job
that never started matched every record created after it was submitted, so
sacct -D repeated the job once per record, and slurmdbd buffered every
copy. A reservation with the REPLACE flag gains a record each time its
node list changes, so this reached tens of thousands of rows per job.
Rows are seeded directly in MySQL because the failure turns on second-level
coincidences between a job's start and a reservation record's boundaries,
which cannot be produced reliably by submitting real jobs. Seeding proves
the lookup; it does not prove slurmdbd writes this shape, which no test
currently covers.
Every case asserts Reservation together with ReservationId, because the
reported defect was those two disagreeing while ReqReservation and squeue
still named the reservation. ReqReservation is asserted only where a case
turns on it, since it is read straight from job_table and never passes
through the lookup.
Seeding goes through conftest's mysql helper.
Run this only against a throwaway accounting DB.
"""
import collections
import time
import pytest
import atf
_CLUSTER = "test1016resv"
_ACCOUNT = "test1016acct"
_USER = "root"
_NO_VAL = 0xFFFFFFFE
# Slurm job states (enum job_states)
_JOB_PENDING = 0
_JOB_COMPLETE = 3
_JOB_DEADLINE = 10
# Single-record reservation covering the whole window.
_RESV_ID = 1
_RESV_NAME = "resv1016"
# Split reservation: two records that tile the same window, as
# as_mysql_modify_resv() produces when an active reservation changes. Real
# split records share a name; these differ only so an assertion can tell
# which record the lookup selected.
_SPLIT_ID = 2
_SPLIT_EARLY_NAME = "resv1016a"
_SPLIT_LATE_NAME = "resv1016b"
# Second reservation, used as the one a multi-reservation job actually ran
# in. Seeded with the same window as _RESV_ID, so a lookup keyed on time
# instead of id_resv would match both.
_ALT_ID = 3
_ALT_NAME = "resv1016alt"
# An id with no resv_table record at all, as a PurgeResvAfter shorter than
# PurgeJobAfter leaves behind.
_DANGLING_ID = 99
_WINDOW = 3600
_HALF = _WINDOW // 2
# Job ids, each also used as job_db_inx.
_JOB_AT_START = 1
_JOB_INSIDE = 2
_JOB_LAST_SECOND = 3
_JOB_AT_END = 4
_JOB_BEFORE_START = 5
_JOB_PENDING_AT_START = 6
_JOB_SPLIT_BOUNDARY = 7
_JOB_SPLIT_EARLY = 8
_JOB_PENDING_BEFORE_RESV = 9
_JOB_NO_RESV = 10
_JOB_DANGLING_RESV = 11
_JOB_MULTI_STARTED = 12
_JOB_MULTI_PENDING = 13
_JOB_PENDING_BEFORE_SPLIT = 14
_JOB_PENDING_IN_SPLIT_EARLY = 15
_JOB_PENDING_IN_SPLIT_LATE = 16
_JOB_STILL_PENDING_BEFORE_SPLIT = 17
_RESET_TABLES = ["job_table", "resv_table"]
_Row = collections.namedtuple("_Row", "jobid reservation reservation_id req")
xfail_t25695 = pytest.mark.xfail(
atf.get_version("sbin/slurmdbd") < (26, 11),
reason="Ticket 25695: sacct dropped the reservation name for a job "
"starting in the reservation's first second. Fixed in 26.11",
)
xfail_t25967 = pytest.mark.xfail(
atf.get_version("sbin/slurmdbd") < (26, 11),
reason="Ticket 25967: sacct -D repeated a job that never started once per "
"reservation record created after it was submitted. Fixed in 26.11",
)
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(25, 5),
component="bin/sacct",
reason="Issue 50285: sacct ReqReservation format field added in 25.05",
)
atf.require_accounting(modify=True)
# Outside auto-config the conftest mysql helper is None, so every seeding
# call would shell out "None -e ...", failing opaquely and leaving the
# synthetic cluster behind with no database restore to undo it.
# require_accounting() only gates that when allow-slurmdbd-modify is
# unset, so it is not sufficient on its own.
atf.require_auto_config("seeds rows directly into the accounting database")
# 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("PurgeResvAfter", 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,
)
@pytest.fixture(scope="module")
def seeded_db(request, setup, sql_statement_repeat):
"""Create the test cluster and seed every case once.
slurmdbd caches association and user data but reads job_table and
resv_table per query, so the seeded rows are visible without a restart.
"""
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 _RESET_TABLES:
_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():
# A record with time_end=0 counts as an active job and makes the
# cluster removal refuse, so drop the jobs first. Not fatal, or a
# failure here would abandon both removals below.
_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
)
# Accounts are not cluster-scoped, so removing the cluster leaves this.
# Not fatal: on a setup that failed before the account was added there
# is nothing here to remove.
atf.run_command(f"sacctmgr -i remove account {_ACCOUNT}", user=slurm_user)
# Registered before the account, the user and the seeded rows exist, so a
# failure creating any of those still cleans up rather than leaking them.
request.addfinalizer(_teardown)
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} cluster={_CLUSTER}",
user=slurm_user,
fatal=True,
)
out = atf.run_command_output(
f"sacctmgr -nP list assoc cluster={_CLUSTER} account={_ACCOUNT} "
f"user={_USER} format=id",
user=slurm_user,
fatal=True,
)
if not out.strip():
pytest.fail(f"no assoc for {_USER}/{_ACCOUNT} on {_CLUSTER}")
assoc_id = int(out.strip().splitlines()[0].rstrip("|"))
# Put the whole window comfortably in the past so nothing is still open.
resv_start = int(time.time()) - 2 * _WINDOW
resv_end = resv_start + _WINDOW
for resv_id, name, start, end in [
(_RESV_ID, _RESV_NAME, resv_start, resv_end),
(_ALT_ID, _ALT_NAME, resv_start, resv_end),
# Two records tiling one window: the earlier ends exactly where the
# later begins, which is the invariant as_mysql_modify_resv() keeps.
(_SPLIT_ID, _SPLIT_EARLY_NAME, resv_start, resv_start + _HALF),
(_SPLIT_ID, _SPLIT_LATE_NAME, resv_start + _HALF, resv_end),
]:
_sql(
sql_statement_repeat,
f"INSERT INTO `{_CLUSTER}_resv_table` "
f"(id_resv, assoclist, nodelist, resv_name, "
f"time_start, time_end, flags, unused_wall) "
f"VALUES ({resv_id}, '{assoc_id}', 'n1', '{name}', "
f"{start}, {end}, 0, 0.0)",
)
both = f"{_RESV_NAME},{_ALT_NAME}"
early = _SPLIT_EARLY_NAME
# job id, id_resv, resv_req, time_submit, time_start, time_end.
# A zero time_start is a job that never ran.
jobs = [
# The reported failure: started in the reservation's first second.
(_JOB_AT_START, _RESV_ID, _RESV_NAME, resv_start, resv_start, resv_end),
(_JOB_INSIDE, _RESV_ID, _RESV_NAME, resv_start, resv_start + 1, resv_end),
(_JOB_LAST_SECOND, _RESV_ID, _RESV_NAME, resv_start, resv_end - 1, resv_end),
# Starts on the final record's time_end, i.e. once the reservation is
# over; outside [time_start, time_end).
(_JOB_AT_END, _RESV_ID, _RESV_NAME, resv_start, resv_end, resv_end + 1),
(
_JOB_BEFORE_START,
_RESV_ID,
_RESV_NAME,
resv_start - 10,
resv_start - 1,
resv_start + 10,
),
(
_JOB_SPLIT_BOUNDARY,
_SPLIT_ID,
early,
resv_start,
resv_start + _HALF,
resv_end,
),
(
_JOB_SPLIT_EARLY,
_SPLIT_ID,
early,
resv_start,
resv_start + 1,
resv_start + 10,
),
# Ran in the second of two requested reservations: Reservation must
# name the one used, ReqReservation must still list both.
(_JOB_MULTI_STARTED, _ALT_ID, both, resv_start, resv_start + 1, resv_end),
# Ordinary job, no reservation at all.
(_JOB_NO_RESV, 0, "", resv_start, resv_start + 1, resv_end),
# id_resv pointing at a record that no longer exists, as a shorter
# PurgeResvAfter than PurgeJobAfter leaves behind.
(
_JOB_DANGLING_RESV,
_DANGLING_ID,
_RESV_NAME,
resv_start,
resv_start + 1,
resv_end,
),
# Never started, so the lookup falls back to time_submit. Submitted in
# the reservation's first second.
(_JOB_PENDING_AT_START, _RESV_ID, _RESV_NAME, resv_start, 0, 0),
# Never started, submitted before the reservation began -- the
# ordinary "queue a job for tomorrow's reservation" shape.
(_JOB_PENDING_BEFORE_RESV, _RESV_ID, _RESV_NAME, resv_start - 10, 0, 0),
# Never started, several reservations requested.
(_JOB_MULTI_PENDING, _RESV_ID, both, resv_start - 10, 0, 0),
# Never started against a reservation with several records, so each
# must still yield one row. These hit their deadline while pending,
# so they have a time_end.
(
_JOB_PENDING_BEFORE_SPLIT,
_SPLIT_ID,
early,
resv_start - 10,
0,
resv_start + 10,
),
(
_JOB_PENDING_IN_SPLIT_EARLY,
_SPLIT_ID,
early,
resv_start + 1,
0,
resv_start + 10,
),
(
_JOB_PENDING_IN_SPLIT_LATE,
_SPLIT_ID,
early,
resv_start + _HALF + 1,
0,
resv_start + _HALF + 10,
),
# Still pending, so no time_end.
(
_JOB_STILL_PENDING_BEFORE_SPLIT,
_SPLIT_ID,
early,
resv_start - 10,
0,
0,
),
]
for job_id, resv_id, resv_req, submit, start, end in jobs:
if start:
state = _JOB_COMPLETE
elif end:
state = _JOB_DEADLINE
else:
state = _JOB_PENDING
_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, "
f"cpus_req, job_name, account, resv_req) "
f"VALUES ({job_id}, {assoc_id}, 0, {resv_id}, {job_id}, "
f"0, 0, 0, {_NO_VAL}, "
f"0, 1, 'part1', 0, {state}, "
f"{submit}, {submit}, {start}, {end}, "
f"4, 'test1016', '{_ACCOUNT}', '{resv_req}')",
)
yield
def _sacct_row(job_id):
"""Return the single sacct row for job_id as a _Row.
--duplicates is required: without it sacct keeps only one record per job
id (sacct.1, -D), which would hide a lookup that matched several
reservation records and make the cardinality check vacuous. Asserting
exactly one row is itself part of the contract -- sacct.1's -D text
enumerates the causes of multiple rows per job id, and reservation
record splits are not among them.
"""
out = atf.run_command_output(
f"sacct -M {_CLUSTER} -j {job_id} -X -n -P -D --allusers "
f"--format=JobID,Reservation,ReservationId,ReqReservation",
user=atf.properties["slurm-user"],
fatal=True,
)
rows = []
for line in out.splitlines():
fields = line.split("|")
# Guards against a short or unexpected line silently reading as
# "no reservation" and passing the negative assertions.
assert len(fields) == len(
_Row._fields
), f"unparsable sacct row for job {job_id}: {line!r}"
rows.append(_Row(*fields))
assert (
len(rows) == 1
), f"expected exactly one sacct row for job {job_id}, got {rows}"
return rows[0]
@pytest.mark.parametrize(
"job_id",
[
pytest.param(_JOB_AT_START, id="start_equals_resv_start", marks=xfail_t25695),
pytest.param(_JOB_INSIDE, id="start_inside_window"),
pytest.param(_JOB_LAST_SECOND, id="start_last_second"),
],
)
def test_started_job_resolves_reservation(seeded_db, job_id):
"""A job started within [time_start, time_end) resolves its name.
Reservation and ReservationId must agree; the reported defect was
Reservation coming back empty while ReservationId still named it.
"""
row = _sacct_row(job_id)
assert (
row.reservation == _RESV_NAME
), f"job {job_id} reported Reservation '{row.reservation}', expected '{_RESV_NAME}'"
assert row.reservation_id == str(_RESV_ID), (
f"job {job_id} reported ReservationId '{row.reservation_id}', "
f"expected '{_RESV_ID}'"
)
assert (
row.req == _RESV_NAME
), f"job {job_id} reported ReqReservation '{row.req}', expected '{_RESV_NAME}'"
@pytest.mark.parametrize(
"job_id",
[
pytest.param(
_JOB_PENDING_AT_START,
id="submit_equals_resv_start",
marks=xfail_t25695,
),
pytest.param(_JOB_PENDING_BEFORE_RESV, id="submit_before_resv_start"),
],
)
def test_pending_job_resolves_reservation(seeded_db, job_id):
"""A job that never started resolves its name from time_submit.
submit_equals_resv_start takes the record active at time_submit;
submit_before_resv_start, submitted while no record was active, takes
the first record after time_submit.
"""
row = _sacct_row(job_id)
assert (
row.reservation == _RESV_NAME
), f"job {job_id} reported Reservation '{row.reservation}', expected '{_RESV_NAME}'"
assert row.reservation_id == str(_RESV_ID), (
f"job {job_id} reported ReservationId '{row.reservation_id}', "
f"expected '{_RESV_ID}'"
)
def test_multi_reservation_started_job_names_the_one_used(seeded_db):
"""A started job names the reservation it ran in, not the first requested.
sbatch.1 (--reservation): "In accounting the first reservation will be
seen and after the job starts the reservation used will replace it."
This covers the accounting half only; which id_resv slurmctld records is
not exercised here.
"""
row = _sacct_row(_JOB_MULTI_STARTED)
assert row.reservation == _ALT_NAME, (
f"started job reported Reservation '{row.reservation}', expected the "
f"reservation it ran in, '{_ALT_NAME}'"
)
# ReservationId is read from job_table.id_resv and never passes through
# the lookup, so it only echoes the seeded value. What proves the lookup is
# keyed on id_resv rather than on time is the name above plus
# _sacct_row()'s single-row check: _ALT_ID shares _RESV_ID's window, so a
# time-keyed lookup would match several records for this job.
assert row.reservation_id == str(_ALT_ID), (
f"started job reported ReservationId '{row.reservation_id}', "
f"expected '{_ALT_ID}'"
)
assert (
row.req == f"{_RESV_NAME},{_ALT_NAME}"
), f"ReqReservation '{row.req}' should still list every requested reservation"
def test_multi_reservation_pending_job_lists_all_requested(seeded_db):
"""A job that never started lists every reservation it requested.
Reservation follows id_resv, which the fixture supplies, so this does
not verify sbatch.1's "the first reservation will be seen" -- that is a
statement about what slurmctld records, and no test covers it. What it
does pin is that ReqReservation echoes the full requested list while
Reservation resolves the single recorded id.
"""
row = _sacct_row(_JOB_MULTI_PENDING)
assert (
row.reservation == _RESV_NAME
), f"pending job reported Reservation '{row.reservation}', expected '{_RESV_NAME}'"
assert row.reservation_id == str(_RESV_ID), (
f"pending job reported ReservationId '{row.reservation_id}', "
f"expected '{_RESV_ID}'"
)
assert (
row.req == f"{_RESV_NAME},{_ALT_NAME}"
), f"ReqReservation '{row.req}' should still list every requested reservation"
def test_job_without_reservation_reports_none(seeded_db):
"""An ordinary job reports no reservation in either field."""
row = _sacct_row(_JOB_NO_RESV)
assert (
row.reservation == ""
), f"job with no reservation reported Reservation '{row.reservation}'"
assert (
row.reservation_id == ""
), f"job with no reservation reported ReservationId '{row.reservation_id}'"
def test_job_started_before_non_flex_reservation(seeded_db):
"""A job started before a non-FLEX reservation's record resolves no name.
Scoped to a reservation with no flags set. FLEX explicitly permits a job
to begin before the reservation's start time (scontrol.1) and the lookup
does not resolve that case either, which is a pre-existing gap tracked
separately. This job overlaps the reservation's window, so a FLEX fix
keyed on overlap would change this expectation, while one keyed on the
reservation's flags would leave it intact.
"""
row = _sacct_row(_JOB_BEFORE_START)
assert (
row.reservation == ""
), f"job starting before the reservation reported '{row.reservation}'"
# The id is still recorded even though the name does not resolve.
assert row.reservation_id == str(_RESV_ID), (
f"ReservationId '{row.reservation_id}' should still be recorded, "
f"expected '{_RESV_ID}'"
)
@xfail_t25695
def test_job_started_at_resv_end_resolves_no_name(seeded_db):
"""Pins the lookup's end-exclusive convention, not a documented promise.
Exactly one endpoint of a reservation record can be inclusive, or a job
landing on an interior seam between two tiled records matches both. The
start is the inclusive one because the scheduler plans every queued job
to start at exactly resv.start_time. The cost is this case: a job whose
start equals the final record's time_end resolves no name, while
ReservationId still records it.
The state is reachable, not hypothetical. job_test_resv() rejects a
candidate start only when it is strictly after end_time, and
job_resv_check() keeps the reservation alive while end_time >= now.
Deleting a reservation, or shortening its end into the current second,
puts a record's time_end on a running job's start the same way. A
recurring reservation usually advances past the instant first, but not
when it carries FLEX, which skips the advance, and not when its duration
equals its recurrence period, where the advance leaves the next
start_time equal to the old end_time. Treat a failure here as a
decision to revisit -- the cost is tracked, not an impossibility.
"""
row = _sacct_row(_JOB_AT_END)
assert (
row.reservation == ""
), f"job starting at the reservation's end reported '{row.reservation}'"
assert row.reservation_id == str(_RESV_ID), (
f"ReservationId '{row.reservation_id}' should still be recorded, "
f"expected '{_RESV_ID}'"
)
def test_job_with_purged_reservation_record(seeded_db):
"""A job outliving its reservation record still lists, with no name.
PurgeResvAfter and PurgeJobAfter are independent (slurmdbd.conf.5), so a
job row can reference an id_resv whose record is gone. The job must not
disappear from sacct.
"""
row = _sacct_row(_JOB_DANGLING_RESV)
assert row.jobid == str(
_JOB_DANGLING_RESV
), f"job with a purged reservation record went missing from sacct: {row}"
assert (
row.reservation == ""
), f"job with a purged reservation record reported '{row.reservation}'"
assert row.reservation_id == str(_DANGLING_ID), (
f"ReservationId '{row.reservation_id}' should still be recorded, "
f"expected '{_DANGLING_ID}'"
)
@xfail_t25695
def test_split_records_match_started_job_exactly_once(seeded_db):
"""Tiled records must not duplicate or drop a job that has started.
test_split_records_match_pending_job_exactly_once covers a job that
never started.
"""
for job_id, expected in (
(_JOB_SPLIT_BOUNDARY, _SPLIT_LATE_NAME),
(_JOB_SPLIT_EARLY, _SPLIT_EARLY_NAME),
):
row = _sacct_row(job_id)
assert row.reservation == expected, (
f"job {job_id} matched '{row.reservation}', expected the record "
f"it ran under, '{expected}'"
)
assert row.reservation_id == str(_SPLIT_ID), (
f"job {job_id} reported ReservationId '{row.reservation_id}', "
f"expected '{_SPLIT_ID}'"
)
@pytest.mark.parametrize(
"job_id,expected",
[
pytest.param(
_JOB_PENDING_BEFORE_SPLIT,
_SPLIT_EARLY_NAME,
id="submit_before_split",
marks=xfail_t25967,
),
pytest.param(
_JOB_PENDING_IN_SPLIT_EARLY,
_SPLIT_EARLY_NAME,
id="submit_in_early_record",
marks=xfail_t25967,
),
pytest.param(
_JOB_STILL_PENDING_BEFORE_SPLIT,
_SPLIT_EARLY_NAME,
id="still_pending_before_split",
marks=xfail_t25967,
),
# No record starts after this job's submit, so a pre-fix slurmdbd
# also returns one row and this case needs no xfail.
pytest.param(
_JOB_PENDING_IN_SPLIT_LATE,
_SPLIT_LATE_NAME,
id="submit_in_late_record",
),
],
)
def test_split_records_match_pending_job_exactly_once(seeded_db, job_id, expected):
"""Ticket 25967: tiled records must not duplicate a job that never started.
A job that never started takes the record active at its time_submit,
or the first record after it when none was active, however many
records follow time_submit. The selection is the existing convention,
not a documented promise. _sacct_row() asserts the single row.
"""
row = _sacct_row(job_id)
assert row.reservation == expected, (
f"job {job_id} matched '{row.reservation}', expected the record "
f"active at or after its submit, '{expected}'"
)
assert row.reservation_id == str(_SPLIT_ID), (
f"job {job_id} reported ReservationId '{row.reservation_id}', "
f"expected '{_SPLIT_ID}'"
)