blob: ce0c5b00ebf91f220c7bf11ab3e8c055a94ff693 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""slurmd keeps waiting for a temporarily-absent slurmctld (Ticket 24793).
slurmstepd retries its completion RPC to slurmctld forever by design
(_send_complete_batch_script_msg) so accounting is never lost. slurmd only stops
waiting for a step's completion when slurmd ITSELF is shutting down (see
test_105_14). When slurmctld is merely temporarily gone -- a crash that will be
restarted, or a `scontrol reconfigure` that re-execs it -- slurmd is NOT shutting
down, so it must keep WAITING; it must not give up (which would lose accounting)
and must not deadlock. This checks:
- a crashed slurmctld: while it is gone, slurmd (not shutting down) and the
job's slurmstepd both keep waiting -- neither gives up; and
- `scontrol reconfigure` with a job still completing is not treated as a
shutdown, so the job is not abandoned and completes normally.
"""
import pytest
import atf
MESSAGE_TIMEOUT = 5
# KillWait keeps the SIGTERM-ignoring step (and its slurmstepd) alive across the
# moment slurmctld is taken away, so the completion is genuinely still pending.
KILL_WAIT = 10
# Bound for a killed daemon's process to disappear. Use atf's own default
# polling timeout rather than a shorter one, so this does not become a
# spurious failure on a loaded machine.
GONE_BOUND = atf.default_polling_timeout
# Long enough to cover KillWait plus a slurmstepd retry (RETRY_DELAY=15s), to
# confirm the daemons keep waiting rather than giving up.
WAIT_MARGIN = KILL_WAIT + 20
# slurmctld stays up across a reconfigure, so a correctly-handled completion is
# delivered promptly; a gross regression that stalled it would blow this bound.
RECONFIG_DONE_BOUND = 60
pytestmark = [pytest.mark.slow]
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
component="sbin/slurmd",
reason="Ticket 24793: keep waiting while slurmctld is temporarily away",
)
atf.require_nodes(1)
atf.require_config_parameter("MessageTimeout", MESSAGE_TIMEOUT)
atf.require_config_parameter("KillWait", KILL_WAIT)
atf.require_slurm_running()
@pytest.fixture(scope="function")
def restore_cluster():
"""The crash test leaves a SIGTERM-ignoring slurmstepd (and its wedge task)
alive; SIGKILL them and rebuild a clean cluster after each test."""
yield
slurmstepd_exe = f"{atf.properties['slurm-sbin-dir']}/slurmstepd"
pids = atf.pids_from_exe(slurmstepd_exe)
if pids:
# "|| true" already makes this command always succeed, so fatal=True
# would never fire; a failed kill (e.g. an unkillable stepd) must not
# be silently swallowed here, or it shows up later as a confusing
# failure in atf.start_slurm() instead.
atf.run_command(
"kill -9 " + " ".join(str(p) for p in pids) + " 2>/dev/null || true",
user="root",
)
# The '[w]edge' regex matches the wedge task but not pkill's own command
# line, so pkill does not SIGKILL the shell running it.
atf.run_command("pkill -9 -f '[w]edge_job.sh' 2>/dev/null || true", user="root")
atf.start_slurm(clean=True, quiet=True)
def _kill9(exe):
pids = atf.pids_from_exe(exe)
if pids:
pid_list = " ".join(str(p) for p in pids)
atf.run_command(f"kill -9 {pid_list} 2>/dev/null || true", user="root")
def _wait_gone(exe, timeout):
for _ in atf.timer(timeout=timeout):
if not atf.pids_from_exe(exe):
return True
return False
def _submit_completing_job(node):
"""Start a batch job and cancel it so it is COMPLETING, with slurmstepd held
alive (its batch-complete RPC still pending) by a SIGTERM-ignoring script."""
atf.make_bash_script("wedge_job.sh", "trap '' TERM\nwhile true; do sleep 1; done\n")
job_id = atf.submit_job_sbatch(f"-w {node} wedge_job.sh", fatal=True)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
atf.run_command(f"scancel {job_id}", user=atf.properties["slurm-user"], fatal=True)
atf.wait_for_job_state(job_id, "COMPLETING", fatal=True)
return job_id
def test_slurmd_keeps_waiting_for_crashed_slurmctld(restore_cluster):
"""While slurmctld is crashed and slurmd is NOT shutting down, slurmd and the
job's slurmstepd both keep waiting for the completion to become deliverable --
neither gives up (which would lose accounting)."""
node = next(iter(atf.nodes))
slurmctld_exe = f"{atf.properties['slurm-sbin-dir']}/slurmctld"
slurmd_exe = f"{atf.properties['slurm-sbin-dir']}/slurmd"
slurmstepd_exe = f"{atf.properties['slurm-sbin-dir']}/slurmstepd"
_submit_completing_job(node)
assert atf.pids_from_exe(
slurmstepd_exe
), "a slurmstepd should be running the completing job"
# Crash slurmctld but leave slurmd running: slurmd is NOT shutting down, so it
# must keep waiting for the (now undeliverable) completion, not give up.
_kill9(slurmctld_exe)
assert _wait_gone(slurmctld_exe, GONE_BOUND), "slurmctld should be gone"
# After a full retry cycle both must still be waiting. State cannot be queried
# with slurmctld down, so assert on the processes.
atf.run_command(f"sleep {WAIT_MARGIN}", quiet=True, fatal=True)
assert atf.pids_from_exe(
slurmd_exe
), "slurmd must keep waiting for slurmctld, not exit"
assert atf.pids_from_exe(
slurmstepd_exe
), "slurmstepd must keep retrying its completion, not give up"
def test_reconfigure_does_not_abandon_completion(restore_cluster):
"""`scontrol reconfigure` is not a slurmd shutdown, so a still-completing job
is not abandoned. With slurmctld up throughout, the job completes promptly and
normally across the reconfigure (a gross stall would blow the bound)."""
node = next(iter(atf.nodes))
job_id = _submit_completing_job(node)
atf.run_command(
"scontrol reconfigure", user=atf.properties["slurm-user"], fatal=True
)
atf.wait_for_job_state(job_id, "DONE", timeout=RECONFIG_DONE_BOUND, fatal=True)