| ############################################################################ | |
| # 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 | |
| # 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 _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\ntouch wedge_ready\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.wait_for_file("wedge_ready", 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)) | |
| 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. | |
| assert not atf.stop_slurmctld(signal="KILL"), "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.slurmd_pids(node), "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) |