blob: 9f593dd9d83380d8fdcd8735b5431313b32fdcc6 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify topology/tree + RouteTree relays a forwarded message once per switch.
Even when the target hostlist spans top-level switches that share no common
root (a "forest" of disconnected trees), every top-level switch that no parent
groups into a forward sublist - whether a bare leaf or the root of its own
tree - must get its own relay sublist instead of dropping every node to the
per-node fallback.
A full-cluster REQUEST_PING uses the forwarding tree, so its controller-side
split is observable in the slurmctld ROUTE debug log:
RELAY : "] switch=<name>" -> one relay sublist per switch
FALLBACK : "find switch containing nodes" -> per-node fan-out (the bug)
The split decision is the only observable: fan-out and per-node fan-out both
deliver the ping, so there is no wire-level outcome to assert on. Each case
installs its own topology and truncates the log first, so its assertions see
only that split.
"""
import logging
import re
import pytest
import atf
# Two leaf switches with no common root.
DISCONNECTED_LEAVES = """\
- topology: poc
cluster_default: true
tree:
switches:
- switch: sw_a
nodes: node[1-16]
- switch: sw_b
nodes: node[17-32]
"""
# Two multi-leaf trees (trunks) with no common root. Each trunk collapses to
# one relay per child leaf - the path that always worked - but the trunks
# share no root, so neither is reachable from the other.
DISCONNECTED_TRUNKS = """\
- topology: poc
cluster_default: true
tree:
switches:
- switch: trunk_a
children: leaf_a1,leaf_a2
- switch: trunk_b
children: leaf_b1,leaf_b2
- switch: leaf_a1
nodes: node[1-8]
- switch: leaf_a2
nodes: node[9-16]
- switch: leaf_b1
nodes: node[17-24]
- switch: leaf_b2
nodes: node[25-32]
"""
# One trunk and a standalone leaf with no common root: the trunk relays per
# child switch (always worked) and the bare leaf relays its own nodes (the
# fix), both in the same split.
MIXED = """\
- topology: poc
cluster_default: true
tree:
switches:
- switch: sw_root1
children: sw_a,sw_b
- switch: sw_a
nodes: node[1-8]
- switch: sw_b
nodes: node[9-16]
- switch: sw_c
nodes: node[17-32]
"""
# All leaves under a single root: connected control, no disconnection.
ROOTED = """\
- topology: poc
cluster_default: true
tree:
switches:
- switch: sw_root
children: sw_a,sw_b
- switch: sw_a
nodes: node[1-16]
- switch: sw_b
nodes: node[17-32]
"""
FALLBACK = "find switch containing nodes" # per-node fallback (the bug)
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 5),
"sbin/slurmctld",
reason="Ticket 25473: leaf-relay fan-out fix in 26.05+",
)
atf.require_version(
(25, 5), "bin/scontrol", reason="The topology.yaml option was added in 25.05"
)
atf.require_nodes(32)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_Core_Memory")
atf.require_config_parameter("TopologyPlugin", "topology/tree")
atf.require_config_parameter_includes("TopologyParam", "RouteTree")
atf.require_config_parameter("SlurmctldDebug", "debug")
atf.require_config_parameter_includes("DebugFlags", "Route")
# Ping cadence is SlurmdTimeout/3, so a full-cluster REQUEST_PING fires
# every few seconds and logs its topology split.
atf.require_config_parameter("SlurmdTimeout", "15")
atf.require_config_file("topology.yaml", DISCONNECTED_LEAVES)
atf.require_slurm_running()
def _logfile():
return atf.get_config_parameter("SlurmctldLogFile", live=False, quiet=True)
def _count(pattern):
out = atf.run_command_output(
f'grep -c -F -- "{pattern}" {_logfile()} || true',
user=atf.properties["slurm-user"],
quiet=True,
)
return int((out or "0").strip() or 0)
def _relayed_switches():
"""Set of switch names that received a relay sublist, parsed from the
per-switch ROUTE split lines (`] switch=<name> ::`)."""
out = atf.run_command_output(
f"grep -oE '] switch=[^ ]+' {_logfile()} || true",
user=atf.properties["slurm-user"],
quiet=True,
)
return {m.group(1) for m in re.finditer(r"] switch=(\S+)", out or "")}
@pytest.fixture
def apply_topology(request):
"""Install the parametrized topology.yaml, reconfigure, and truncate the
log so the test sees only this topology's ROUTE split."""
atf.require_config_file("topology.yaml", request.param)
atf.run_command(
"scontrol reconfigure", user=atf.properties["slurm-user"], fatal=True
)
atf.run_command(
f"truncate -s 0 {_logfile()}",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
@pytest.mark.parametrize(
"apply_topology, expected",
[
pytest.param(DISCONNECTED_LEAVES, {"sw_a", "sw_b"}, id="disconnected_leaves"),
pytest.param(
DISCONNECTED_TRUNKS,
{"leaf_a1", "leaf_a2", "leaf_b1", "leaf_b2"},
id="disconnected_trunks",
),
pytest.param(
MIXED, {"sw_a", "sw_b", "sw_c"}, id="mixed_trunk_and_standalone_leaf"
),
pytest.param(ROOTED, {"sw_a", "sw_b"}, id="rooted_tree"),
],
indirect=["apply_topology"],
)
def test_route_tree_fanout(apply_topology, expected):
"""Each top-level switch relays its own sublist and the per-node fallback
never fires, so the message fans out once per switch."""
relayed = set()
for _t in atf.timer():
relayed = _relayed_switches()
if expected <= relayed:
break
else:
pytest.fail(
f"expected one relay sublist per switch {sorted(expected)}; "
f"saw relays for {sorted(relayed)}"
)
logging.info("relayed switches: %s", sorted(relayed))
assert (
expected == relayed
), f"unexpected relay sublists: expected {sorted(expected)}, saw {sorted(relayed)}"
assert (
_count(FALLBACK) == 0
), f"per-node fallback ('{FALLBACK}') fired; expected per-switch relay"