blob: c376ca5d29a40b0bd41b21694bd376ec80b94c4a [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""GraceTime + PreemptMode=REQUEUE must not requeue a later successful run.
With partition-priority preemption, a low-priority job that is preempted after
a nonzero GraceTime is correctly requeued once. After the high-priority job
finishes and the low-priority job restarts, that restarted run must complete
normally (Restarts=1). A stale GRACE_PREEMPT bit incorrectly forces a second
requeue when the restarted batch script exits 0.
Requires Slurm 26.05+ (ticket 25682; the fix is backported to the 26.05 and
26.11 branches).
A batch-only victim (no srun step) is not killed by GraceTime's warning
signal, so this test waits for GraceTime expiry (job_requeue_internal).
PreemptTime and EndTime pin that the job entered grace (EndTime is
clamped to PreemptTime+GraceTime) rather than inferring it from a short
"still RUNNING" window.
GraceTime expiry may be acted on promptly by the scheduler, but in the worst
case it waits for the controller's periodic time limit check
(PERIODIC_TIMEOUT, 30 seconds).
Requires: PreemptType=preempt/partition_prio, overlapping PriorityTier
partitions on one node, GraceTime>0 and PreemptMode=REQUEUE on the low
partition, JobRequeue enabled, and SchedulerParameters requeue_delay=0 plus
tight bf_interval/sched_interval so post-requeue starts are prompt. Both jobs
are pinned to the same node (-w) so a multi-node cluster cannot let the
high-priority job simply start elsewhere instead of preempting.
The bug's user-visible symptom, per ticket 25682, is the restarted job's
normal exit being recorded as PREEMPTED in accounting. Live scontrol state
can settle correctly while that database record is still wrong, so the test
also checks sacct -D for the low-priority job: one PREEMPTED/REQUEUED record
for the interrupted run, and a final COMPLETED record for the restarted run.
"""
import pytest
import atf
pytestmark = pytest.mark.slow
_GRACE_TIME_SEC = 15
_GRACE_STILL_RUNNING_SEC = 5
_HIGH_WRAP_SLEEP_SEC = 5
# Worst-case GraceTime expiry waits for PERIODIC_TIMEOUT. After requeue the
# job stays COMPLETING until KillWait, so keep KillWait short.
_PERIODIC_TIMEOUT_SEC = 30
_KILL_WAIT_SEC = 5
_REQUEUE_TIMEOUT_SEC = _GRACE_TIME_SEC + _PERIODIC_TIMEOUT_SEC + _KILL_WAIT_SEC + 40
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 5),
"sbin/slurmctld",
reason=(
"Ticket 25682: GRACE_PREEMPT was left set after a preempt requeue, "
"forcing a second requeue on a successful run. Fixed on master and "
"backported to 26.05 and 26.11."
),
)
atf.require_accounting()
atf.require_config_parameter("PreemptType", "preempt/partition_prio")
atf.require_config_parameter("PreemptMode", "REQUEUE")
atf.require_config_parameter("PreemptExemptTime", "0")
atf.require_config_parameter("JobRequeue", "1")
atf.require_config_parameter("KillWait", str(_KILL_WAIT_SEC))
atf.require_config_parameter_includes("SchedulerParameters", ("requeue_delay", 0))
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
atf.require_nodes(1, [("CPUs", 2), ("RealMemory", 512)])
atf.require_config_parameter("DefMemPerNode", "128")
atf.require_config_parameter(
"PartitionName",
{
"lowprio": {
"Nodes": "ALL",
"PriorityTier": "1",
"GraceTime": str(_GRACE_TIME_SEC),
"PreemptMode": "REQUEUE",
},
"highprio": {
"Nodes": "ALL",
"PriorityTier": "2",
},
},
)
atf.require_slurm_running()
def _sacct_records(job_id):
output = atf.run_command_output(
f"sacct -j {job_id} -D -X -o State,Restarts -P -n", fatal=True
)
records = [line.split("|") for line in output.splitlines() if line]
# sacct's output order for duplicate records of one job id is not
# guaranteed, so sort by Restarts to reliably tell the interrupted run's
# record from the restarted run's record.
records.sort(key=lambda r: int(r[1] or 0))
return records
def _wait_for_sacct_completed(job_id):
records = None
for _ in atf.timer():
records = _sacct_records(job_id)
if records and records[-1][0] == "COMPLETED":
break
return records
def _assert_two_run_sacct_history(job_id, records):
"""Confirm one PREEMPTED/REQUEUED record then a final COMPLETED record.
Live scontrol state can settle correctly while the accounting record for
the restarted run is still stamped PREEMPTED (the user-visible symptom of
ticket 25682), so this checks what actually landed in the database.
"""
assert len(records) == 2, (
f"Job ({job_id}) should have exactly two accounting records "
f"(interrupted run + restarted run), got {records}"
)
assert (
records[-1][0] == "COMPLETED"
), f"Job ({job_id}) final accounting record should be COMPLETED, got {records}"
assert records[0][0] in ("PREEMPTED", "REQUEUED"), (
f"Job ({job_id}) first accounting record should be PREEMPTED or "
f"REQUEUED (the GraceTime requeue), got {records}"
)
def test_gracetime_requeue_clears_before_next_run():
"""After GraceTime+REQUEUE, the restarted job must complete with Restarts=1.
Sequence: low-priority job runs, high-priority job preempts it when
GraceTime expires (job_requeue_internal), low job is requeued once, then
exits 0 when the node is free. Restarts must stay at 1 (not jump to 2
from a forced requeue of that successful run).
The restarted run exits immediately, so success is observed as the terminal
COMPLETED state with Restarts=1 rather than by catching it RUNNING.
"""
node = atf.run_job_nodes("-N1 -c2 -t1 --exclusive -p lowprio true", fatal=True)[0]
script = "low_preempt.sh"
atf.make_bash_script(
script,
"""
if [ "${SLURM_RESTART_COUNT:-0}" -eq 0 ]; then
sleep infinity
fi
exit 0
""",
)
low_job = atf.submit_job_sbatch(
f"-w {node} -c2 -t5 -o /dev/null --exclusive -p lowprio --requeue {script}",
fatal=True,
)
atf.wait_for_job_state(low_job, "RUNNING", fatal=True)
high_job = atf.submit_job_sbatch(
f"-w {node} -c2 -t1 -o /dev/null --exclusive -p highprio "
f"--wrap 'sleep {_HIGH_WRAP_SLEEP_SEC}'",
fatal=True,
)
# Still inside GraceTime: must remain RUNNING.
assert not atf.wait_for_job_state(
low_job,
"PENDING",
timeout=_GRACE_STILL_RUNNING_SEC,
xfail=True,
), (
f"Low-priority job ({low_job}) left RUNNING before GraceTime expired; "
f"requeue must come from GraceTime expiry"
)
assert (
atf.get_job_parameter(low_job, "JobState", quiet=True) == "RUNNING"
), f"Low-priority job ({low_job}) should still be RUNNING during GraceTime"
# EndTime is clamped to PreemptTime+GraceTime while the job is in grace,
# so the equality below holds only because TimeLimit exceeds GraceTime.
# Require EndTime > PreemptTime: a sample taken once grace has already
# expired pairs a re-stamped PreemptTime with a stale EndTime.
preempt_time = end_time = None
for _ in atf.timer():
job = atf.get_jobs(low_job, use_json=True, quiet=True)[low_job]
preempt_time = job["preempt_time"]
end_time = job["end_time"]
if (
preempt_time["set"]
and end_time["set"]
and end_time["number"] > preempt_time["number"]
):
break
assert preempt_time["set"], (
f"Low-priority job ({low_job}) never got a PreemptTime set after the "
f"high-priority job ({high_job}) was submitted"
)
assert end_time["number"] - preempt_time["number"] == _GRACE_TIME_SEC, (
f"Low-priority job ({low_job}) EndTime ({end_time['number']}) should "
f"be exactly GraceTime ({_GRACE_TIME_SEC}s) after PreemptTime "
f"({preempt_time['number']})"
)
# GraceTime expiry requeue (job_requeue_internal), not in-grace completion.
assert atf.wait_for_job_state(
low_job,
"PENDING",
timeout=_REQUEUE_TIMEOUT_SEC,
), f"Low-priority job ({low_job}) was not requeued after GraceTime"
restarts = atf.get_job_parameter(low_job, "Restarts", default=0, quiet=True)
assert restarts == 1, (
f"Low-priority job ({low_job}) should have Restarts=1 after the "
f"preemption requeue, got {restarts}"
)
atf.wait_for_job_state(high_job, "DONE", timeout=60, fatal=True)
# The restarted run exits 0 immediately, so it is never observable as
# RUNNING. Either the job completes (correct) or that exit is turned into a
# second requeue, which shows up as Restarts=2.
state = restarts = None
for _ in atf.timer(timeout=60):
state = atf.get_job_parameter(low_job, "JobState", quiet=True)
restarts = atf.get_job_parameter(low_job, "Restarts", default=0, quiet=True)
if state == "COMPLETED" or restarts >= 2:
break
assert restarts == 1, (
f"Low-priority job ({low_job}) should complete with Restarts=1 after "
f"one GraceTime+REQUEUE preemption; Restarts={restarts} means a later "
f"successful run was treated as another preemption requeue"
)
assert state == "COMPLETED", (
f"Low-priority job ({low_job}) should be COMPLETED after the "
f"post-preempt run, got {state} Restarts={restarts}"
)
_assert_two_run_sacct_history(low_job, _wait_for_sacct_completed(low_job))