blob: 2c0be2ebc42728fccb6831c51abe5b1729fb6736 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify hetjobs with external components run, end and survive restarts on external nodes."""
import re
import pytest
import atf
# Enough external nodes for every component of the hetjobs that
# test_concurrent_external_hetjobs runs at once, so that test does not depend
# on CR_CPU packing their -n1 components onto shared nodes.
component_count = 3
concurrent_count = 2
node_names = [f"extnode{i}" for i in range(1, component_count * concurrent_count + 1)]
# The external nodes live in their own partition so an external job targets
# only them; the compute node with a slurmd stays in the default partition.
partition_name = "external"
# The one node that runs a slurmd, for the regular leader of mixed hetjobs
compute_node = "node1"
# Printed by the mixed hetjob leader's batch script, so the test can tell that
# the script actually ran rather than that the job merely held an allocation
batch_marker = "mixed_leader_ran"
@pytest.fixture(scope="module", autouse=True)
def setup():
# Gating sbatch covers slurmctld, which is always upgraded first
atf.require_version(
(26, 5, 3),
"bin/sbatch",
reason="Issue 50997: external hetjobs are available in 26.05.3",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
# Overwrite rather than append: the CR_* allocation types are mutually
# exclusive, so adding CR_CPU to a config already setting CR_Core_Memory
# yields "cr_core_memory,CR_CPU", which slurmctld rejects.
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
# Only the backfill scheduler starts hetjobs, so every job here waits on a
# backfill cycle. Shorten it rather than spend the session budget waiting.
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
# Either one makes "scancel <leader>+1" cancel every component
atf.require_config_parameter_excludes("SchedulerParameters", "whole_hetjob")
atf.require_config_parameter_excludes("SchedulerParameters", "whole_pack")
# This module starts no slurmdbd, so finished jobs are checked through
# scontrol
atf.require_accounting(enabled=False)
atf.require_config_parameter("MinJobAge", 300, ">=")
# Define the EXTERNAL nodes statically rather than creating them with
# "scontrol create". A dynamically created node is also flagged
# DYNAMIC_NORM, which sends the allocation path down the alias list and
# address lookup code that has nothing to resolve for an external node.
# The compute node needs NodeAddr: slurmctld has to open connections to
# its slurmd, and the bare node name does not resolve here. The external
# nodes deliberately get no address, since nothing should ever try to
# contact a node that has no slurmd.
node_config = {compute_node: {"CPUs": 4, "NodeAddr": "localhost"}}
for node_name in node_names:
node_config[node_name] = {"CPUs": 4, "State": "EXTERNAL"}
atf.require_config_parameter("NodeName", node_config)
atf.require_config_parameter(
"PartitionName",
{
"primary": {"Nodes": compute_node, "Default": "YES"},
partition_name: {"Nodes": ",".join(node_names)},
},
)
# require_slurm_running() starts a slurmd for every NodeName line, but an
# EXTERNAL node has none by definition. Start the controller, then only
# the compute node's slurmd, as test_141_1 does for static CLOUD nodes.
atf.start_slurmctld(clean=True)
atf.start_slurmd(compute_node, wait="idle")
yield
# Not fatal: scancel refuses a pending non-leader hetjob component
atf.cancel_all_jobs(quiet=True)
# stop_slurmctld() reports failures by returning them rather than failing
failures = atf.stop_slurmctld(also_slurmds=True)
assert failures is None, f"Failures stopping the daemons: {failures}"
@pytest.fixture(autouse=True)
def cancel_jobs_after_test():
"""Cancel each test's jobs so the next one finds the nodes free.
Not fatal: scancel refuses a pending non-leader hetjob component.
"""
yield
atf.cancel_all_jobs(quiet=True)
def get_components(leader_job_id, count):
"""Return the leader's count component job ids, sorted, leader first."""
het_job_expression = atf.get_job_parameter(leader_job_id, "HetJobIdSet", fatal=True)
assert het_job_expression is not None, "Leader is missing HetJobIdSet"
component_list = sorted(atf.range_to_list(het_job_expression))
assert (
len(component_list) == count
), f"Expected {count} components, got {component_list}"
assert (
component_list[0] == leader_job_id
), f"Leader {leader_job_id} is not the lowest component id in {component_list}"
return component_list
def verify_external_hetjob(leader_job_id):
"""Assert the leader owns component_count components, each running on an
external node.
Returns the component job ids in offset order and the jobs they were
checked against.
"""
component_list = get_components(leader_job_id, component_count)
# Wait for every component to run
for component_job_id in component_list:
atf.wait_for_job_state(component_job_id, "RUNNING", fatal=True)
jobs_dict = atf.get_jobs(fatal=True)
for offset, component_job_id in enumerate(component_list):
assert component_job_id in jobs_dict, f"Component {component_job_id} not found"
component_dict = jobs_dict[component_job_id]
assert (
component_dict["HetJobId"] == leader_job_id
), f"Component {component_job_id} HetJobId != leader {leader_job_id}"
assert (
component_dict["HetJobOffset"] == offset
), f"Component {component_job_id} HetJobOffset != {offset}"
assert (
component_dict["JobState"] == "RUNNING"
), f"Component {component_job_id} is not RUNNING"
# Each component must have landed on external nodes. NodeList is a
# hostlist expression, so expand it rather than comparing it whole.
for component_node in atf.node_range_to_list(component_dict["NodeList"]):
assert component_node in node_names, (
f"Component {component_job_id} ran on {component_node}, "
f"expected an external node ({node_names})"
)
node_state = atf.get_node_parameter(component_node, "state")
assert (
"EXTERNAL" in node_state
), f"Node {component_node} is not in EXTERNAL state"
assert (
"IDLE" not in node_state
), f"Node {component_node} is IDLE under component {component_job_id}"
return component_list, jobs_dict
def hetjob_snapshot(leader_job_id):
"""Return what a controller restart must preserve of each component."""
jobs_dict = atf.get_jobs(fatal=True)
assert leader_job_id in jobs_dict, f"Leader {leader_job_id} not found"
het_job_expression = jobs_dict[leader_job_id].get("HetJobIdSet")
assert het_job_expression is not None, "Leader is missing HetJobIdSet"
component_list = atf.range_to_list(het_job_expression)
missing = set(component_list) - set(jobs_dict)
assert not missing, f"Components {missing} of {leader_job_id} not found"
return {
component_job_id: {
field: jobs_dict[component_job_id].get(field)
for field in ("HetJobId", "HetJobOffset", "JobState", "NodeList")
}
for component_job_id in component_list
}
def slurmd_start_time(node_name):
"""Return the slurmd_start_time slurmctld reports for node_name, or None."""
start_time = atf.get_node_parameter(node_name, "slurmd_start_time")
if isinstance(start_time, dict):
start_time = start_time.get("number")
return start_time or None
def backfill_cycles():
"""Return how many backfill cycles slurmctld has completed."""
output = atf.run_command_output("sdiag", fatal=True, quiet=True)
match = re.search(r"Backfilling stats.*?Total cycles:\s*(\d+)", output, re.S)
assert match, f"No backfill cycle count in sdiag output:\n{output}"
return int(match.group(1))
def test_external_hetjob():
"""
An external hetjob submits and runs on external nodes via sbatch.
"""
args = " : ".join([f"--external -n1 -t5 -p {partition_name}"] * component_count)
leader_job_id = atf.submit_job_sbatch(args, fatal=True)
atf.wait_for_job_state(leader_job_id, "RUNNING", fatal=True)
verify_external_hetjob(leader_job_id)
def test_concurrent_external_hetjobs():
"""
Two external hetjobs run at the same time without interfering.
Both are RUNNING in one snapshot, and each keeps its own components, every
one running on an external node.
"""
args = " : ".join([f"--external -n1 -t5 -p {partition_name}"] * component_count)
leader_job_ids = [
atf.submit_job_sbatch(args, fatal=True) for _ in range(concurrent_count)
]
for leader_job_id in leader_job_ids:
atf.wait_for_job_state(leader_job_id, "RUNNING", fatal=True)
# One snapshot, so this shows the hetjobs running at the same time rather
# than each having run at some point.
jobs_dict = atf.get_jobs(fatal=True)
for leader_job_id in leader_job_ids:
assert (
jobs_dict[leader_job_id]["JobState"] == "RUNNING"
), f"Leader {leader_job_id} is not RUNNING alongside the others"
for leader_job_id in leader_job_ids:
verify_external_hetjob(leader_job_id)
def test_sbatch_mixed_hetjob():
"""
A mixed hetjob submitted by sbatch runs, leader's batch script and all.
test_105_22 inspects this shape while it is still pending; this runs it.
The script trails every component, even though it belongs to the leader:
sbatch takes each component's options from the front of the command line
after trimming the script's arguments off the end.
"""
script_name = "sbatch_mixed_leader.sh"
atf.make_bash_script(script_name, f"echo {batch_marker}\nsleep infinity\n")
leader_job_id = atf.submit_job_sbatch(
f"-n1 -t5 -p primary"
f" : -n1 -t5 --external -p {partition_name} {script_name}",
fatal=True,
)
component_list = get_components(leader_job_id, 2)
for component_job_id in component_list:
atf.wait_for_job_state(component_job_id, "RUNNING", fatal=True)
jobs_dict = atf.get_jobs(fatal=True)
leader_nodes = atf.node_range_to_list(jobs_dict[leader_job_id]["NodeList"])
assert leader_nodes, "Leader was allocated no nodes"
for leader_node in leader_nodes:
assert (
leader_node not in node_names
), f"Leader ran on external node {leader_node}"
external_nodes = atf.node_range_to_list(jobs_dict[component_list[1]]["NodeList"])
assert external_nodes, "External component was allocated no nodes"
for external_node in external_nodes:
assert (
external_node in node_names
), f"External component ran on {external_node}, expected one of {node_names}"
atf.assert_file_contents(
jobs_dict[leader_job_id]["StdOut"],
batch_marker,
contains=True,
message=f"Leader {leader_job_id} never ran its batch script",
)
def test_external_hetjob_component_cancel():
"""
Cancelling one component of a running external hetjob ends only that
component and returns its node to IDLE, still EXTERNAL.
"""
args = " : ".join([f"--external -n1 -t5 -p {partition_name}"] * component_count)
leader_job_id = atf.submit_job_sbatch(args, fatal=True)
component_list, jobs_dict = verify_external_hetjob(leader_job_id)
cancelled_job_id = component_list[1]
sibling_job_ids = [component_list[0], component_list[2]]
cancelled_nodes = atf.node_range_to_list(jobs_dict[cancelled_job_id]["NodeList"])
sibling_nodes = {
node
for job_id in sibling_job_ids
for node in atf.node_range_to_list(jobs_dict[job_id]["NodeList"])
}
# The node can only go idle if no sibling is on it
assert not set(cancelled_nodes) & sibling_nodes, (
f"Component {cancelled_job_id} on {cancelled_nodes} shares a node with "
f"its siblings on {sibling_nodes}"
)
atf.run_command(f"scancel {leader_job_id}+1", fatal=True)
atf.wait_for_job_state(cancelled_job_id, "CANCELLED", fatal=True)
# Exactly IDLE and EXTERNAL, so a lingering COMPLETING fails it too
atf.wait_for_node_state(
cancelled_nodes,
["IDLE", "EXTERNAL"],
operator=atf.SetMatch.EQUAL,
fatal=True,
)
jobs_dict = atf.get_jobs(fatal=True)
for sibling_job_id in sibling_job_ids:
assert jobs_dict[sibling_job_id]["JobState"] == "RUNNING", (
f"Component {sibling_job_id} is {jobs_dict[sibling_job_id]['JobState']} "
f"after {leader_job_id}+1 was cancelled"
)
def test_mixed_hetjob_completes():
"""
A mixed hetjob ends when its leader's script exits: every component
completes and the external node goes back to IDLE. The script sees the
external component as het group 1, though that component has no script.
"""
script_name = "mixed_leader_exits.sh"
atf.make_bash_script(script_name, f"env\necho {batch_marker}\n")
leader_job_id = atf.submit_job_sbatch(
f"-n1 -t5 -p primary"
f" : -n1 -t5 --external -p {partition_name} {script_name}",
fatal=True,
)
component_list = get_components(leader_job_id, 2)
external_job_id = component_list[1]
# The script exits as soon as it starts, so RUNNING can come and go
# between two polls. Wait for the end state directly.
for component_job_id in component_list:
atf.wait_for_job_state(component_job_id, "COMPLETED", fatal=True)
jobs_dict = atf.get_jobs(fatal=True)
external_nodes = atf.node_range_to_list(jobs_dict[external_job_id]["NodeList"])
assert external_nodes, "External component was allocated no nodes"
for external_node in external_nodes:
assert (
external_node in node_names
), f"External component ran on {external_node}, expected one of {node_names}"
atf.wait_for_node_state(
external_nodes, ["IDLE", "EXTERNAL"], operator=atf.SetMatch.EQUAL, fatal=True
)
stdout_path = jobs_dict[leader_job_id]["StdOut"]
atf.assert_file_contents(
stdout_path,
batch_marker,
contains=True,
message=f"Leader {leader_job_id} never ran its batch script",
)
output = atf.run_command_output(f"cat {stdout_path}", fatal=True)
expected = {
"SLURM_HET_SIZE": len(component_list),
"SLURM_JOB_ID_HET_GROUP_1": external_job_id,
"SLURM_JOB_NODELIST_HET_GROUP_1": jobs_dict[external_job_id]["NodeList"],
}
for name, value in expected.items():
assert re.search(
rf"^{name}={re.escape(str(value))}$", output, re.MULTILINE
), f"Missing {name}={value} in the leader's environment"
def test_external_nodes_reused():
"""
A hetjob pinned to the nodes of a running external hetjob waits for them,
then runs on exactly those nodes once the first one is cancelled.
"""
# --exclusive so each component holds its whole node; a -n1 component
# would leave CPUs the second hetjob could start on alongside it
first_args = " : ".join(
[f"--external --exclusive -n1 -t5 -p {partition_name}"] * component_count
)
first_leader_job_id = atf.submit_job_sbatch(first_args, fatal=True)
first_component_list, jobs_dict = verify_external_hetjob(first_leader_job_id)
first_nodes = [
jobs_dict[component_job_id]["NodeList"]
for component_job_id in first_component_list
]
second_args = " : ".join(
f"--external --exclusive -n1 -t5 -p {partition_name} -w {node}"
for node in first_nodes
)
second_leader_job_id = atf.submit_job_sbatch(second_args, fatal=True)
second_component_list = get_components(second_leader_job_id, component_count)
# Only backfill starts hetjobs, and the submission already set Reason, so
# count cycles instead. The first may have begun before the submission.
first_cycle = backfill_cycles()
# Poll at the bf_interval cadence rather than every timeout/10
for _ in atf.timer(poll_interval=1):
if backfill_cycles() - first_cycle >= 2:
break
else:
pytest.fail(f"Backfill never evaluated hetjob {second_leader_job_id}")
jobs_dict = atf.get_jobs(fatal=True)
for component_job_id in second_component_list:
assert jobs_dict[component_job_id]["JobState"] == "PENDING", (
f"Component {component_job_id} is "
f"{jobs_dict[component_job_id]['JobState']} while {first_nodes} are "
f"held by hetjob {first_leader_job_id}"
)
atf.run_command(f"scancel {first_leader_job_id}", fatal=True)
for component_job_id in first_component_list:
atf.wait_for_job_state(component_job_id, "CANCELLED", fatal=True)
for component_job_id in second_component_list:
atf.wait_for_job_state(component_job_id, "RUNNING", fatal=True)
jobs_dict = atf.get_jobs(fatal=True)
for offset, component_job_id in enumerate(second_component_list):
second_node = jobs_dict[component_job_id]["NodeList"]
assert second_node == first_nodes[offset], (
f"Component {component_job_id} ran on {second_node}, expected "
f"{first_nodes[offset]} from cancelled hetjob {first_leader_job_id}"
)
@pytest.mark.parametrize("shape", ["external", "mixed", "held"])
def test_hetjob_survives_controller_restart(shape):
"""
A hetjob with external components keeps every component across a
controller restart, whether running or held, and a held one runs once
released.
An external component has no script for slurmctld to save, so recovery
must keep a pending external leader that has none.
"""
if shape == "mixed":
script_name = "restart_mixed_leader.sh"
atf.make_bash_script(script_name, f"echo {batch_marker}\nsleep infinity\n")
leader_job_id = atf.submit_job_sbatch(
f"-n1 -t5 -p primary"
f" : -n1 -t5 --external -p {partition_name} {script_name}",
fatal=True,
)
for component_job_id in get_components(leader_job_id, 2):
atf.wait_for_job_state(component_job_id, "RUNNING", fatal=True)
# Restart only once the script runs, so its launch is not left to the
# restarted controller
atf.assert_file_contents(
atf.get_job_parameter(leader_job_id, "StdOut", fatal=True),
batch_marker,
contains=True,
message=f"Leader {leader_job_id} never ran its batch script",
)
registered_start_time = slurmd_start_time(compute_node)
assert registered_start_time, f"The {compute_node} slurmd never registered"
else:
hold = "--hold " if shape == "held" else ""
args = " : ".join(
[f"{hold}--external -n1 -t5 -p {partition_name}"] * component_count
)
leader_job_id = atf.submit_job_sbatch(args, fatal=True)
if shape == "external":
verify_external_hetjob(leader_job_id)
before = hetjob_snapshot(leader_job_id)
if shape == "held":
assert all(
component["JobState"] == "PENDING" for component in before.values()
), f"Held hetjob {leader_job_id} is not PENDING: {before}"
atf.restart_slurmctld()
if shape == "mixed":
# slurmctld does not save slurmd_start_time, so it reads unset until
# the slurmd registers again, a few seconds after the restart
for _ in atf.timer(poll_interval=0.5):
if slurmd_start_time(compute_node) == registered_start_time:
break
else:
pytest.fail(f"The {compute_node} slurmd never registered again")
after = hetjob_snapshot(leader_job_id)
if shape == "held":
assert (
after[leader_job_id]["JobState"] == "PENDING"
), f"Held leader {leader_job_id} is {after[leader_job_id]['JobState']} after the restart"
assert (
after == before
), f"Hetjob {leader_job_id} changed across the restart: {before} -> {after}"
if shape == "external":
verify_external_hetjob(leader_job_id)
elif shape == "mixed":
# StdOut is only reported for a batch job
stdout_path = atf.get_job_parameter(leader_job_id, "StdOut", fatal=True)
assert stdout_path, f"Leader {leader_job_id} has no StdOut after the restart"
atf.assert_file_contents(
stdout_path,
batch_marker,
contains=True,
message=f"Leader {leader_job_id} lost its batch output across the restart",
)
external_job_id = sorted(after)[1]
for node in atf.node_range_to_list(after[external_job_id]["NodeList"]):
assert "IDLE" not in atf.get_node_parameter(
node, "state"
), f"Node {node} is IDLE under component {external_job_id}"
else:
atf.run_command(f"scontrol release {leader_job_id}", fatal=True)
verify_external_hetjob(leader_job_id)