Merge branch 'nate-i50192-buf' into 'master'
See merge request SchedMD/dev/slurm!4208
diff --git a/src/common/xutf.c b/src/common/xutf.c
index a269125..a1883c6 100644
--- a/src/common/xutf.c
+++ b/src/common/xutf.c
@@ -41,6 +41,12 @@
#include "src/common/xutf.h"
+/* The code point and byte checks below rely on the signedness and width here */
+_Static_assert((utf_code_t) -1 > 0, "utf_code_t must be unsigned");
+_Static_assert(sizeof(utf_code_t) == 4, "utf_code_t must be 32 bits");
+_Static_assert((utf8_t) -1 > 0, "utf8_t must be unsigned");
+_Static_assert(sizeof(utf8_t) == 1, "utf8_t must be 8 bits");
+
/*
* Alias the non-prefixed exported functions to slurm_-prefixed names so the
* serializer plugin links against them via slurm_xlator.h. The macro-backed
@@ -201,9 +207,6 @@
0x3164, /* hangul filler */
};
- if (utf < 0)
- return false;
-
#ifndef NDEBUG
/*
* Wrapping macro will have already check ASCII so just verify it didn't
@@ -218,7 +221,7 @@
* ISO/IEC 2022 framework. The ranges of these code points are
* U+0000..U+001F, U+007F, and U+0080..U+009F
*/
- xassert(!((utf >= 0) && (utf <= 0x8)));
+ xassert(utf > 0x8);
/*
* We are just going to pretend these are not control codes:
* TAB (horizontal tab)
@@ -267,9 +270,6 @@
0x2029, /* PS (paragraph separator) */
};
- if (utf < 0)
- return false;
-
/*
* Unicode 15.0.0: 5.8 Newline Guidelines gives these as
* newlines:
@@ -343,9 +343,6 @@
* U+3000 ideographic space
*/
- if (utf < 0)
- return false;
-
#ifndef NDEBUG
/*
* Wrapping macro will have already check ASCII so just verify it didn't
@@ -408,7 +405,7 @@
return ESLURM_UTF_NULL_CODE;
}
- if ((utf < 0) || (utf > 0x10FFFF))
+ if (utf > 0x10FFFF)
/* outside of UTF codespace */
return ESLURM_UTF_INVALID_CODE;
@@ -633,7 +630,7 @@
slurm_strerror(rc));
return slurm_write_utf8_character(UTF_REPLACEMENT_CODE, dst,
bytes_ptr, log);
- } else if ((utf >= 0) && (utf <= 0x7F)) {
+ } else if (utf <= 0x7F) {
/* UTF8 below 128 is same as ASCII */
dst[0] = utf & 0x7F;
*bytes_ptr = 1;
@@ -714,16 +711,11 @@
return EINVAL;
}
- if ((*src >= 0) && (*src <= 0x7F)) {
+ if ((*src & 0x80) == 0x00) {
/* ASCII: 0xxxxxxx */
bytes = 1;
utf = *src;
- } else if ((*src & 0x80) == 0x00) {
- /* 0xxxxxxx */
-
- bytes = 1;
- utf = *src & 0x7f;
} else if ((*src & 0xe0) == 0xc0) {
/* 110xxxxx 10xxxxxx */
diff --git a/src/conmgr/con.c b/src/conmgr/con.c
index 4bb8394..ae2c18a 100644
--- a/src/conmgr/con.c
+++ b/src/conmgr/con.c
@@ -119,6 +119,7 @@
T(FLAG_RPC_RECV_FORWARD),
T(FLAG_WAIT_ON_EXTRACT),
T(FLAG_IS_TLS_SHUTTING_DOWN),
+ T(FLAG_INITIATE_TLS_SHUTDOWN),
};
#undef T
@@ -1808,12 +1809,27 @@
/* Ask kernel for socket error */
int rc = SLURM_ERROR, err = SLURM_ERROR;
- if ((rc = fd_get_socket_error(fd, &err)))
+ if ((rc = fd_get_socket_error(fd, &err))) {
error("%s: [%s] error while getting socket error: %s",
__func__, con->name, slurm_strerror(rc));
- else if (err) {
- if (err == EPIPE)
- err = SLURM_COMMUNICATIONS_DELAYED_ERROR;
+ } else if (err == EPIPE) {
+ /*
+ * EPIPE here is not separately actionable. It is
+ * usually a delayed error: a prior write() already
+ * returned success and the kernel is only reporting
+ * afterwards that the peer had closed. An RST that
+ * arrives after the peer's FIN can also set EPIPE
+ * without any local write. The connection is being
+ * closed either way and the status code below carries
+ * the condition to the callbacks, so this does not
+ * warrant error(). A write that actually fails, and
+ * therefore drops queued output, is still reported by
+ * write_output().
+ */
+ err = SLURM_COMMUNICATIONS_DELAYED_ERROR;
+ log_flag(CONMGR, "%s: [%s] socket error encountered while polling: %s",
+ __func__, con->name, slurm_strerror(err));
+ } else if (err) {
error("%s: [%s] socket error encountered while polling: %s",
__func__, con->name, slurm_strerror(err));
}
diff --git a/src/interfaces/cred.c b/src/interfaces/cred.c
index a109b4c..5c207c9 100644
--- a/src/interfaces/cred.c
+++ b/src/interfaces/cred.c
@@ -273,6 +273,7 @@
xfree(arg->job_alias_list);
xfree(arg->job_comment);
xfree(arg->job_constraints);
+ xfree(arg->job_extra);
xfree(arg->job_licenses);
xfree(arg->job_hostlist);
xfree(arg->sock_core_rep_count);
@@ -283,6 +284,7 @@
xfree(arg->job_partition);
xfree(arg->job_qos);
xfree(arg->job_reservation);
+ xfree(arg->job_selinux_context);
xfree(arg->job_std_err);
xfree(arg->job_std_in);
xfree(arg->job_std_out);
diff --git a/src/slurmctld/job_mgr.c b/src/slurmctld/job_mgr.c
index 732be9e..c26a44c 100644
--- a/src/slurmctld/job_mgr.c
+++ b/src/slurmctld/job_mgr.c
@@ -17967,17 +17967,23 @@
/*
* We want this job to have the requeued/preempted state in the
* accounting logs. Set a new submit time so the restarted
- * job looks like a new job.
+ * job looks like a new job. Do not rebuild node_bitmap_cg for
+ * a job that is already completing. A bit_copy(node_bitmap)
+ * would resurrect nodes whose epilog has already finished
+ * (and which make_node_idle() has therefore already released)
+ * without updating node_cnt to match.
*/
if (preempt) {
job_state_set(job_ptr, JOB_PREEMPTED);
- build_cg_bitmap(job_ptr);
+ if (!is_completing)
+ build_cg_bitmap(job_ptr);
if (!is_completed && !is_completing)
job_completion_logger(job_ptr, true);
job_state_set(job_ptr, JOB_REQUEUE);
} else {
job_state_set(job_ptr, JOB_REQUEUE);
- build_cg_bitmap(job_ptr);
+ if (!is_completing)
+ build_cg_bitmap(job_ptr);
if (!is_completed && !is_completing)
job_completion_logger(job_ptr, true);
}
@@ -18049,7 +18055,7 @@
*/
acct_policy_add_job_submit(job_ptr, false);
- acct_policy_update_pending_job(job_ptr, true);
+ acct_policy_update_pending_job(job_ptr, false);
if (flags & JOB_SPECIAL_EXIT) {
job_state_set_flag(job_ptr, JOB_SPECIAL_EXIT);
diff --git a/src/slurmctld/node_scheduler.c b/src/slurmctld/node_scheduler.c
index 53b87e5..6f92943 100644
--- a/src/slurmctld/node_scheduler.c
+++ b/src/slurmctld/node_scheduler.c
@@ -4673,6 +4673,7 @@
if (job_ptr->node_bitmap_cg) {
for (int i = 0;
+ job_ptr->node_bitmap_cg &&
(node_ptr = next_node_bitmap(job_ptr->node_bitmap_cg, &i));
i++) {
if (IS_NODE_DOWN(node_ptr)) {
@@ -4703,7 +4704,13 @@
}
}
- if (agent_args->node_count == 0) {
+ /*
+ * cleanup_completing() above may have requeued the job. That frees
+ * node_bitmap_cg and resets the job record, so create_kill_job_msg()
+ * below would build a credential from cleared fields. Drop whatever
+ * the loop gathered rather than send a message built from stale state.
+ */
+ if (!job_ptr->node_bitmap_cg || (agent_args->node_count == 0)) {
FREE_NULL_HOSTLIST(agent_args->hostlist);
xfree(agent_args);
hostlist_destroy(kill_hostlist);
diff --git a/src/slurmctld/reservation.c b/src/slurmctld/reservation.c
index 0875f6b..4e50c42 100644
--- a/src/slurmctld/reservation.c
+++ b/src/slurmctld/reservation.c
@@ -8198,6 +8198,9 @@
}
if (job_ptr->resv_name) {
+ list_itr_t *iter;
+ slurmctld_resv_t *res2_ptr;
+
if (!job_ptr->resv_ptr) {
rc2 = validate_job_resv(job_ptr);
if (rc2 != SLURM_SUCCESS)
@@ -8281,8 +8284,17 @@
*/
args.job_end_time = job_end_time;
args.job_start_time = job_start_time;
- list_for_each_ro(resv_list, _foreach_job_test_resv_overlap,
- &args);
+
+ /*
+ * This needs to be an iterator since
+ * _foreach_job_test_resv_overlap() may eventually call
+ * _advance_resv_time() which may call _generate_resv_id() which
+ * will deadlock the resv_list lock.
+ */
+ iter = list_iterator_create(resv_list);
+ while ((res2_ptr = list_next(iter)))
+ _foreach_job_test_resv_overlap(res2_ptr, &args);
+ list_iterator_destroy(iter);
if (slurm_conf.debug_flags & DEBUG_FLAG_RESERVATION) {
char *nodes = bitmap2node_name(*node_bitmap);
@@ -8324,10 +8336,24 @@
* run and get it's required nodes (if any)
*/
for (i = 0; ; i++) {
+ slurmctld_resv_t *resv;
+ list_itr_t *iter;
args.job_end_time = job_end_time;
args.job_start_time = job_start_time;
lic_resv_time = (time_t) 0;
- list_for_each_ro(resv_list, _foreach_job_test_no_resv, &args);
+
+ /*
+ * This needs to be an iterator since
+ * _foreach_job_test_no_resv() may eventually call
+ * _advance_resv_time() which may eventually call
+ * _generate_resv_id() which will deadlock the resv_list lock.
+ */
+ iter = list_iterator_create(resv_list);
+ while ((resv = list_next(iter))) {
+ if (_foreach_job_test_no_resv(resv, &args) < 0)
+ break;
+ }
+ list_iterator_destroy(iter);
if (resv_exc_ptr) {
free_core_array(&resv_exc_ptr->exc_cores);
diff --git a/testsuite/README b/testsuite/README
index 68528d8..621a720 100644
--- a/testsuite/README
+++ b/testsuite/README
@@ -703,6 +703,7 @@
test_101_2 Test sacct SLUID support including resizes and OriginalSLUID
test_101_3 Test sacct OverSubscribe and Exclusive reporting from slurmdbd
test_101_4 Test basic sacct --json
+test_101_5 Test sacct Restarts and Start are per-run after a requeue
test_102_# Testing of sacctmgr options.
=========================================
diff --git a/testsuite/expect/test16.4 b/testsuite/expect/test16.4
index f797d94..e9ced98 100755
--- a/testsuite/expect/test16.4
+++ b/testsuite/expect/test16.4
@@ -32,6 +32,7 @@
set file_prog "$test_name.prog"
set job_id 0
set matches 0
+set timeout $max_job_delay
proc cleanup {} {
global job_id file_prog
@@ -52,7 +53,6 @@
#
# Spawn initial program via srun
#
-set timeout $max_job_delay
spawn $salloc -N $node_cnt -t2 $srun -n $task_cnt --overcommit ./$file_prog
set init_id $spawn_id
expect {
@@ -112,7 +112,6 @@
# Attach to initial program, just get one tasks output
#
set matches 0
-set timeout 10
set attach_pid [spawn $sattach -l --output-filter=[expr $task_cnt - 1] $job_id.0]
set attach_id $spawn_id
expect {
@@ -141,7 +140,6 @@
# Attach to initial program
#
set matches 0
-set timeout 10
spawn $sattach -vv -l $job_id.0
set attach_id $spawn_id
expect {
diff --git a/testsuite/python/lib/atf.py b/testsuite/python/lib/atf.py
index 2641d68..70b7dd5 100644
--- a/testsuite/python/lib/atf.py
+++ b/testsuite/python/lib/atf.py
@@ -510,6 +510,19 @@
failures.append(reason)
return
+ reason = "Issue 50974: Known issue about slurmd stuck waiting for a completing job during shutdown. Seems fixed in 26.05+"
+ component = "sbin/slurmd"
+ if (
+ component in bin_path
+ and "pause_for_job_completion" in bt
+ and "_rpc_terminate_job" in bt
+ ):
+ if get_version(component, slurm_prefix=slurm_prefix) >= (26, 5):
+ failures.append(reason)
+ else:
+ xfailures.append(reason)
+ return
+
reason = "Issue 51060: slurmctld - SIGABRT: _kill_job_step(): Assertion (job_ptr->job_id == job_step_kill_msg->step_id.job_id) failed"
component = "sbin/slurmctld"
if (
diff --git a/testsuite/python/tests/test_101_5.py b/testsuite/python/tests/test_101_5.py
new file mode 100644
index 0000000..39e33ea
--- /dev/null
+++ b/testsuite/python/tests/test_101_5.py
@@ -0,0 +1,363 @@
+############################################################################
+# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+############################################################################
+"""Regression test for bug 25240: per-run accounting after a requeue.
+
+Each run of a requeued batch job must have its own accounting row with a
+distinct restart count (0 for the first run, 1 for the requeued run) and a
+valid start time. scontrol requeue is documented for running, suspended or
+finished jobs; the requeueable states reachable without a suspend are covered:
+
+- COMPLETING: requeuing while the Epilog is still running.
+- RUNNING: an ordinary requeue of a running job.
+- COMPLETED: requeuing a job that has already finished.
+"""
+
+import collections
+import os
+
+import pytest
+
+import atf
+
+# The Epilog blocks while a flag file exists, so the COMPLETING window stays
+# open exactly until the test has issued the requeue and removed the flag.
+# Hard cap (seconds) so a dead test cannot wedge the node in COMPLETING.
+EPILOG_MAX_WAIT = 30
+# Short job body so the job reaches COMPLETING quickly.
+JOB_SLEEP = 1
+# Long enough that the job is reliably observed RUNNING before it is requeued,
+# short enough that waiting for the requeued run to finish stays cheap.
+RUNNING_JOB_SLEEP = 10
+
+ACCOUNT = "acct25240"
+USER = atf.properties["test-user"]
+
+# sacct renders an unset timestamp as one of these; a zero start_time -- the
+# symptom reported alongside the duplicated restart count -- prints as Unknown.
+EMPTY_TIMES = ("", "Unknown", "None")
+
+Row = collections.namedtuple("Row", "restarts start submit state end dbindex")
+
+# A requeue path to drive a job through, and what its accounting must show.
+# start_precedes_requeue records whether the job body ran to completion before
+# the requeue, which is what lets the first run's Start be ordered against the
+# requeue's Submit.
+Scenario = collections.namedtuple(
+ "Scenario",
+ "wait_state job_sleep hold_epilog first_state start_precedes_requeue",
+)
+
+SCENARIOS = {
+ "completing": Scenario("COMPLETING", JOB_SLEEP, True, "COMPLETED", True),
+ "running": Scenario("RUNNING", RUNNING_JOB_SLEEP, False, "REQUEUED", False),
+ "finished": Scenario("COMPLETED", JOB_SLEEP, False, "COMPLETED", True),
+}
+
+
+@pytest.fixture(scope="module", autouse=True)
+def setup(epilog_script):
+ # Both tests need the requeued instance to start promptly, which relies on
+ # SchedulerParameters=requeue_delay. Without it the requeue is deferred by
+ # AuthInfo cred_expire (~120s) and the tests only ever time out.
+ atf.require_version(
+ (25, 11),
+ reason="Ticket 25240: SchedulerParameters=requeue_delay was added in 25.11",
+ )
+
+ atf.require_accounting(modify=True)
+ # The corrupting accounting update is only emitted from the requeue limit
+ # re-validation path when limit enforcement is on; without this the bug
+ # cannot be reproduced.
+ atf.require_config_parameter_includes("AccountingStorageEnforce", "limits")
+
+ atf.require_config_parameter("Epilog", epilog_script)
+ # EpilogSlurmctld would add an extra COMPLETING barrier that masks the race.
+ atf.require_config_parameter("EpilogSlurmctld", None)
+
+ # A requeued batch job is not eligible to run again until requeue_delay
+ # elapses (default: AuthInfo cred_expire, ~120s). Set it to 0 and schedule
+ # tightly so the requeued instance starts promptly once the node frees.
+ _require_scheduler_parameters(requeue_delay=0, bf_interval=1, sched_interval=1)
+
+ atf.require_slurm_running()
+
+ # With limit enforcement on, the submitting user needs a valid association.
+ atf.run_command(
+ f"sacctmgr -i add account {ACCOUNT}",
+ user=atf.properties["slurm-user"],
+ fatal=True,
+ )
+ atf.run_command(
+ f"sacctmgr -i add user {USER} account={ACCOUNT}",
+ user=atf.properties["slurm-user"],
+ fatal=True,
+ )
+
+ yield
+
+ # Delete the association before the parent account.
+ atf.run_command(
+ f"sacctmgr -i delete user {USER} account={ACCOUNT}",
+ user=atf.properties["slurm-user"],
+ fatal=True,
+ )
+ atf.run_command(
+ f"sacctmgr -i delete account {ACCOUNT}",
+ user=atf.properties["slurm-user"],
+ fatal=True,
+ )
+
+
+@pytest.fixture(scope="module")
+def epilog_flag():
+ """Flag file that keeps the Epilog (and thus COMPLETING) alive."""
+ return os.path.abspath("epilog_flag")
+
+
+@pytest.fixture
+def epilog_gate(epilog_flag):
+ """Yield the Epilog flag path, lowered before and after the test.
+
+ Raising the flag is left to the test body because only some requeue paths
+ need the COMPLETING window held open. The teardown is the safety net that
+ keeps a failed test from wedging the node in COMPLETING.
+ """
+ atf.run_command(f"rm -f {epilog_flag}", fatal=True)
+ yield epilog_flag
+ atf.run_command(f"rm -f {epilog_flag}", fatal=True)
+
+
+@pytest.fixture(scope="module")
+def epilog_script(epilog_flag):
+ """Epilog that blocks on the flag file to hold the COMPLETING window open."""
+ script = "epilog.sh"
+ atf.make_bash_script(
+ script,
+ f"""for i in $(seq 1 {EPILOG_MAX_WAIT}); do
+ [ -f "{epilog_flag}" ] || exit 0
+ sleep 1
+done
+""",
+ )
+ return os.path.abspath(script)
+
+
+def _restart_rows(job_id):
+ """Return a Row per sacct accounting record for the job.
+
+ --duplicates makes sacct emit every accounting row for the job, including
+ the row for the original (first) run, which sacct otherwise omits.
+ DBIndex identifies which record each row is, so it is collected alongside
+ Submit. SLUID would be redundant (it renders the same value as DBIndex)
+ and OriginalSLUID does not exist on every supported version.
+ """
+ output = atf.run_command_output(
+ f"sacct -X -j {job_id} --duplicates --noheader --parsable2 "
+ "--format=Restarts,Start,Submit,State,End,DBIndex",
+ fatal=True,
+ )
+ rows = []
+ for line in output.splitlines():
+ fields = [field.strip() for field in line.split("|")]
+ if len(fields) == len(Row._fields):
+ rows.append(Row(*fields))
+ return rows
+
+
+def _by_submit(rows):
+ """Return rows oldest-Submit first; a requeue resets the Submit time.
+
+ Submit has one-second resolution, so DBIndex breaks a tie: the requeued
+ run is inserted after the original and gets the higher index.
+ """
+ return sorted(rows, key=lambda row: (row.submit, int(row.dbindex)))
+
+
+def _assert_distinct_records(rows, job_id):
+ """Assert each run got its own database record.
+
+ The bug is a job_start landing on the previous run's record, so the
+ restart counts alone are only a symptom; pin the record identity too.
+ """
+ assert len({row.dbindex for row in rows}) == len(rows), (
+ f"Ticket 25240: expected a distinct DBIndex per run of job {job_id}; "
+ f"got {rows}"
+ )
+
+
+def _require_scheduler_parameters(**params):
+ """Set SchedulerParameters subparameters, replacing any existing values.
+
+ Appending would leave an earlier token for the same key in place, and
+ slurmctld reads the first occurrence -- so a config that already sets one
+ of these (the perf variants set sched_interval=30) would win over us.
+ """
+ current = atf.get_config_parameter("SchedulerParameters", live=False, default="")
+ kept = [
+ token
+ for token in (current or "").split(",")
+ if token and token.split("=")[0] not in params
+ ]
+ kept += [f"{key}={value}" for key, value in params.items()]
+ atf.require_config_parameter("SchedulerParameters", ",".join(kept))
+
+
+@pytest.mark.parametrize(
+ "scenario",
+ [
+ pytest.param(
+ SCENARIOS["completing"],
+ id="completing",
+ marks=pytest.mark.xfail(
+ atf.get_version("sbin/slurmctld") < (26, 5, 4),
+ reason="Ticket 25240: restart count fix on requeue landed in 26.05.4",
+ ),
+ ),
+ pytest.param(SCENARIOS["running"], id="running"),
+ pytest.param(SCENARIOS["finished"], id="finished"),
+ ],
+)
+def test_requeue_keeps_distinct_restart_cnt(scenario, epilog_gate):
+ """Each run of a requeued job must keep its own accounting record.
+
+ Drives a job to the scenario's state, requeues it, and pins the per-run
+ accounting contract: two sacct --duplicates rows with distinct DBIndex,
+ restart counts [0, 1] in Submit order, each row keeping its own Start and
+ final State, and the non-duplicate view resolving to the requeued run.
+
+ Only the COMPLETING scenario is corrupted by ticket 25240, so only it
+ carries the xfail marker; the other scenarios pass on an unfixed tree.
+ """
+
+ if scenario.hold_epilog:
+ atf.run_command(f"touch {epilog_gate}", fatal=True)
+
+ # Each run appends the restart count it was handed, to be checked against
+ # the Restarts recorded for it. The first run is not given the variable.
+ restart_count_file = os.path.abspath("restart_count.out")
+ job_id = atf.submit_job_sbatch(
+ f"--account={ACCOUNT} --requeue -N1 "
+ f"--wrap 'echo ${{SLURM_RESTART_COUNT:-0}} >> {restart_count_file}; "
+ f"sleep {scenario.job_sleep}'",
+ fatal=True,
+ )
+
+ assert atf.wait_for_job_state(job_id, scenario.wait_state), (
+ f"Job {job_id} never reached {scenario.wait_state}; cannot exercise this "
+ f"requeue path"
+ )
+
+ atf.run_command(
+ f"scontrol requeue {job_id}",
+ user=atf.properties["slurm-user"],
+ fatal=True,
+ )
+
+ # Release the Epilog so the requeued instance can start immediately; the
+ # fixture teardown lowers the flag too if we failed before reaching here.
+ atf.run_command(f"rm -f {epilog_gate}", fatal=True)
+
+ # Wait until the requeued instance has its own row and has recorded a Start.
+ # Key on Submit order, not on the restart count: the bug rewrites the old
+ # row's count to 1, so a count-keyed wait is satisfied by the corruption
+ # itself and would break before the second row ever appears. We don't wait
+ # for the transient PENDING state because requeue_delay=0 and tight
+ # scheduling can move the job PENDING->RUNNING faster than a poll interval.
+ rows = []
+ for _ in atf.timer(fatal=True):
+ rows = _restart_rows(job_id)
+ if len(rows) >= 2 and _by_submit(rows)[-1].start not in EMPTY_TIMES:
+ break
+
+ _assert_distinct_records(rows, job_id)
+
+ # Core assertion: bind each restart_cnt to its row by Submit order. A
+ # requeue resets the Submit time, so the original run (earlier Submit) must
+ # carry restart_cnt 0 and the requeued run (later Submit) restart_cnt 1.
+ # The bug writes the new count into the old row instead.
+ assert [row.restarts for row in _by_submit(rows)] == ["0", "1"], (
+ f"Ticket 25240: expected restart_cnt [0, 1] in Submit order for job "
+ f"{job_id}; got {rows}"
+ )
+
+ # Both runs must keep their own real Start. Merely checking for a non-empty
+ # value would still pass if the requeued run's Start were written into the
+ # old row, so bind the timestamps to their rows.
+ first_row, second_row = _by_submit(rows)
+ assert all(
+ row.start not in EMPTY_TIMES for row in (first_row, second_row)
+ ), f"Ticket 25240: accounting row with missing Start for job {job_id}: {rows}"
+
+ if scenario.start_precedes_requeue:
+ assert first_row.start < second_row.submit < second_row.start, (
+ f"Ticket 25240: expected the first run's Start to precede the requeue "
+ f"and the requeued run's Start to follow it for job {job_id}; got {rows}"
+ )
+ else:
+ # The requeue lands in the same second the job started, so its Submit
+ # cannot be ordered against the first Start here. Comparing the two
+ # Starts still catches the clobber: a rewritten old row would carry the
+ # requeued run's Start.
+ assert (
+ first_row.start < second_row.start
+ ), f"Expected each run to keep its own Start for job {job_id}; got {rows}"
+
+ # The requeued instance must run to completion with the same job id, and the
+ # counts must survive the job-complete write -- the field reports show the
+ # corruption on completed rows.
+ assert atf.wait_for_job_state(
+ job_id, "COMPLETED"
+ ), f"Requeued job {job_id} never completed"
+
+ rows = []
+ for _ in atf.timer(fatal=True):
+ rows = _restart_rows(job_id)
+ if len(rows) >= 2 and all(row.end not in EMPTY_TIMES for row in rows):
+ break
+
+ _assert_distinct_records(rows, job_id)
+
+ assert [row.restarts for row in _by_submit(rows)] == ["0", "1"], (
+ f"Ticket 25240: expected restart counts [0, 1] in Submit order to survive "
+ f"completion of job {job_id}; got {rows}"
+ )
+
+ # Bind State per row rather than asserting it over the set: the requeue
+ # leaves each run with its own final state, and a clobbered record would
+ # otherwise be hidden by an all() that both rows happen to satisfy.
+ first_row, second_row = _by_submit(rows)
+ assert (first_row.state, second_row.state) == (scenario.first_state, "COMPLETED"), (
+ f"Expected the original run of job {job_id} to end {scenario.first_state} "
+ f"and the requeued run to end COMPLETED; got {rows}"
+ )
+
+ # Without --duplicates sacct promises exactly the most recent record, so
+ # the requeued run must be the one it picks.
+ output = atf.run_command_output(
+ f"sacct -X -j {job_id} --noheader --parsable2 --format=Restarts,Submit",
+ fatal=True,
+ )
+ default_rows = [line.strip() for line in output.splitlines() if line.strip()]
+ assert (
+ len(default_rows) == 1
+ ), f"Expected one non-duplicate row for job {job_id}; got {default_rows}"
+
+ restarts, submit = (field.strip() for field in default_rows[0].split("|"))
+ latest_row = _by_submit(rows)[-1]
+ assert (restarts, submit) == (latest_row.restarts, latest_row.submit), (
+ f"Expected the non-duplicate row for job {job_id} to be the requeued "
+ f"run {latest_row}; got Restarts={restarts} Submit={submit}"
+ )
+
+ # Tie the records back to what the runs themselves saw: SLURM_RESTART_COUNT
+ # is handed to the requeued instance, so it must agree with the Restarts
+ # stored for that run. This is the invariant the bug broke.
+ seen_counts = atf.run_command_output(
+ f"cat {restart_count_file}", fatal=True
+ ).split()
+
+ assert seen_counts == [row.restarts for row in _by_submit(rows)], (
+ f"Expected the restart count seen by each run of job {job_id} to match "
+ f"the Restarts recorded for it; got {seen_counts} against {rows}"
+ )