blob: 8a7de55f054e8a9853ea267e3b3fbb8268305b9b [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify --prefer falls back to --constraint under ignore_prefer_validation.
With SchedulerParameters=ignore_prefer_validation the controller accepts a
--prefer list it cannot satisfy, and the scheduler then evaluates the job using
only --constraint. Every --prefer job below is therefore expected to run.
Ticket 25788: when some, but fewer than requested, nodes had the --prefer
features, the job was held with Reason=BadConstraints and Priority=0 instead of
being retested against --constraint.
"""
import pytest
import atf
# Gate for the fallback when --prefer matches some nodes but fewer than the
# job needs.
PARTIAL_MATCH_VERSION = (26, 5, 5)
PARTIAL_MATCH_REASON = (
"Ticket 25788: a --prefer list matching some but not enough nodes held "
"the job instead of falling back to --constraint"
)
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter_includes(
"SchedulerParameters", "ignore_prefer_validation"
)
# Batch jobs never start during the submit RPC, so every job below waits
# for a scheduling pass. Keep that wait short.
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
# The fallback under test is the main scheduler's. Backfill could start a
# job before the main scheduler evaluates it and hide a regression there.
atf.require_config_parameter("SchedulerType", "sched/builtin")
# Exactly one node carries f1, so a two node --prefer=f1 request matches
# some, but not enough, nodes.
atf.require_nodes(1, [("CPUs", 1), ("Features", "f1")])
atf.require_nodes(2, [("CPUs", 1), ("Features", "f2")])
atf.require_slurm_running()
def run_job(job_params):
"""Submit a batch job, require that it runs, and return its job id.
Reports the held-job signature explicitly, since a bare wait_for_job_state()
timeout does not say whether the job was held or merely slow.
"""
job_id = atf.submit_job("sbatch", job_params, "hostname", fatal=True)
if not atf.wait_for_job_state(job_id, "DONE"):
job = atf.get_job(job_id)
pytest.fail(
f"Job {job_id} never ran: JobState={job.get('JobState')} "
f"Reason={job.get('Reason')} Priority={job.get('Priority')}. "
"Reason=BadConstraints with Priority=0 means the --prefer pass "
"held the job instead of falling back to --constraint."
)
return job_id
def first_node_features(job_id):
"""Return the list of features of the first node a job ran on."""
nodes = atf.node_range_to_list(atf.get_job_parameter(job_id, "NodeList"))
assert nodes, f"Verify job {job_id} recorded a node list"
return atf.get_node_parameter(nodes[0], "features", default=[])
def test_unknown_prefer_feature_runs():
"""Verify a job whose --prefer feature exists on no node still runs"""
job_id = run_job("-N1 --prefer=invalid -o /dev/null")
assert (
atf.get_job_parameter(job_id, "NumNodes") == 1
), "Verify the job ran on one node"
@pytest.mark.xfail(
atf.get_version("sbin/slurmctld") < PARTIAL_MATCH_VERSION,
reason=PARTIAL_MATCH_REASON,
)
def test_too_few_prefer_nodes_runs():
"""Verify a job runs when fewer nodes than requested have the --prefer feature"""
# Two nodes are requested but only one carries f1, so the preferred pass
# finds a non-empty node set that is still too small.
job_id = run_job("-N2 --prefer=f1 -o /dev/null")
assert (
atf.get_job_parameter(job_id, "NumNodes") == 2
), "Verify the job ran on two nodes"
@pytest.mark.xfail(
atf.get_version("sbin/slurmctld") < PARTIAL_MATCH_VERSION,
reason=PARTIAL_MATCH_REASON,
)
def test_unsatisfiable_prefer_count_runs():
"""Verify a job runs when a counted --prefer expression cannot be satisfied"""
# The same shortfall expressed as a count: two f1 nodes are asked for and
# only one exists.
job_id = run_job('-n3 --ntasks-per-node=1 --prefer "[f1*2&f2*1]" -o /dev/null')
assert (
atf.get_job_parameter(job_id, "NumNodes") == 3
), "Verify the job ran on three nodes"
@pytest.mark.xfail(
atf.get_version("sbin/slurmctld") < PARTIAL_MATCH_VERSION,
reason=PARTIAL_MATCH_REASON,
)
def test_too_few_prefer_nodes_falls_back_to_constraint():
"""Verify the retest after a too-small --prefer node set honors --constraint"""
# Only one node has f1, so the preferred pass fails and the retest must
# place the job on the two f2 nodes.
job_id = run_job("-N2 --prefer=f1 --constraint=f2 -o /dev/null")
nodes = atf.node_range_to_list(atf.get_job_parameter(job_id, "NodeList"))
assert len(nodes) == 2, f"Verify the job ran on two nodes, got {nodes}"
for node in nodes:
assert "f2" in atf.get_node_parameter(
node, "features", default=[]
), f"Verify {node} has the constrained feature f2"
def test_satisfiable_prefer_still_steers_placement():
"""Verify a satisfiable --prefer still selects the preferred node"""
# Guards against a fallback that makes --prefer a no-op rather than a
# preference. The f1 node lacks f2, so a job that ignored --prefer would
# land on an f2 node instead.
job_id = run_job("-N1 --constraint=f2 --prefer=f1 -o /dev/null")
assert "f1" in first_node_features(job_id), "Verify the job ran on the f1 node"
def test_unknown_prefer_falls_back_to_constraint():
"""Verify the fallback tests --constraint alone once --prefer has failed
This is the second half of the switch between the two lists.
test_145_4::test_prefer_evaluated_instead_of_constraint covers the first:
while --prefer can be satisfied, --constraint is not applied at all. Here
--prefer cannot be satisfied, so the retest uses --constraint alone.
"""
# The f1 node is where a job with no feature requirement lands, so ask for
# f2: a retest that dropped --constraint would land on the f1 node.
job_id = run_job("-N1 --constraint=f2 --prefer=invalid -o /dev/null")
assert "f2" in first_node_features(job_id), "Verify --constraint was honored"
def test_constraint_still_rejected():
"""Verify ignore_prefer_validation does not soften --constraint"""
# Only one node has f1, so a two node hard constraint can never be
# satisfied and must still be refused at submit.
result = atf.run_command(
"sbatch -N2 --constraint=f1 -o /dev/null --wrap 'hostname'", xfail=True
)
assert result["exit_code"] != 0, "Verify that job was NOT submitted"
assert (
"Requested node configuration is not available" in result["stderr"]
), "Verify that the job was rejected for an unsatisfiable --constraint"