blob: ef0bb80194f9fcaa696a72c0a1031328fc48cbb8 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Early bail out of block scheduling for --spread-segments jobs."""
import pytest
import atf
SEGMENT_REASON = "No_suitable_topology_unit_found_for_segment"
# What sbatch prints for a job the controller decides can never run. The
# plugin's own ESLURM_TOPO_SEGMENT_NO_FIT does not reach the client here:
# _pick_best_nodes() forwards only ESLURM_REQUESTED_TOPO_CONFIG_UNAVAILABLE and
# ESLURM_NOT_SUPPORTED, and turns anything else into this generic error
# (src/slurmctld/node_scheduler.c). sbatch fails for many reasons, so the
# rejection still has to be identified by its text, and the segment specific
# evidence comes from the log that assert_early_bail() reads.
REJECT_ERROR = "Requested node configuration is not available"
# Signatures of the pre-fix path, where the plugin kept evaluating its way into
# a block shortage. Neither line means "late" on its own: in eval_nodes_block.c
# "node_map is empty" has both an alloc_node_map retry branch and a
# first-segment break branch, and "unable to find block" is reached on the
# first segment too. They mark the old retry path in general, and the fix must
# give up before either one is logged.
RETRY_PATTERNS = ["node_map is empty", "unable to find block"]
# The bail out the fix added, logged under DebugFlags=SelectType. Matches both
# of its lines: "base blocks fit N of M requested segments (spread check)" and
# "blocks fit N of M requested segments (block estimate)".
BAIL_PATTERN = "blocks fit.*requested segments"
# Resolved once in setup(), then read by log_count().
logfile = None
topology_conf = """
BlockName=b1 Nodes=node[1-4]
BlockName=b2 Nodes=node[5-8]
BlockName=b3 Nodes=node[9-12]
BlockName=b4 Nodes=node[13-16]
BlockSizes=4,16
"""
# Setup
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_auto_config("wants to create custom topology.conf")
atf.require_nodes(16, [("CPUs", 4)])
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter("TopologyPlugin", "topology/block")
# Every line log_count() looks for is emitted below info: "node_map is
# empty" is a debug(), and "unable to find block" and both BAIL_PATTERN
# lines are log_flag(), which prints at verbose. SlurmctldDebug defaults
# to info, so without this every count stays at zero, and that would make
# assert_early_bail() fail its positive check rather than pass silently.
atf.require_config_parameter("SlurmctldDebug", "debug")
atf.require_config_parameter_includes("DebugFlags", "SelectType")
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
atf.require_config_file("topology.conf", topology_conf)
atf.require_slurm_running()
global logfile
logfile = resolve_logfile()
def resolve_logfile():
"""Return the slurmctld log path, checked readable by the slurm user.
get_config_parameter() casefolds the value it returns, so it cannot be
used for a path: any upper case in the log path would yield a name that
does not exist, and every log_count() below would silently return zero.
get_config() preserves case.
"""
config = atf.get_config(live=False, quiet=True)
path = next(
(
value
for key, value in config.items()
if key.casefold() == "slurmctldlogfile"
),
None,
)
assert path, "SlurmctldLogFile must be set to count slurmctld log lines"
atf.run_command(
f'test -r "{path}"',
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
return path
def log_position():
"""Return the line number the slurmctld log currently ends at.
The harness collects this log, so a test must never truncate it. Reading
on from a saved position isolates a measurement just as well and leaves
the file intact.
"""
result = atf.run_command(
f'wc -l < "{logfile}"',
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
return int(result["stdout"].strip())
def log_count(pattern, job_id=None, since=0):
"""Count slurmctld log lines after since that match pattern, an awk ERE.
Every plugin line counted here carries JobId=<n> from %pJ, so passing a
job_id keeps the count from picking up lines that another job logged while
the scheduler re-evaluated the queue. Where there is no job id to scope by,
since does the same job for a window the caller opened with
log_position().
"""
if job_id:
pattern = f"JobId={job_id} .*{pattern}"
result = atf.run_command(
f'awk -v pat="{pattern}" -v since={since} '
f"'NR > since && $0 ~ pat {{n++}} END {{print n+0}}' \"{logfile}\"",
user=atf.properties["slurm-user"],
quiet=True,
)
assert result["exit_code"] == 0, f"Unable to search {logfile}: {result['stderr']}"
return int(result["stdout"].strip())
def start_blockers(blockers):
"""Submit each blocker job and wait for all of them to run."""
job_ids = []
for blocker in blockers:
job_ids.append(
atf.submit_job_sbatch(
f'{blocker} --mem=1 --wrap="sleep infinity"', fatal=True
)
)
for job_id in job_ids:
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
return job_ids
def assert_early_bail(job_id=None, since=0):
"""Assert that the plugin bailed out early instead of retrying into it.
Scope the counts by job_id where there is one, and otherwise by a since
position the caller took before it submitted.
"""
assert (
log_count(BAIL_PATTERN, job_id, since) > 0
), "Plugin must log the segment shortage that it bailed out on"
for pattern in RETRY_PATTERNS:
assert (
log_count(pattern, job_id, since) == 0
), f"Plugin must bail out before it retries, but logged '{pattern}'"
# Each case is (blocker jobs, job arguments, job can never run).
# The topology has four base blocks of four nodes in one 16 node block, so a
# segment of one to four nodes needs one base block, and a larger segment
# gathers whole base blocks.
BAIL_CASES = [
pytest.param(
[],
"-N 5 --segment=1 --spread-segments --exclusive",
True,
id="more_segments_than_base_blocks",
),
pytest.param(
[],
"-N 15 --segment=5 --spread-segments --exclusive",
True,
id="partial_base_blocks_fill_too_few_segments",
),
pytest.param(
["-w node[1-2] -N 2 --exclusive", "-w node[9-16] -N 8 --exclusive"],
"-N 6 --segment=3 --spread-segments --exclusive",
False,
id="only_one_base_block_fits_a_segment",
),
pytest.param(
[
"-w node[1-2] -N 2 --ntasks-per-node=1 -c 3",
"-w node[3-4] -N 2 --exclusive",
"-w node[9-16] -N 8 --exclusive",
],
"-N 4 -n 8 --segment=2 --spread-segments",
False,
id="base_block_short_on_cpus",
),
]
# Each case is (blocker jobs, job arguments).
RUN_CASES = [
pytest.param(
[],
"-N 4 --segment=1 --spread-segments --exclusive",
id="one_node_per_base_block",
),
pytest.param(
[],
"-N 12 --segment=6 --spread-segments --exclusive",
id="segment_spans_two_base_blocks",
),
pytest.param(
[],
"-N 10 --segment=5 --spread-segments --exclusive",
id="segments_at_block_capacity",
),
pytest.param(
["-w node[1-2] -N 2 --exclusive"],
"-N 4 --segment=2 --spread-segments --exclusive",
id="partly_used_base_block",
),
]
# Each case is (job arguments). These are the two BAIL_CASES that no cluster
# state can satisfy, minus --spread-segments. Without spreading, segments may
# share a base block, so both fit the four base blocks and must run. The
# bail out the fix added applies to every job of more than one segment, not
# only to spreading ones, so these pin the tighter bound to --spread-segments.
NO_SPREAD_CASES = [
pytest.param("-N 5 --segment=1 --exclusive", id="five_one_node_segments"),
pytest.param(
"-N 15 --segment=5 --exclusive",
id="three_five_node_segments",
marks=pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (25, 11),
reason="A segment larger than a base block needs 25.11+.",
),
),
]
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 5, 5)
or atf.get_version("bin/sbatch") < (25, 11),
reason="Ticket 25720: spread segments did not bail out on a block shortage before 26.05.5, and sbatch --spread-segments needs 25.11+.",
)
@pytest.mark.parametrize("blockers, job_args, never_runnable", BAIL_CASES)
def test_spread_segments_bails_out(blockers, job_args, never_runnable):
"""The plugin gives up before it places a segment it cannot follow.
A job that no cluster state can satisfy is rejected at submit. A job that
only the current cluster state blocks pends with the segment reason, and
runs once the blockers are gone.
"""
blocker_ids = start_blockers(blockers)
if never_runnable:
# A rejected job never gets an id to scope the log search by, so note
# where the log ends and read on from there after the submit.
since = log_position()
result = atf.run_command(
f'sbatch {job_args} --mem=1 --wrap="exit 0"', xfail=True
)
assert (
result["exit_code"] != 0
), "Job that no cluster state can satisfy must be rejected"
assert REJECT_ERROR in result["stderr"], (
"Job must be rejected as never runnable, but sbatch said"
f' "{result["stderr"].strip()}"'
)
assert_early_bail(since=since)
return
job_id = atf.submit_job_sbatch(f'{job_args} --mem=1 --wrap="exit 0"', fatal=True)
assert atf.wait_for_job_state(
job_id, "PENDING", SEGMENT_REASON
), "Job must pend with the segment topology reason"
assert_early_bail(job_id)
# The bail out must be temporary, not a rejection.
atf.cancel_jobs(blocker_ids, fatal=True)
assert atf.wait_for_job_state(
job_id, "COMPLETED"
), f"Job {job_id} must run once the blockers are gone"
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (25, 11)
or atf.get_version("bin/sbatch") < (25, 11),
reason="--spread-segments was added in 25.11.",
)
@pytest.mark.parametrize("blockers, job_args", RUN_CASES)
def test_spread_segments_runs(blockers, job_args):
"""The bail out does not reject a job the blocks can still hold."""
start_blockers(blockers)
job_id = atf.submit_job_sbatch(f'{job_args} --mem=1 --wrap="exit 0"', fatal=True)
assert atf.wait_for_job_state(job_id, "COMPLETED"), f"Job {job_id} should have run"
@pytest.mark.parametrize("job_args", NO_SPREAD_CASES)
def test_segments_without_spread_run(job_args):
"""These bail out cases need to run with --spread-segments removed"""
job_id = atf.submit_job_sbatch(f'{job_args} --mem=1 --wrap="exit 0"', fatal=True)
assert atf.wait_for_job_state(
job_id, "COMPLETED"
), f"Job {job_id} should have run since it doesn't use --spread-segments"