blob: ce4efba872c33fad72c889aa65c237b42826ce62 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""
Test that a job requeued while completing keeps its completing nodes
accounted when slurmctld restarts after one of its nodes was removed from
slurm.conf.
_sync_jobs_to_conf() fails such a job with JOB_NODE_FAIL because its node
list no longer resolves, and job_state_set() replaces the whole state word,
so the job used to lose JOB_COMPLETING there. _sync_nodes_to_jobs() and
_sync_nodes_to_comp_job() both skip a job without that flag, so no node's
comp_job_cnt was incremented, while the requeue that follows still rebuilt
a full node_bitmap_cg -- every epilog complete that arrived afterwards
underflowed the counter.
Ticket: 25792
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
@pytest.fixture(scope="module", autouse=True)
def setup():
# Fixed in 26.05.5 and 26.11.0; nothing released in between lacks it.
atf.require_version(
(26, 5, 5),
component="sbin/slurmctld",
reason="Ticket 25792: completing flag dropped on a failed pending job",
)
atf.require_auto_config("needs to set Epilog and remove a node from slurm.conf")
# One more than the job asks for, so a node can be removed and the job,
# which is requeued rather than terminal, still has somewhere to run.
atf.require_nodes(3)
# The Epilog below blocks until the test releases it; bound it so an
# aborted run cannot leave a node completing for the rest of the suite.
atf.require_config_parameter("PrologEpilogTimeout", 300)
atf.require_slurm_running()
@pytest.fixture
def epilog_proceed(tmp_path):
"""Sentinel the Epilog waits on, always released so none is left hung."""
proceed = str(tmp_path / "epilog_proceed")
yield proceed
atf.run_command(f"touch {proceed}", fatal=True, quiet=True)
@pytest.fixture
def stop_removed_slurmd():
"""Stops slurmds of nodes removed from slurm.conf, which shutdown misses."""
nodes = []
yield nodes.append
for node in nodes:
atf.run_command(
f"pkill -f '[s]lurmd -N {node}$'", user="root", fatal=True, quiet=True
)
def _logfile():
return atf.get_config_parameter("SlurmctldLogFile", live=False, quiet=True)
def _log_grep(pattern):
"""Matching slurmctld log lines, or an empty string."""
return atf.run_command_output(
f"grep -F -- '{pattern}' {_logfile()} || true",
user=atf.properties["slurm-user"],
quiet=True,
).strip()
def _remove_node_from_conf(node_name):
"""Drop node_name from slurm.conf, splitting its NodeName range if needed."""
config_file = f"{atf.properties['slurm-config-dir']}/slurm.conf"
original_config = atf.run_command_output(
f"cat {config_file}", user=atf.properties["slurm-user"], quiet=True
)
new_config_lines = []
removed = False
for line in original_config.splitlines():
match = re.match(r"(?i)^ *NodeName=(\S+)(.*)$", line)
if match is None:
new_config_lines.append(line)
continue
node_names = atf.node_range_to_list(match.group(1))
if node_name not in node_names:
new_config_lines.append(line)
continue
removed = True
node_index = node_names.index(node_name)
node_names.remove(node_name)
if not node_names:
continue
# A per-node Port list must shrink with the node range, or slurmctld
# refuses the config ("Port count must equal that of NodeName
# records"). Mirrors the remainder handling in atf.set_node_parameter().
attributes = []
for word in match.group(2).split():
parameter, _, value = word.partition("=")
if parameter.lower() == "port":
port_list = atf.range_to_list(value)
if node_index < len(port_list):
port_list.pop(node_index)
if port_list:
word = f"Port={atf.list_to_range(port_list)}"
attributes.append(word)
node_range = atf.node_list_to_range(node_names)
remainder = f"NodeName={node_range} {' '.join(attributes)}"
new_config_lines.append(remainder.rstrip())
assert removed, f"{node_name} was not found in {config_file}"
new_config_string = "\n".join(new_config_lines)
atf.run_command(
f"echo '{new_config_string}' > {config_file}",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
def test_requeued_completing_job_survives_node_removal(
tmp_path, epilog_proceed, stop_removed_slurmd
):
"""A requeued completing job keeps its nodes accounted across a restart."""
slurm_user = atf.properties["slurm-user"]
# The Epilog blocks until released, so the job stays COMPLETING while we
# requeue it, drop a node and restart slurmctld.
epilog_script = str(tmp_path / "epilog.sh")
atf.make_bash_script(
epilog_script,
f"""while [ ! -f {epilog_proceed} ]; do sleep 0.5; done
""",
)
atf.set_config_parameter("Epilog", epilog_script)
job_id = atf.submit_job_sbatch(
"--requeue -N2 -o /dev/null --wrap 'sleep 300'", fatal=True
)
assert atf.wait_for_job_state(
job_id, "RUNNING", timeout=60
), f"Job {job_id} never started"
node_list = atf.get_job_parameter(job_id, "NodeList", fatal=True)
allocated_nodes = atf.node_range_to_list(node_list)
assert len(allocated_nodes) == 2, f"Expected 2 allocated nodes, got {node_list}"
victim_node = allocated_nodes[-1]
# Requeue while running. The blocked Epilog holds the job in COMPLETING,
# which is where it must still be when slurmctld comes back.
atf.run_command(f"scontrol requeue {job_id}", user=slurm_user, fatal=True)
assert atf.wait_for_job_state(
job_id, "COMPLETING", timeout=60
), f"Job {job_id} did not enter COMPLETING after being requeued"
# Only the recovery pass should be visible to the assertions below.
atf.run_command(f"truncate -s 0 {_logfile()}", user=slurm_user, fatal=True)
# Removing an allocated node is what makes _sync_jobs_to_conf() fail the
# job on the next startup.
_remove_node_from_conf(victim_node)
stop_removed_slurmd(victim_node)
atf.restart_slurmctld()
# Regression: with the completing flag dropped, _sync_nodes_to_comp_job()
# skipped the job entirely and never logged this.
assert atf.repeat_until(
lambda: _log_grep(f"JobId={job_id} in completing state"),
lambda output: output != "",
timeout=60,
), f"slurmctld did not recover Job {job_id} as completing after the restart"
# The documented invariant: while its Epilog runs, the surviving node is
# COMPLETING and takes no new allocations.
assert atf.wait_for_node_state(
allocated_nodes[0], "COMPLETING", timeout=60
), f"Node {allocated_nodes[0]} was not completing after the restart"
# Release the Epilog so the surviving node reports its epilog complete.
atf.run_command(f"touch {epilog_proceed}", fatal=True)
# The job was requeued, so it settles back as PENDING once the last epilog
# complete clears the flag.
assert atf.wait_for_job_state(
job_id, "PENDING", timeout=120
), f"Job {job_id} did not settle as PENDING after the Epilog was released"
# The positive consequence of a correctly decremented comp_job_cnt: the
# node stops completing and can be scheduled on again.
assert atf.wait_for_node_state(
allocated_nodes[0], "COMPLETING", timeout=120, reverse=True
), f"Node {allocated_nodes[0]} never left COMPLETING"
# Regression: each epilog complete landed on a node whose comp_job_cnt was
# never incremented, because the sync above had skipped it.
underflows = _log_grep("comp_job_cnt underflow")
assert underflows == "", f"slurmctld logged comp_job_cnt underflow:\n{underflows}"