| ############################################################################ |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| ############################################################################ |
| """Client tooling against a heterogeneous job running with --stepmgr. |
| |
| sbcast has to resolve --jobid=<leader> and --jobid=<leader>+<offset> through |
| the controller and send each component its own job_id, because the |
| <leader>+<offset> form is not resolvable on the target slurmd after reroute. |
| squeue --only-job-state has to report every component of the hetjob. |
| |
| Both already have coverage against a plain hetjob elsewhere in the suite, |
| so this only adds the stepmgr combination. |
| """ |
| |
| import re |
| |
| import pytest |
| |
| import atf |
| |
| pytestmark = pytest.mark.slow |
| |
| HET_OPTS = "--stepmgr -N1 --ntasks=1 -t2 --exclusive : -N1 --ntasks=1 -t2 --exclusive" |
| |
| |
| @pytest.fixture(scope="module", autouse=True) |
| def setup(): |
| # The client tools upgrade last, so gating on sbcast >= 26.11 already |
| # implies slurmd and slurmctld are >= 26.11. |
| atf.require_version( |
| (26, 11), |
| component="bin/sbcast", |
| reason="Issue 50976: hetjob + stepmgr requires sbcast 26.11+", |
| ) |
| atf.require_nodes(2) |
| atf.require_config_parameter_includes("PrologFlags", "Contain") |
| # Het jobs are only started by backfill; don't wait for its full cycle |
| atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1)) |
| atf.require_config_parameter("SchedulerType", "sched/backfill") |
| atf.require_slurm_running() |
| |
| if atf.get_config_parameter("SlurmdUser") != "root(0)": |
| pytest.skip("Issue 50976: sbcast requires SlurmdUser to be root") |
| |
| |
| def _start_hetjob(): |
| """Submit a running 2-component stepmgr hetjob and return its leader id.""" |
| |
| leader_job_id = atf.submit_job_sbatch( |
| f"-o /dev/null {HET_OPTS} --wrap='sleep infinity'", fatal=True |
| ) |
| atf.wait_for_job_state(leader_job_id, "RUNNING", fatal=True) |
| return leader_job_id |
| |
| |
| def test_sbcast_hetjob_stepmgr(): |
| """sbcast reaches the leader, and each component by +<offset>.""" |
| |
| leader_job_id = _start_hetjob() |
| bcast_dir = f"bcast_{leader_job_id}" |
| |
| payload = "sbcast_payload" |
| atf.make_bash_script(payload, "true\n") |
| |
| # A relative destination resolves against BcastParameters' directory when |
| # one is configured, and against the invoking working directory otherwise. |
| atf.run_command(f"mkdir -p {bcast_dir}", fatal=True) |
| |
| # The bare leader id fans out to every component; the +<offset> form |
| # targets one component and is the form that is not resolvable on the |
| # target slurmd once the request has been rerouted. |
| atf.run_command( |
| f"sbcast -f --jobid={leader_job_id} {payload} {bcast_dir}/file", fatal=True |
| ) |
| |
| jobs = atf.get_jobs(leader_job_id, fatal=True) |
| components = sorted( |
| atf.range_to_list(jobs[leader_job_id]["HetJobIdSet"]), |
| key=lambda job_id: jobs[job_id]["HetJobOffset"], |
| ) |
| |
| # --exclusive puts the components on different nodes, so the resolved job |
| # id and node list tell apart a request that reached its own component |
| # from one that collapsed onto the leader. |
| nodes = [jobs[job_id]["NodeList"] for job_id in components] |
| if nodes[0] == nodes[1]: |
| pytest.fail( |
| f"The components should be on different nodes for the routing" |
| f" assertions to be meaningful, got {nodes}" |
| ) |
| |
| for offset, job_id in enumerate(components): |
| result = atf.run_command( |
| f"sbcast -vv -f --jobid={leader_job_id}+{offset} {payload}" |
| f" {bcast_dir}/file_comp{offset}", |
| fatal=True, |
| ) |
| assert re.search(rf"^sbcast: jobid\s+= {job_id}$", result["stderr"], re.M), ( |
| f"sbcast --jobid={leader_job_id}+{offset} must resolve to component" |
| f" job {job_id}.\nstderr:\n{result['stderr']}" |
| ) |
| node = jobs[job_id]["NodeList"] |
| assert re.search(rf"^sbcast: node_list\s+= {node}$", result["stderr"], re.M), ( |
| f"sbcast --jobid={leader_job_id}+{offset} must target component" |
| f" job {job_id}'s node {node}.\nstderr:\n{result['stderr']}" |
| ) |
| |
| for name in ("file", "file_comp0", "file_comp1"): |
| atf.assert_file_contents(f"{bcast_dir}/{name}", "true", contains=True) |
| |
| |
| def test_squeue_only_job_state_hetjob_stepmgr(): |
| """squeue --only-job-state reports every component of a stepmgr hetjob.""" |
| |
| leader_job_id = _start_hetjob() |
| |
| output = ( |
| atf.run_command_output( |
| f"squeue --noheader --only-job-state --format='%i=%T' -j {leader_job_id}", |
| fatal=True, |
| ) |
| .strip() |
| .splitlines() |
| ) |
| |
| assert f"{leader_job_id}+0=RUNNING" in output, ( |
| f"Expected component 0 of hetjob {leader_job_id} in --only-job-state " |
| f"output, got {output}" |
| ) |
| assert f"{leader_job_id}+1=RUNNING" in output, ( |
| f"Expected component 1 of hetjob {leader_job_id} in --only-job-state " |
| f"output, got {output}" |
| ) |
| assert ( |
| len(output) == 2 |
| ), f"Expected exactly 2 hetjob components, got {len(output)}: {output}" |