blob: 9bfbda8b0acce2395461c3425af054e5098cff57 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""slurmd can shut down while a job is still completing, without losing it (Ticket 24793).
A slurmstepd retries its completion RPC to slurmctld forever by design, so
accounting is never lost. Before the fix, shutting slurmd down while a job was
still completing made slurmd's terminate handler wait forever for that stepd;
slurmd's shutdown drain (_wait_for_all_threads) never returned, so slurmd could
not be stopped.
The fix lets slurmd, once it has itself been asked to shut down, stop waiting
for the stepd and return from the terminate handler WITHOUT running the epilog
or sending epilog_complete -- leaving the job COMPLETING for slurmctld to
re-drive on restart. slurmctld's own shutdown stays immediate (it never waits on
job state), and the slurmstepd survives slurmd (with its still-pending
completion), so nothing is discarded. This checks that slurmd stops and that the
stepd (and its completion) is preserved, not killed. The companion test_105_13
covers the temporary-absence (crash / reconfigure) case, where slurmd is NOT
shutting down and so must keep waiting.
"""
import pytest
import atf
MESSAGE_TIMEOUT = 5
# KillWait keeps the SIGTERM-ignoring step (and its slurmstepd) genuinely
# running -- so the job is really still completing -- when the shutdown begins.
KILL_WAIT = 15
# slurmctld must exit well within this even with a stuck completion: its
# shutdown must stay independent of job state (a controller that waited for
# completing jobs would hang forever, never finite). At least the atf default
# polling timeout so this does not become a spurious failure under load.
CTLD_SHUTDOWN_BOUND = atf.default_polling_timeout
# Finite upper bound on slurmd shutting down. The pre-fix bug was an INFINITE
# hang (the drain is _wait_for_all_threads(MAX(prolog_timeout, epilog_timeout)),
# and both default to NO_VAL16 = wait-forever unless PrologEpilogTimeout is set --
# which this test intentionally leaves unset), so any finite bound catches the
# regression. It is generous because, while slurmctld is unreachable, conmgr is
# slow to process slurmd's shutdown, so the fix's abandon can take tens of
# seconds to fire.
SHUTDOWN_BOUND = 150
# 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
pytestmark = [pytest.mark.slow]
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
component="sbin/slurmd",
reason="Ticket 24793: slurmd shutdown while a job is completing",
)
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():
"""These tests deliberately leave an orphaned, SIGTERM-ignoring slurmstepd
(holding a pending completion) alive; SIGKILL it and its wedge task so the
clean restart starts from a truly idle node."""
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 _signal(exe, sig):
pids = atf.pids_from_exe(exe)
if pids:
pid_list = " ".join(str(p) for p in pids)
atf.run_command(f"kill {sig} {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_shutdown_lets_slurmd_stop(restore_cluster):
"""`scontrol shutdown` with a still-completing job lets both slurmctld and
slurmd exit -- slurmctld promptly (it never waits on the job) and slurmd
within a finite bound (the pre-fix bug was an infinite hang)."""
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"
job_id = _submit_completing_job(node)
# The job must still genuinely be completing (its slurmstepd still up)
# right before the shutdown, or this test proves nothing: if the step
# had already finished, slurmd would exit promptly with or without the
# fix, and the assertions below would pass vacuously.
assert atf.pids_from_exe(
slurmstepd_exe
), "a slurmstepd should still be running the completing job"
assert (
atf.get_job_parameter(job_id, "JobState") == "COMPLETING"
), "job should still be completing right before the shutdown"
# Graceful full-cluster shutdown while the job is still completing.
atf.run_command("scontrol shutdown", user=atf.properties["slurm-user"], fatal=True)
# slurmctld must exit promptly -- coupling its shutdown to job state would
# hang it forever (never finite).
assert _wait_gone(slurmctld_exe, CTLD_SHUTDOWN_BOUND), (
"slurmctld did not shut down promptly with a job still completing; its "
"shutdown must stay independent of job state (Ticket 24793)"
)
# slurmd must finish shutting down. Before the fix it waited forever for the
# still-completing job's stepd and hung here.
assert _wait_gone(slurmd_exe, SHUTDOWN_BOUND), (
f"slurmd did not shut down within {SHUTDOWN_BOUND}s with a job still "
f"completing (Ticket 24793)"
)
def test_shutdown_preserves_the_stepd(restore_cluster):
"""When slurmd is stopped while a completion cannot be delivered (slurmctld
already gone), slurmd abandons the wait and exits, but must NOT take the
slurmstepd (or its pending completion) down with it -- the completion is
preserved for delivery once the cluster is back."""
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"
# Take slurmctld away first, so the stepd's completion is genuinely
# undeliverable, then ask slurmd to stop. This isolates the abandon path
# (with slurmctld gone, pre-fix slurmd hangs forever here).
_signal(slurmctld_exe, "-9")
assert _wait_gone(slurmctld_exe, GONE_BOUND), "slurmctld should be gone"
_signal(slurmd_exe, "-TERM")
# slurmd abandons the undeliverable completion and exits ...
assert _wait_gone(slurmd_exe, SHUTDOWN_BOUND), (
f"slurmd did not shut down within {SHUTDOWN_BOUND}s while a completion "
f"was pending (Ticket 24793)"
)
# ... but the slurmstepd survives, still holding the pending completion.
assert atf.pids_from_exe(slurmstepd_exe), (
"slurmstepd should survive slurmd shutdown so it can still deliver the "
"job's completion (Ticket 24793)"
)