blob: 2341e05908938c425b095ef71522e631ffcc5335 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test that nodes held for an expedited requeue stay held until it is done.
A three node job loses one node; one survivor's epilog finishes at once and
the other's waits on a gate, leaving the first held only by the requeue.
"""
import os
import pathlib
import pytest
import atf
NODE_COUNT = 3
SHARED_DIR = None
GATE_FILE = None
FAST_DONE_FILE = None
DRAINED_FILE = None
# How long the held node is watched; an unheld node would be released and
# scheduled well within this.
HOLD_WINDOW = 3
# Triggers are processed every 16 s (TRIGGER_INTERVAL + 1).
TRIGGER_DELAY = 19
# Setup
@pytest.fixture(scope="module", autouse=True)
def setup():
global SHARED_DIR, GATE_FILE, FAST_DONE_FILE, DRAINED_FILE
SHARED_DIR = f"{atf.module_tmp_path}/shared"
GATE_FILE = f"{SHARED_DIR}/gate"
FAST_DONE_FILE = f"{SHARED_DIR}/fast_epilog_done"
DRAINED_FILE = f"{SHARED_DIR}/drained"
atf.require_version(
(26, 11),
component="sbin/slurmctld",
reason="Issue 51019: node holds were added in 26.11",
)
atf.require_version(
(25, 11),
component="bin/sbatch",
reason="Issue 51019: sbatch --requeue=expedite was added in 25.11",
)
atf.require_version(
(25, 11),
component="bin/scontrol",
reason="Issue 51019: the EXPEDITING job state was added in 25.11",
)
atf.require_nodes(NODE_COUNT)
atf.require_config_parameter_includes(
"SlurmctldParameters", "enable_expedited_requeue"
)
atf.require_config_parameter_includes(
"SchedulerParameters", ("batch_sched_delay", 0)
)
os.makedirs(SHARED_DIR)
os.chmod(SHARED_DIR, 0o777)
atf.require_slurm_running()
@pytest.fixture(scope="module")
def nodes(setup):
"""Return the node taken down, the fast node and the slow node."""
failed, fast, slow = sorted(atf.nodes)[:NODE_COUNT]
return failed, fast, slow
@pytest.fixture(scope="function")
def gate():
"""Hold the slow node's epilog until the test lets it go."""
path = pathlib.Path(GATE_FILE)
path.touch()
os.chmod(GATE_FILE, 0o666)
pathlib.Path(FAST_DONE_FILE).unlink(missing_ok=True)
yield lambda: path.unlink(missing_ok=True)
path.unlink(missing_ok=True)
@pytest.fixture(scope="function")
def epilog(nodes, gate):
"""Set an Epilog that finishes at once on the fast node only."""
_, fast, _ = nodes
prev_epilog = atf.get_config_parameter("Epilog")
script = f"{SHARED_DIR}/epilog.sh"
atf.make_bash_script(
script,
f"""#!/bin/bash
if [ "$SLURMD_NODENAME" = "{fast}" ]; then
touch {FAST_DONE_FILE}
exit 0
fi
# Capped in case a test fails.
deadline=$((SECONDS + 120))
while [ -e {GATE_FILE} ] && [ $SECONDS -lt $deadline ]; do
sleep 0.2
done
exit 0
""",
)
os.chmod(script, 0o755)
atf.set_config_parameter("Epilog", script)
yield
atf.set_config_parameter("Epilog", prev_epilog)
pathlib.Path(script).unlink(missing_ok=True)
@pytest.fixture(scope="function", autouse=True)
def restore_nodes(setup):
"""Resume any node taken out of service and clear triggers."""
yield
for node, node_dict in atf.get_nodes(quiet=True, fatal=True).items():
if {"DOWN", "DRAIN", "FAIL"} & set(node_dict["state"]):
atf.run_command(
f"scontrol update nodename={node} state=resume",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.run_command(
f"strigger --clear --user={atf.properties['slurm-user']}",
user=atf.properties["slurm-user"],
quiet=True,
)
def node_states(node):
return set(atf.get_nodes(quiet=True, fatal=True)[node]["state"])
@pytest.fixture(scope="function")
def held(nodes, epilog, gate):
"""Return a job requeued with its fast node finished and held."""
failed, fast, slow = nodes
# Allow the requeued job to restart on the two nodes left.
job_id = atf.submit_job_sbatch(
f"--requeue=expedite -N{NODE_COUNT - 1}-{NODE_COUNT} "
f'-w {failed},{fast},{slow} --wrap "sleep infinity"',
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
atf.run_command(
f"scontrol update nodename={failed} state=down reason=test_node_failure",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.wait_for_file(FAST_DONE_FILE, fatal=True)
return job_id, failed, fast, slow
def hold_is_kept(fast, window=HOLD_WINDOW):
"""Return whether the node stays COMPLETING for the whole window."""
for _ in atf.timer(timeout=window, poll_interval=1, quiet=True):
if "COMPLETING" not in node_states(fast):
return False
return True
def test_held_node_is_kept_until_requeued(held, gate):
"""Test that a held node is not given to another job before the requeue."""
job_id, failed, fast, slow = held
other_id = atf.submit_job_sbatch(f'-N1 -w {fast} --wrap "true"', fatal=True)
assert hold_is_kept(
fast
), f"{fast} left COMPLETING while it was held for the expedited requeue"
assert (
atf.get_job_parameter(other_id, "JobState", fatal=True) == "PENDING"
), f"Job {other_id} took {fast} while it was held for the expedited requeue"
gate()
assert atf.wait_for_job_state(
job_id, "RUNNING"
), f"Job {job_id} did not get its nodes back after the requeue"
assert (
int(atf.get_job_parameter(job_id, "Restarts", fatal=True)) > 0
), f"Job {job_id} is running its first time, not requeued"
assert (
atf.get_job_parameter(other_id, "JobState", fatal=True) == "PENDING"
), f"Job {other_id} was given {fast} ahead of the job it was held for"
def test_drain_of_held_node_waits_for_release(held, gate):
"""Test that a held node is only reported DRAINED once released."""
job_id, failed, fast, slow = held
program = f"{SHARED_DIR}/on_drained.sh"
atf.make_bash_script(program, f'echo "$@" >> {DRAINED_FILE}\n')
os.chmod(program, 0o755)
pathlib.Path(DRAINED_FILE).unlink(missing_ok=True)
atf.run_command(
f"strigger --set --node={fast} --drained --program={program}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"scontrol update nodename={fast} state=drain reason=test_held_drain",
user=atf.properties["slurm-user"],
fatal=True,
)
assert hold_is_kept(
fast, TRIGGER_DELAY
), f"{fast} left COMPLETING while it was held"
states = node_states(fast)
assert (
"DRAIN" in states
), f"{fast} should be draining while it is held, got {states}"
assert (
atf.run_command_exit(f"test -e {DRAINED_FILE}", quiet=True) != 0
), f"{fast} was reported DRAINED while it was still held"
gate()
assert atf.wait_for_node_state(
fast, "COMPLETING", reverse=True
), f"{fast} stayed COMPLETING after it was released"
assert atf.wait_for_file(
DRAINED_FILE
), f"{fast} was never reported DRAINED once it was released"
assert "DRAIN" in node_states(fast), f"{fast} lost its drain when it was released"
def test_held_node_taken_down_stays_down(held, gate):
"""Test that a held node taken down stays down when released."""
job_id, failed, fast, slow = held
atf.run_command(
f"scontrol update nodename={fast} state=down reason=test_held_down",
user=atf.properties["slurm-user"],
fatal=True,
)
assert "DOWN" in node_states(fast), f"{fast} was not taken down"
gate()
assert atf.wait_for_node_state(
slow, "COMPLETING", reverse=True
), f"{slow} never finished completing after the requeue"
assert atf.wait_for_job_state(
job_id, "EXPEDITING"
), f"Job {job_id} was not requeued after a held node was taken down"
assert "DOWN" in node_states(
fast
), f"{fast} did not stay down after its hold was released"
assert "COMPLETING" not in node_states(
fast
), f"{fast} went back to COMPLETING when its hold was released"
log_file = atf.get_config_parameter("SlurmctldLogFile", live=False, quiet=True)
result = atf.run_command(
f"grep -F underflow {log_file}",
user=atf.properties["slurm-user"],
quiet=True,
)
assert result["exit_code"] in (
0,
1,
), f"Could not search {log_file}: {result['stderr']}"
assert (
"hold_cnt underflow" not in result["stdout"]
), f"Releasing the hold underflowed a hold count: {result['stdout']}"
def test_held_node_returned_to_service_stays_held(held, gate):
"""Test that a held node that goes down and is resumed stays held."""
job_id, failed, fast, slow = held
atf.run_command(
f"scontrol update nodename={fast} state=down reason=test_held_bounce",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"scontrol update nodename={fast} state=resume",
user=atf.properties["slurm-user"],
fatal=True,
)
other_id = atf.submit_job_sbatch(f'-N1 -w {fast} --wrap "true"', fatal=True)
assert hold_is_kept(
fast
), f"{fast} left COMPLETING after it came back while it was held"
assert (
atf.get_job_parameter(other_id, "JobState", fatal=True) == "PENDING"
), f"Job {other_id} took {fast} after it came back while it was held"
gate()
assert atf.wait_for_job_state(
job_id, "RUNNING"
), f"Job {job_id} did not get its nodes back after the requeue"
assert (
int(atf.get_job_parameter(job_id, "Restarts", fatal=True)) > 0
), f"Job {job_id} is running its first time, not requeued"
assert (
atf.get_job_parameter(other_id, "JobState", fatal=True) == "PENDING"
), f"Job {other_id} was given {fast} ahead of the job it was held for"