| ############################################################################ |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| ############################################################################ |
| """Regression: a POWERED_DOWN cloud node must not be left NOT_RESPONDING. |
| |
| A powered down node has no slurmd to answer, and ping_nodes() skips it, so a |
| NODE_STATE_NO_RESPOND flag set on it is never cleared. _build_bitmaps() will |
| not put a NO_RESPOND node into avail_node_bitmap, so after the next |
| reconfigure or restart the node is permanently unschedulable -- and a cloud |
| node that is never scheduled is never resumed. |
| |
| Ticket 25110. |
| """ |
| |
| import pytest |
| |
| import atf |
| |
| pytestmark = pytest.mark.slow |
| |
| node_name = "node1" |
| suspend_time = 10 |
| suspend_timeout = 10 |
| resume_timeout = 10 |
| power_save_interval = 10 |
| # check_node_timers(), which fires a scheduled ResumeAfter, runs on the |
| # controller's 30s PERIODIC_TIMEOUT tick, so a resumeafter=1 can take a full |
| # tick to land on top of the usual scheduling latency. |
| resume_after_timeout = 90 |
| |
| |
| @pytest.fixture(scope="module", autouse=True) |
| def setup(): |
| atf.require_version( |
| (26, 5, 5), |
| "sbin/slurmctld", |
| reason="Ticket 25110: _require_node_reg() sets NO_RESPOND on" |
| " POWERED_DOWN nodes before 26.05.5", |
| ) |
| atf.require_config_parameter("SelectType", "select/cons_tres") |
| atf.require_config_parameter("SelectTypeParameters", "CR_CPU") |
| atf.require_config_parameter("TreeWidth", 65533) |
| # /bin/true never actually brings the node up, so the resume always |
| # fails and the node falls back to POWERED_DOWN at ResumeTimeout. |
| atf.require_config_parameter("ResumeProgram", "/bin/true") |
| atf.require_config_parameter("SuspendProgram", "/bin/true") |
| atf.require_config_parameter("SuspendTime", suspend_time) |
| atf.require_config_parameter("SuspendTimeout", suspend_timeout) |
| atf.require_config_parameter("ResumeTimeout", resume_timeout) |
| # The waits below are budgeted against this scan interval, so pin it |
| # rather than inheriting whatever the installation configures. |
| atf.require_config_parameter_includes( |
| "SlurmctldParameters", f"power_save_interval={power_save_interval}" |
| ) |
| atf.require_config_parameter("NodeName", {node_name: {"State": "CLOUD"}}) |
| atf.require_config_parameter( |
| "PartitionName", {"primary": {"Nodes": "ALL", "Default": "YES"}} |
| ) |
| |
| atf.start_slurmctld(clean=True) |
| |
| yield |
| |
| # conftest only cancels jobs and stops daemons when it started Slurm |
| # itself (it keys off properties["slurm-started"], which |
| # require_slurm_running() sets and start_slurmctld() does not), so do both |
| # here. Otherwise this module's slurmctld outlives it and every later |
| # module inherits its cloud-node config. |
| atf.cancel_all_jobs(fatal=True, quiet=True) |
| atf.stop_slurmctld() |
| |
| |
| @pytest.fixture(scope="function") |
| def powered_down_node(): |
| """Drive the cloud node to DOWN+POWERED_DOWN via a failed resume.""" |
| # The node starts out IDLE+POWERED_DOWN, so wait on DOWN rather than |
| # POWERED_DOWN: only the failed resume can put it DOWN. |
| atf.run_command("sbatch --wrap 'hostname'", fatal=True) |
| # The failed resume is quantized by power_save_interval (10s by default) |
| # at both ends: one tick to issue the resume, ResumeTimeout to expire, |
| # then another tick to notice it and mark the node DOWN. Leave generous |
| # room on top of that for a loaded CI box. |
| atf.wait_for_node_state(node_name, "DOWN", timeout=resume_timeout + 80, fatal=True) |
| st = atf.get_node_parameter(node_name, "state") |
| assert "POWERED_DOWN" in st, ( |
| f"Setup error: cloud {node_name} should be POWERED_DOWN after a " |
| f"failed resume; state={st!r}" |
| ) |
| # As in the module teardown, conftest will not cancel for us because this |
| # module starts slurmctld itself, so these are load-bearing rather than |
| # belt-and-braces: a leftover job would resume the node under the next test. |
| atf.cancel_all_jobs(fatal=True, quiet=True) |
| |
| yield node_name |
| |
| atf.cancel_all_jobs(fatal=True, quiet=True) |
| atf.restart_slurmctld(clean=True) |
| |
| |
| @pytest.mark.parametrize("resume_via", ["state", "resumeafter"]) |
| def test_resume_powered_down_no_not_responding(powered_down_node, resume_via): |
| """resume on a POWERED_DOWN cloud node must not set NOT_RESPONDING.""" |
| node = powered_down_node |
| slurm_user = atf.properties["slurm-user"] |
| |
| if resume_via == "state": |
| atf.run_command( |
| f"scontrol update nodename={node} state=resume", |
| user=slurm_user, |
| fatal=True, |
| ) |
| else: |
| # The sequence reported in ticket 25110: a deferred resume scheduled |
| # with ResumeAfter. It lands on the same NODE_RESUME handling, but |
| # through check_node_timers() rather than the update RPC. |
| atf.run_command( |
| f"scontrol update nodename={node} state=drain resumeafter=1" |
| f" reason=test_141_6", |
| user=slurm_user, |
| fatal=True, |
| ) |
| # Longer than the default: see resume_after_timeout above, the deferred |
| # resume waits on the controller's 30s periodic tick. |
| atf.wait_for_node_state(node, "IDLE", timeout=resume_after_timeout, fatal=True) |
| |
| st = atf.get_node_parameter(node, "state") |
| assert "POWERED_DOWN" in st, ( |
| f"resume must not power up cloud {node}; the NOT_RESPONDING check " |
| f"below is only meaningful while it is still POWERED_DOWN; " |
| f"state={st!r}" |
| ) |
| assert "NOT_RESPONDING" not in st, ( |
| f"POWERED_DOWN cloud {node} must not be NOT_RESPONDING after resume; " |
| f"state={st!r}" |
| ) |
| |
| |
| def test_powered_down_schedulable_after_reconfigure(powered_down_node): |
| """A resumed cloud node must still be schedulable after a reconfigure.""" |
| node = powered_down_node |
| slurm_user = atf.properties["slurm-user"] |
| |
| atf.run_command( |
| f"scontrol update nodename={node} state=resume", |
| user=slurm_user, |
| fatal=True, |
| ) |
| atf.run_command("scontrol reconfigure", user=slurm_user, fatal=True) |
| # Wait for the re-exec'd slurmctld to answer again. get_node_parameter() |
| # pytest.fail()s with a gcore dump if it cannot reach the controller, so |
| # racing it here would be reported as a crash rather than as a race. |
| assert atf.repeat_command_until( |
| "scontrol ping", lambda results: "is UP" in results["stdout"] |
| ), "slurmctld did not come back up after scontrol reconfigure" |
| |
| st = atf.get_node_parameter(node, "state") |
| assert "POWERED_DOWN" in st, ( |
| f"cloud {node} must still be POWERED_DOWN here; the wait below " |
| f"detects it leaving that state, so it would pass without waiting " |
| f"if it had already left; state={st!r}" |
| ) |
| assert "NOT_RESPONDING" not in st, ( |
| f"POWERED_DOWN cloud {node} must not be NOT_RESPONDING after " |
| f"reconfigure; state={st!r}" |
| ) |
| |
| # The node must be picked up by the scheduler again, which for a cloud |
| # node means a resume is issued and it leaves POWERED_DOWN. |
| atf.run_command("sbatch --wrap 'hostname'", fatal=True) |
| # Longer than the default: the scheduler has to run and then power_save |
| # has to issue the resume on its own power_save_interval (10s) tick. |
| assert atf.wait_for_node_state(node, "POWERED_DOWN", reverse=True, timeout=60), ( |
| f"Cloud {node} was never scheduled after reconfigure, so it was never " |
| f"resumed; state={atf.get_node_parameter(node, 'state')!r}" |
| ) |
| |
| |
| def test_powered_down_not_responding_cleared_on_restart(powered_down_node): |
| """A NOT_RESPONDING flag on a powered down node must not survive a restart. |
| |
| This covers the load_all_node_state() half of the fix, the half that heals |
| nodes an older slurmctld already left stuck. With the guard in |
| _require_node_reg() in place the flag can no longer be set through the |
| resume path, so set it explicitly with the documented State=NoResp action, |
| which writes the flag directly and is unaffected by the guard. |
| """ |
| node = powered_down_node |
| slurm_user = atf.properties["slurm-user"] |
| |
| atf.run_command( |
| f"scontrol update nodename={node} state=NoResp", |
| user=slurm_user, |
| fatal=True, |
| ) |
| st = atf.get_node_parameter(node, "state") |
| assert ( |
| "NOT_RESPONDING" in st |
| ), f"Setup error: State=NoResp should have flagged {node}; state={st!r}" |
| assert ( |
| "POWERED_DOWN" in st |
| ), f"Setup error: cloud {node} should still be POWERED_DOWN; state={st!r}" |
| |
| # Restart without clean=True so slurmctld recovers the node state file and |
| # load_all_node_state() actually runs. clean=True passes -c -i, which is |
| # recover=0, and would skip the recovery path entirely. |
| atf.restart_slurmctld() |
| |
| st = atf.get_node_parameter(node, "state") |
| assert "NOT_RESPONDING" not in st, ( |
| f"POWERED_DOWN cloud {node} must not still be NOT_RESPONDING after a " |
| f"slurmctld restart recovered its state; state={st!r}" |
| ) |