| ############################################################################ | |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| ############################################################################ | |
| """Verify srun --bcast with hetjobs.""" | |
| import os | |
| import re | |
| import pytest | |
| import atf | |
| # srun.1 documents this name for a broadcast file whose destination is a | |
| # directory rather than a file: slurm_bcast_<job_id>.<step_id>_<nodename>. | |
| BCAST_NAME_PATTERN = r"slurm_bcast_\d+\.\d+_\S+" | |
| # Hetjobs only start on a backfill cycle, so allow more than the default | |
| # command timeout. A timeout otherwise reports exit code 110 and no stderr. | |
| SRUN_TIMEOUT = 120 | |
| # What detects the regression these tests cover is the srun exit code, not any | |
| # field parsed out of a broadcast name. slurmd names the file it writes from | |
| # the sbcast credential the controller issued, while slurmstepd rebuilds the | |
| # same name from the step it is about to run, so a credential carrying the | |
| # wrong step id sends the file somewhere the task never looks, and a | |
| # credential carrying the wrong het component sends it to the other | |
| # component's node. Either way the exec fails with ENOENT. | |
| # | |
| # The controller side of that was broken in 25.11 and is fixed in 26.11 and in | |
| # 26.05.5, so every test below that depends on the credential is expected to | |
| # fail against a controller older than that. tox.ini sets xfail_strict, so a | |
| # controller in this range that does have the fix is a hard failure rather than | |
| # an XPASS signal: the range has to shrink in step with every backport. | |
| xfail_bcast_hetjob = pytest.mark.xfail( | |
| (25, 11) <= atf.get_version("sbin/slurmctld") < (26, 5, 5), | |
| reason="Issue 51136: srun --bcast in a hetjob is broken before 26.05.5", | |
| ) | |
| # --bcast-exclude has a separate gate. Its defect is in srun rather than the | |
| # controller, so an upgraded controller in front of an old srun still shows it, | |
| # and it predates 25.11 rather than being a regression in it, so there is no | |
| # lower bound. srun dropped a non-final component's list, leaving that | |
| # component to take its successor's value. | |
| xfail_bcast_exclude_hetjob = pytest.mark.xfail( | |
| atf.get_version("bin/srun") < (26, 5, 5), | |
| reason="Issue 51136: srun --bcast-exclude is lost for every hetjob" | |
| " component but the last before 26.05.5", | |
| ) | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(): | |
| # Two nodes because each component asks for -N1. Nothing here forces the | |
| # components onto different nodes: require_nodes() treats CPUs as a | |
| # minimum rather than an exact count, and heterogeneous_jobs.shtml says | |
| # components may end up sharing a node. What the tests rely on is that | |
| # backfill normally has to place each component separately to start the | |
| # allocation at all. | |
| atf.require_nodes(2, [("CPUs", 1)]) | |
| # Hetjobs are only ever started by the backfill scheduler. | |
| atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1)) | |
| # With no dest_path srun.1 prefers BcastParameters DestDir over --chdir. | |
| # The tests below assert the --chdir form, so require the whole parameter | |
| # unset rather than report a site setting as if --bcast were broken. | |
| # SbcastParameters is still accepted as a deprecated alias for the same | |
| # value (read_config.c only logs a rename error), so it has to be unset | |
| # too or a site using the old spelling slips a DestDir past this. | |
| atf.require_config_parameter("BcastParameters", None) | |
| atf.require_config_parameter("SbcastParameters", None) | |
| # --bcast-exclude overrides slurm.conf BcastExclude, so the exclude test | |
| # can only tell an honoured override from an ignored one if BcastExclude is | |
| # known. Unset is the documented default, which is non-empty. | |
| atf.require_config_parameter("BcastExclude", None) | |
| atf.require_slurm_running() | |
| @pytest.fixture(scope="module") | |
| def component_scripts(): | |
| """Return one distinct executable per hetjob component. | |
| Each script prints its own marker, its own argv[0] and the node it ran | |
| on. srun rewrites argv[0] to the broadcast destination, so the output | |
| says which file the component ran, where that file landed, and which | |
| node ran it. | |
| """ | |
| scripts = [] | |
| for component in range(2): | |
| name = f"comp{component}.sh" | |
| atf.make_bash_script(name, f'echo "COMP{component} $0 $SLURMD_NODENAME"') | |
| scripts.append(f"{os.getcwd()}/{name}") | |
| return scripts | |
| def executed_files(output): | |
| """Map each component marker to its (argv[0], nodename).""" | |
| executed = {} | |
| for line in output.splitlines(): | |
| match = re.fullmatch(r"(COMP\d) (\S+) (\S+)", line.strip()) | |
| if match: | |
| executed[match.group(1)] = (match.group(2), match.group(3)) | |
| return executed | |
| def assert_documented_bcast_name(marker, argv0, directory): | |
| """Assert argv[0] is the name srun.1 documents, under directory. | |
| srun.1 says a broadcast file whose destination is a directory is named | |
| slurm_bcast_<job_id>.<step_id>_<nodename>. The directory half carries the | |
| weight here: it comes from srun's --chdir, so matching it is what proves | |
| the documented no-dest_path resolution was used. | |
| The fields inside the name are deliberately not asserted. slurmstepd | |
| rebuilds them from the step it is running, so they look correct whatever | |
| the sbcast credential said; the credential only shows up in whether the | |
| exec succeeded. | |
| """ | |
| assert re.fullmatch(f"{re.escape(directory)}/{BCAST_NAME_PATTERN}", argv0), ( | |
| f"{marker} should have run the documented broadcast name under" | |
| f" {directory}, but ran {argv0}" | |
| ) | |
| @xfail_bcast_hetjob | |
| def test_bcast_hetjob_default_destination(component_scripts): | |
| """Verify --bcast with no path runs each component's own broadcast file. | |
| With no path and no BcastParameters DestDir, srun.1 puts the file in | |
| --chdir under the documented name. slurmd builds that name from the | |
| sbcast credential while slurmstepd rebuilds it from the step it runs, | |
| so the two have to agree or the task dies with ENOENT. | |
| The exit code assertion is therefore the regression detector, not the | |
| name checks that follow it. Do not weaken it. | |
| Note: assumes the same file system is visible to us and to the slurmd. | |
| """ | |
| result = atf.run_command( | |
| f"srun -t1 --bcast -N1 -n1 {component_scripts[0]}" | |
| f" : --bcast -N1 -n1 {component_scripts[1]}", | |
| timeout=SRUN_TIMEOUT, | |
| ) | |
| assert result["exit_code"] == 0, ( | |
| f"srun --bcast in a hetjob should succeed, but got rc=" | |
| f"{result['exit_code']}: {result['stderr']}" | |
| ) | |
| executed = executed_files(result["stdout"]) | |
| assert set(executed) == {"COMP0", "COMP1"}, ( | |
| "Each hetjob component should have run its own broadcast file, but got" | |
| f" {result['stdout']!r}" | |
| ) | |
| cwd = os.getcwd() | |
| for marker, (argv0, _) in executed.items(): | |
| assert_documented_bcast_name(marker, argv0, cwd) | |
| @pytest.fixture(scope="module") | |
| def shared_script(): | |
| """Return one executable for both hetjob components to share.""" | |
| atf.make_bash_script("shared.sh", 'echo "SHARED $0 $SLURMD_NODENAME"') | |
| return f"{os.getcwd()}/shared.sh" | |
| @xfail_bcast_hetjob | |
| def test_bcast_hetjob_shared_application(shared_script): | |
| """Verify --bcast with one application shared by both components. | |
| heterogeneous_jobs.shtml says a component that lacks an application | |
| specification uses the next one provided, so this single command line | |
| broadcasts one source file on behalf of two components. That is the | |
| hardest shape to get right and the easiest to get wrong quietly: there is | |
| only one source path to reason about, so a credential naming the wrong | |
| component has nothing else to disagree with. | |
| As above, the exit code is the detector. | |
| Note: assumes the same file system is visible to us and to the slurmd. | |
| """ | |
| result = atf.run_command( | |
| f"srun -t1 -n1 --bcast : -n1 --bcast {shared_script}", | |
| timeout=SRUN_TIMEOUT, | |
| ) | |
| assert result["exit_code"] == 0, ( | |
| "srun --bcast with an application shared by both hetjob components" | |
| f" should succeed, but got rc={result['exit_code']}: {result['stderr']}" | |
| ) | |
| ran = [ | |
| match.groups() | |
| for match in ( | |
| re.fullmatch(r"SHARED (\S+) (\S+)", line.strip()) | |
| for line in result["stdout"].splitlines() | |
| ) | |
| if match | |
| ] | |
| assert len(ran) == 2, ( | |
| "Both hetjob components should have run the shared application, but" | |
| f" got {result['stdout']!r}" | |
| ) | |
| cwd = os.getcwd() | |
| for argv0, node in ran: | |
| assert_documented_bcast_name(f"The component on {node}", argv0, cwd) | |
| assert len({argv0 for argv0, _ in ran}) == 2, ( | |
| "Each component should have broadcast the shared application to its" | |
| f" own destination, but both ran {ran[0][0]}" | |
| ) | |
| def test_bcast_hetjob_destination_file(component_scripts): | |
| """Verify --bcast=<absolute file> is used verbatim for each component. | |
| srun.1 says a path that does not end in '/' is the destination file name. | |
| No name is derived from the job or step in that case, so this covers the | |
| other half of the documented path resolution from the pattern tests. | |
| Note: assumes the same file system is visible to us and to the slurmd. | |
| """ | |
| cwd = os.getcwd() | |
| destinations = [f"{cwd}/bcast_comp0", f"{cwd}/bcast_comp1"] | |
| result = atf.run_command( | |
| f"srun -t1 --bcast={destinations[0]} -N1 -n1 {component_scripts[0]}" | |
| f" : --bcast={destinations[1]} -N1 -n1 {component_scripts[1]}", | |
| timeout=SRUN_TIMEOUT, | |
| ) | |
| assert result["exit_code"] == 0, ( | |
| f"srun --bcast=<file> in a hetjob should succeed, but got rc=" | |
| f"{result['exit_code']}: {result['stderr']}" | |
| ) | |
| executed = executed_files(result["stdout"]) | |
| assert {marker: argv0 for marker, (argv0, _) in executed.items()} == { | |
| "COMP0": destinations[0], | |
| "COMP1": destinations[1], | |
| }, f"Each component should have run its own --bcast file, but got {executed}" | |
| @xfail_bcast_hetjob | |
| def test_bcast_hetjob_destination_dir(component_scripts): | |
| """Verify --bcast=<dir>/ names the file per the documented pattern. | |
| srun.1 says a path ending in '/' is a target directory, and that the | |
| destination file in it is named slurm_bcast_<job_id>.<step_id>_<nodename>. | |
| Note: assumes the same file system is visible to us and to the slurmd. | |
| """ | |
| cwd = os.getcwd() | |
| directories = [f"{cwd}/bcast_dir0", f"{cwd}/bcast_dir1"] | |
| for directory in directories: | |
| atf.run_command(f"mkdir -p {directory}", fatal=True) | |
| result = atf.run_command( | |
| f"srun -t1 --bcast={directories[0]}/ -N1 -n1 {component_scripts[0]}" | |
| f" : --bcast={directories[1]}/ -N1 -n1 {component_scripts[1]}", | |
| timeout=SRUN_TIMEOUT, | |
| ) | |
| assert result["exit_code"] == 0, ( | |
| f"srun --bcast=<dir>/ in a hetjob should succeed, but got rc=" | |
| f"{result['exit_code']}: {result['stderr']}" | |
| ) | |
| executed = executed_files(result["stdout"]) | |
| assert set(executed) == {"COMP0", "COMP1"}, ( | |
| "Each hetjob component should have run its own broadcast file, but got" | |
| f" {result['stdout']!r}" | |
| ) | |
| for component, marker in enumerate(["COMP0", "COMP1"]): | |
| argv0, _ = executed[marker] | |
| assert_documented_bcast_name(marker, argv0, directories[component]) | |
| @xfail_bcast_hetjob | |
| def test_bcast_hetjob_chdir_per_component(component_scripts): | |
| """Verify --bcast with no path resolves each component's own --chdir. | |
| srun.1 falls back to --chdir when no dest_path is given and no | |
| BcastParameters DestDir is configured. Each component parses its own | |
| --chdir, so each broadcast file has to land under that component's | |
| directory rather than under one the components share. | |
| The scripts are named by absolute path so --chdir moves the destination | |
| without also moving the source. | |
| Note: assumes the same file system is visible to us and to the slurmd. | |
| """ | |
| cwd = os.getcwd() | |
| directories = [f"{cwd}/chdir_comp0", f"{cwd}/chdir_comp1"] | |
| for directory in directories: | |
| atf.run_command(f"mkdir -p {directory}", fatal=True) | |
| result = atf.run_command( | |
| f"srun -t1 --chdir={directories[0]} --bcast -N1 -n1 {component_scripts[0]}" | |
| f" : --chdir={directories[1]} --bcast -N1 -n1 {component_scripts[1]}", | |
| timeout=SRUN_TIMEOUT, | |
| ) | |
| assert result["exit_code"] == 0, ( | |
| f"srun --bcast with a per-component --chdir should succeed, but got rc=" | |
| f"{result['exit_code']}: {result['stderr']}" | |
| ) | |
| executed = executed_files(result["stdout"]) | |
| assert set(executed) == {"COMP0", "COMP1"}, ( | |
| "Each hetjob component should have run its own broadcast file, but got" | |
| f" {result['stdout']!r}" | |
| ) | |
| for component, marker in enumerate(["COMP0", "COMP1"]): | |
| argv0, _ = executed[marker] | |
| assert_documented_bcast_name(marker, argv0, directories[component]) | |
| @pytest.fixture(scope="module") | |
| def library_probe(): | |
| """Return a dynamically linked executable, its library dirs and a probe. | |
| --send-libs only has something to send for a dynamically linked ELF, so a | |
| bash script cannot be the broadcast executable. Broadcasting bash itself | |
| and handing it a probe script makes the task its own oracle: it reports | |
| the LD_LIBRARY_PATH it was launched with and the contents of the cache | |
| directory srun.1 promises to add to it, so nothing here has to know how | |
| slurmd spells that directory. | |
| """ | |
| atf.require_tool("bash") | |
| # srun runs LDD_PATH unconditionally rather than searching PATH | |
| # (src/common/file_bcast.c), so gate on the path srun uses. | |
| atf.require_tool("/usr/bin/ldd") | |
| binary = atf.run_command_output("command -v bash", quiet=True, fatal=True).strip() | |
| # Not fatal: ldd exits non-zero on a non-dynamic binary, which the skip | |
| # below is there to handle. srun treats the same failure as non-fatal. | |
| output = atf.run_command_output(f"/usr/bin/ldd {binary}", quiet=True) | |
| # Take every absolute path ldd prints: the first '/' in the line up to the | |
| # next space. Matching on "=>" alone would miss the dynamic linker line, | |
| # which has no "=>" in it, and leave that directory unexcluded. | |
| directories = set() | |
| for line in output.splitlines(): | |
| start = line.find("/") | |
| if start < 0: | |
| continue | |
| directories.add(os.path.dirname(line[start:].split(" ")[0])) | |
| if not directories: | |
| # A bare skip rather than an atf.require_*() or skipif: whether the | |
| # shell is statically linked is only knowable from ldd's output, and | |
| # with nothing to send there is no exclusion to observe. | |
| pytest.skip(f"{binary} is not dynamically linked, so nothing is sent") | |
| # $1 is the component's marker, $2 the directory it broadcast into. | |
| # srun.1 promises the libraries are placed in a directory alongside the | |
| # executable and that LD_LIBRARY_PATH is updated to include that cache | |
| # directory, so report every LD_LIBRARY_PATH entry that is in the | |
| # component's own directory along with what is in it. The DONE line makes | |
| # "the task ran and was given no cache directory" distinguishable from | |
| # "the task never ran". | |
| atf.make_bash_script( | |
| "libs_probe.sh", | |
| """ | |
| IFS=: | |
| for entry in $LD_LIBRARY_PATH; do | |
| case "$entry" in | |
| "$2"/*) echo "$1 CACHE $entry $(ls -A "$entry" | tr '\\n' ',')" ;; | |
| esac | |
| done | |
| echo "$1 DONE" | |
| """, | |
| ) | |
| return binary, sorted(directories), f"{os.getcwd()}/libs_probe.sh" | |
| def probe_caches(output): | |
| """Map each marker to the (directory, contents) pairs the probe reported. | |
| A marker present with an empty list means the task ran and was given no | |
| library cache directory at all. | |
| """ | |
| caches = {} | |
| for line in output.splitlines(): | |
| line = line.strip() | |
| done = re.fullmatch(r"(COMP\d) DONE", line) | |
| if done: | |
| caches.setdefault(done.group(1), []) | |
| continue | |
| cache = re.fullmatch(r"(COMP\d) CACHE (\S+) ?(.*)", line) | |
| if cache: | |
| contents = [name for name in cache.group(3).split(",") if name] | |
| caches.setdefault(cache.group(1), []).append((cache.group(2), contents)) | |
| return caches | |
| @xfail_bcast_exclude_hetjob | |
| def test_bcast_exclude_applies_per_hetjob_component(library_probe): | |
| """Verify each hetjob component honours its own --bcast-exclude list. | |
| srun.1 says --send-libs places the executable's shared objects in a | |
| directory alongside it and updates LD_LIBRARY_PATH to include that cache | |
| directory, and that --bcast-exclude (defaulting to the slurm.conf | |
| BcastExclude value) keeps listed paths out of it. Each component parses | |
| its own command line, so each must keep its own list rather than share | |
| one. | |
| Both components ask for --send-libs, so both must be given a cache | |
| directory on LD_LIBRARY_PATH; the only difference between them should be | |
| what is in it. | |
| The excluding component is deliberately the first one. srun copies each | |
| component's options while parsing the next, so only a non-final component | |
| can lose its own value. | |
| The dangling read left by srun's per-component copy defect does not | |
| return the original bytes here. Component 0 picks up component 1's NONE | |
| and is broadcast the shared objects it excluded, on every unpatched srun | |
| the mixed-version matrix runs. This test is the regression detector for | |
| that, not just a statement of the functional contract. Do not weaken it. | |
| Note: assumes the same file system is visible to us and to the slurmd. | |
| """ | |
| binary, library_dirs, probe = library_probe | |
| cwd = os.getcwd() | |
| directories = [f"{cwd}/libs_comp0", f"{cwd}/libs_comp1"] | |
| for directory in directories: | |
| atf.run_command(f"mkdir -p {directory}", fatal=True) | |
| excluded = ",".join(library_dirs) | |
| result = atf.run_command( | |
| f"srun -t1 --send-libs --bcast={directories[0]}/probe" | |
| f" --bcast-exclude={excluded} -N1 -n1" | |
| f" {binary} {probe} COMP0 {directories[0]}" | |
| f" : --send-libs --bcast={directories[1]}/probe" | |
| f" --bcast-exclude=NONE -N1 -n1" | |
| f" {binary} {probe} COMP1 {directories[1]}", | |
| timeout=SRUN_TIMEOUT, | |
| ) | |
| assert result["exit_code"] == 0, ( | |
| f"srun --bcast --send-libs in a hetjob should succeed, but got rc=" | |
| f"{result['exit_code']}: {result['stderr']}" | |
| ) | |
| caches = probe_caches(result["stdout"]) | |
| assert set(caches) == {"COMP0", "COMP1"}, ( | |
| "Each hetjob component should have run the probe, but got" | |
| f" {result['stdout']!r}" | |
| ) | |
| for marker, directory in zip(["COMP0", "COMP1"], directories): | |
| assert len(caches[marker]) == 1, ( | |
| f"--send-libs should have given {marker} exactly one library cache" | |
| f" directory under {directory} on its LD_LIBRARY_PATH, but it" | |
| f" reported {caches[marker]}" | |
| ) | |
| cache_dir, included = caches["COMP1"][0] | |
| assert included, ( | |
| "--bcast-exclude=NONE should have broadcast the shared objects of" | |
| f" component 1, but {cache_dir} is empty" | |
| ) | |
| cache_dir, withheld = caches["COMP0"][0] | |
| assert not withheld, ( | |
| f"Component 0 excluded {excluded}, which is every directory ldd" | |
| f" reported for {binary}, so nothing should have been broadcast, but" | |
| f" {cache_dir} holds {sorted(withheld)}" | |
| ) | |
| @xfail_bcast_hetjob | |
| def test_bcast_het_group_in_existing_allocation(): | |
| """Verify --het-group picks the component --bcast transfers to. | |
| heterogeneous_jobs.shtml describes --bcast as transferring files to the | |
| nodes of the application to be launched, as selected by --het-group, and | |
| srun.1 says --het-group applies only inside a salloc allocation or an | |
| sbatch script. That is a different path than srun creating the hetjob. | |
| This uses the default destination on purpose. An explicit path carries no | |
| node identity, so a transfer credentialed for the wrong component still | |
| execs from the shared file system and the test cannot tell. The | |
| documented name embeds the node, so the wrong component gives ENOENT and | |
| the srun in the batch script fails. | |
| Note: assumes the same file system is visible to us and to the slurmd. | |
| """ | |
| cwd = os.getcwd() | |
| output = "het_group_bcast.out" | |
| error = "het_group_bcast.err" | |
| batch_script = "het_group_bcast.sh" | |
| for component in range(2): | |
| atf.make_bash_script( | |
| f"group{component}.sh", f'echo "COMP{component} $0 $SLURMD_NODENAME"' | |
| ) | |
| atf.make_bash_script( | |
| batch_script, | |
| f""" | |
| #SBATCH -N1 -t1 | |
| #SBATCH hetjob | |
| #SBATCH -N1 -t1 | |
| set -e | |
| srun --het-group=0 --bcast -n1 {cwd}/group0.sh | |
| srun --het-group=1 --bcast -n1 {cwd}/group1.sh | |
| """, | |
| ) | |
| job_id = atf.submit_job_sbatch( | |
| f"-t1 -o {output} -e {error} {batch_script}", fatal=True | |
| ) | |
| # Same backfill-cycle wait as SRUN_TIMEOUT above: the 45s default polling | |
| # timeout is not enough for a hetjob to be scheduled and complete. | |
| atf.wait_for_job_state(job_id, "DONE", timeout=SRUN_TIMEOUT, fatal=True) | |
| # Check the job state before polling the output. A failed srun leaves the | |
| # output empty for good, so waiting on it first only burns the timeout and | |
| # hides the real error, which srun wrote to the error file. | |
| state = atf.get_job_parameter(job_id, "JobState", fatal=True) | |
| assert state == "COMPLETED", ( | |
| f"Expected COMPLETED but got {state!r}:" | |
| f" {atf.run_command_output(f'cat {error}', quiet=True)}" | |
| ) | |
| # Waits for the file and re-polls until the second component's line lands. | |
| # The wait includes the start of the name, not just the marker, so a read | |
| # that catches the line half written cannot be mistaken for a component | |
| # that never ran. | |
| atf.assert_file_contents(output, f"COMP1 {cwd}/slurm_bcast_", contains=True) | |
| contents = atf.run_command_output(f"cat {output}", fatal=True) | |
| executed = executed_files(contents) | |
| assert set(executed) == {"COMP0", "COMP1"}, ( | |
| "Each --het-group should have run its own broadcast file, but got" | |
| f" {contents!r}" | |
| ) | |
| for marker, (argv0, _) in executed.items(): | |
| assert_documented_bcast_name(marker, argv0, cwd) | |
| # Not a regression check by itself, and nothing above forces the split: | |
| # hetjob components are observed to be placed on separate nodes. That | |
| # separation is what makes the ENOENT above possible, so if it ever stops | |
| # holding, the test above has quietly stopped testing anything. | |
| nodes = {node for _, node in executed.values()} | |
| assert len(nodes) == 2, ( | |
| "The two --het-group components should have run on different nodes," | |
| f" each with a broadcast file named for its own node, but got {nodes}" | |
| ) | |
| @xfail_bcast_hetjob | |
| def test_bcast_het_group_spanning_components(): | |
| """Verify --het-group=0,1 broadcasts to every component it names. | |
| srun.1 gives --het-group a set expression, so one step can span several | |
| components. That is the only shape where a single sbcast credential has to | |
| resolve to more than one component's nodes, which is what the het step | |
| component field selects; with a single component there is only one answer | |
| it could give. | |
| The default destination is used for the same reason as the test above: the | |
| documented name embeds the node, so a component the file never reached | |
| gives ENOENT rather than silently execing from the shared file system. | |
| Note: assumes the same file system is visible to us and to the slurmd. | |
| """ | |
| cwd = os.getcwd() | |
| output = "het_group_span.out" | |
| error = "het_group_span.err" | |
| batch_script = "het_group_span.sh" | |
| atf.make_bash_script("span.sh", 'echo "SPAN $0 $SLURMD_NODENAME"') | |
| atf.make_bash_script( | |
| batch_script, | |
| f""" | |
| #SBATCH -N1 -t1 | |
| #SBATCH hetjob | |
| #SBATCH -N1 -t1 | |
| srun --het-group=0,1 --bcast {cwd}/span.sh | |
| """, | |
| ) | |
| job_id = atf.submit_job_sbatch( | |
| f"-t1 -o {output} -e {error} {batch_script}", fatal=True | |
| ) | |
| atf.wait_for_job_state(job_id, "DONE", timeout=SRUN_TIMEOUT, fatal=True) | |
| state = atf.get_job_parameter(job_id, "JobState", fatal=True) | |
| assert state == "COMPLETED", ( | |
| f"Expected COMPLETED but got {state!r}:" | |
| f" {atf.run_command_output(f'cat {error}', quiet=True)}" | |
| ) | |
| contents = "" | |
| for _ in atf.timer(fatal=True): | |
| contents = atf.run_command_output(f"cat {output}", quiet=True, fatal=True) | |
| if contents.count(f"SPAN {cwd}/slurm_bcast_") == 2: | |
| break | |
| ran = [ | |
| match.groups() | |
| for match in ( | |
| re.fullmatch(r"SPAN (\S+) (\S+)", line.strip()) | |
| for line in contents.splitlines() | |
| ) | |
| if match | |
| ] | |
| assert len(ran) == 2, ( | |
| "--het-group=0,1 should have run a task in each component it names," | |
| f" but got {contents!r}" | |
| ) | |
| for argv0, node in ran: | |
| assert_documented_bcast_name(f"The component on {node}", argv0, cwd) | |
| assert len({argv0 for argv0, _ in ran}) == 2, ( | |
| "Each component named by --het-group=0,1 should have been broadcast to" | |
| f" separately, but both ran {ran[0][0]}" | |
| ) | |
| # heterogeneous_jobs.shtml says all components of a job step share the step | |
| # ID, while each component carries its own job id. This is the documented | |
| # cross-component invariant, not a credential detector. | |
| ids = [ | |
| re.fullmatch(r"slurm_bcast_(\d+)\.(\d+)_\S+", os.path.basename(argv0)).groups() | |
| for argv0, _ in ran | |
| ] | |
| assert ids[0][1] == ids[1][1], ( | |
| "Components of one --het-group=0,1 step should share a step id, but got" | |
| f" {ids[0][1]} and {ids[1][1]}" | |
| ) | |
| assert ( | |
| ids[0][0] != ids[1][0] | |
| ), f"Each component should carry its own job id, but both used {ids[0][0]}" |