blob: 3a9f2f2e3c52f269b464dbdc591e2ad585c660c5 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test job --extra with SchedulerParameters=extra_constraints.
A job's --extra is an expression the controller parses at submission and
matches against each node's Extra JSON. A request that does not parse is
rejected. A valid request that no node matches pends with Reason=Constraints
for salloc, srun and sbatch alike, and starts once a node matches.
Parsing and matching do not depend on the command, so the expressions are only
submitted with sbatch.
Ticket 18133.
"""
import json
import shlex
import pytest
import atf
pytestmark = pytest.mark.slow
INVALID_EXTRA = "Invalid extra constraints specification"
COMMANDS = ["salloc", "srun", "sbatch"]
# Extra set on every extra node. The first keys are the example from the
# extra_constraints documentation.
NODE_EXTRA = {
"a": 1.23,
"b": True,
"c": 0,
"foo": "bar",
"zed": 23,
"f": 2.000009,
"g": -1.000009,
"n": None,
" k": "v",
"k ": "v",
"w": " v ",
"bf": False,
"s": "23",
"o": {"x": 1},
"l": [1, 2],
}
# Requests matching NODE_EXTRA. Numbers are equal if they differ by less than
# 0.00001. Apart from the documented c>=0.00001 example, cases near that keep at
# least a factor of ten from it, as closer differences are not reliable.
MATCHING = [
# Examples from the documentation
"a=1.23",
"a!=1.24",
"a!=1.23|foo!=blah",
"b=200",
"b=true",
"foo<baz",
"(c<=0.0001&a=1.25)|zed=23.0",
"((c<=0.0001&a=1.25)|zed=23.0)&(a<1|b=false|c>=0.00000001)",
"((c<=0.0001&a=1.25)|zed=23.0)&(a<1|b=true|c>=0.1)",
# A requested value is compared as the type of the node's value
"b<=0.05",
# Numbers are equal only within 0.00001
"f=2.000008",
"g=-1.000008",
"a>1.229",
# False is 0 or any case of false, f, no, n, off, null or ~, anything else
# is true
"bf=0",
"bf=N",
"bf=off",
"bf=~",
"b=yes",
# Strings compare byte by byte, and a number in quotes is a string
"foo>BAR",
"s=23",
"s<3",
# Comma and & are both AND, so they can be mixed
"a=1.23,b=true,c=0",
"a=1.23&b=true,c=0",
"(a=1.23&b=true)|(c=3&zed=1)",
"((a=1|b=true)&(c=0|zed=1))",
# Whitespace is part of keys and values
" k=v",
"k =v",
"w= v ",
"( k=v)&(k =v)",
]
NOT_MATCHING = [
# Examples from the documentation
"a!=1.23",
"b=0",
"b=false",
"foo>baz",
"((c<=0.0001&a=1.25)|zed=23.0)&(a<1|b=false|c>=0.00001)",
"b<0.05",
"a= b",
"( a=b)",
"(a=1&b=2)|c=foobar",
# Numbers are equal only within 0.00001
"f=2.0001",
"g=-1.0001",
# Equal within 0.00001, so not less than
"a<1.230001",
# Numerical suffixes are not supported
"zed=23kb",
"a=1.23&foo=baz",
"a=1|foo=baz",
"missing=1",
"n=null",
# A value of another type, a missing key or null fails every operator
"zed!=23kb",
"zed<23kb",
"missing!=1",
"missing<1",
"n!=1",
# Booleans compare as false or true
"b=FALSE",
"b=off",
"b=~",
"bf=true",
# Strings compare byte by byte, and a number in quotes is a string
"foo=BAR",
"s=23.0",
"s>3",
# Objects and arrays never match
"o=1",
"o!=1",
"l=1",
"l!=1",
# Whitespace and quotes are part of keys and values
"k=v",
"w=v",
" k=v & k =v ",
'foo="bar"',
"foo='bar'",
]
ILLEGAL = [
# Examples from the documentation
" (a=b)",
"a,<=6",
"a<=6<=",
"a=5&&&b=5",
"a=5|||b=5",
"a====5",
"b<=<=5",
"a=5&()",
"a=5&b=5|c=5",
"(a=1)&(b=2)|(c=3)",
"a=1(b=2)",
"(a=1)(b=2)",
"(((a=1)b=2))",
"a=1&b=2|c=foobar",
# Keys and values cannot be empty
"=1",
"a=",
# Other malformed expressions
"a",
"1",
"a==1",
"a=!1",
"a=>1",
"a=<1",
"a=1=2",
"a=1 b=2",
"a=1&",
"&a=1",
"(a=1",
"a=1)",
")",
"((a=1)&(b=2)",
"(a=1))(b=2)",
"(b>=2&)",
"()",
"(())",
"(a=1|())",
# Mixed boolean operators need parentheses
"a=1,b=2|c=3",
"(a=1&b=2|c=3)",
"((a=1|b=2)&c=3|d=4)",
]
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter_includes("SchedulerParameters", "extra_constraints")
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
atf.require_nodes(3)
atf.require_slurm_running()
@pytest.fixture(scope="module")
def nodes():
"""The nodes, with NODE_EXTRA set on all but the first.
The first node has no Extra, so it never matches a job's --extra.
"""
node_list = sorted(atf.get_nodes(fatal=True))
for node in node_list[1:]:
set_extra(node, json.dumps(NODE_EXTRA))
yield node_list
for node in node_list[1:]:
set_extra(node, "")
@pytest.fixture
def first_node(nodes):
"""The first node, with any Extra a test sets on it cleared afterwards."""
yield nodes[0]
set_extra(nodes[0], "")
def set_extra(node, extra):
atf.run_command(
f"scontrol update nodename={node} extra={shlex.quote(extra)}",
user=atf.properties["slurm-user"],
fatal=True,
)
def submit(command, request, args=""):
"""Submit a job with --extra and return its job id, or 0 if rejected."""
kwargs = {}
if command == "sbatch":
args += " -o /dev/null"
else:
kwargs["background"] = True
return atf.submit_job(
command, f"{args} --extra={shlex.quote(request)}", "true", **kwargs
)
def assert_runs(job_id, nodes, request):
assert job_id, f"Verify job with --extra={request!r} is accepted"
assert atf.wait_for_job_state(
job_id, "COMPLETED"
), f"Verify job with --extra={request!r} runs"
job_nodes = atf.node_range_to_list(
atf.get_job_parameter(job_id, "NodeList", fatal=True)
)
assert set(job_nodes) <= set(
nodes
), f"Verify job with --extra={request!r} runs on {nodes}, not {job_nodes}"
def assert_pends(job_id, request):
assert job_id, f"Verify job with --extra={request!r} is accepted"
assert atf.wait_for_job_state(
job_id, "PENDING", desired_reason="Constraints"
), f"Verify job with --extra={request!r} pends with Reason=Constraints"
@pytest.mark.parametrize("request_expr", MATCHING)
def test_matching(nodes, request_expr):
"""Verify a job runs on the nodes its --extra matches"""
assert_runs(submit("sbatch", request_expr), nodes[1:], request_expr)
@pytest.mark.parametrize("request_expr", NOT_MATCHING)
def test_not_matching(nodes, request_expr):
"""Verify a job pends when no node matches its --extra"""
assert_pends(submit("sbatch", request_expr), request_expr)
@pytest.mark.parametrize("command", COMMANDS)
def test_node_count(nodes, command):
"""Verify a job only runs when enough nodes match its --extra"""
matching = len(nodes) - 1
assert_runs(submit(command, "a=1.23", f"-N{matching}"), nodes[1:], "a=1.23")
assert_pends(submit(command, "a=1.23", f"-N{matching + 1}"), "a=1.23")
@pytest.mark.parametrize("command", COMMANDS)
def test_starts_after_node_update(first_node, command):
"""Verify a pending job starts once a node is updated to match its --extra"""
job_id = submit(command, "late=1")
assert_pends(job_id, "late=1")
set_extra(first_node, '{"late":1}')
assert_runs(job_id, [first_node], "late=1")
@pytest.mark.parametrize("request_expr", ILLEGAL)
def test_illegal(nodes, request_expr):
"""Verify a job with an --extra that does not parse is rejected"""
error = atf.run_command_error(
f"sbatch --extra={shlex.quote(request_expr)} -o /dev/null --wrap true",
fatal=True,
xfail=True,
)
assert INVALID_EXTRA in error, f"Verify --extra={request_expr!r} is rejected"
@pytest.mark.parametrize("command", ["salloc", "srun"])
def test_illegal_allocation(nodes, command):
"""Verify salloc and srun are rejected like sbatch when --extra does not parse"""
error = atf.run_command_error(
f"{command} --extra='a=1&' true", fatal=True, xfail=True
)
assert INVALID_EXTRA in error, "Verify --extra='a=1&' is rejected"
def test_update_job_extra(nodes):
"""Verify scontrol update job only accepts an --extra that parses"""
job_id = atf.submit_job_sbatch(
"-H --extra='a=1.23' -o /dev/null --wrap true", fatal=True
)
result = atf.run_command(
f"scontrol update jobid={job_id} extra='a=1&'",
user=atf.properties["slurm-user"],
fatal=True,
xfail=True,
)
assert result["exit_code"] != 0, "Verify the update is rejected"
assert INVALID_EXTRA in result["stderr"], "Verify scontrol reports why"
assert (
atf.get_job_parameter(job_id, "Extra") == "a=1.23"
), "Verify the job Extra is unchanged"
atf.run_command(
f"scontrol update jobid={job_id} extra='late=1'",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(f"scontrol release {job_id}", fatal=True)
assert_pends(job_id, "late=1")