| ############################################################################ |
| # 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" |