blob: 71c5669c78ef2778e791fe9809d7d917cd230e58 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify scontrol rejects an invalid job array expression.
An expression that selects no task must be refused without changing any
task, and must say why rather than blame the job id as a whole.
"""
import pytest
import atf
# Expressions that violate the documented array task id syntax: a range whose
# end precedes its start, a zero step, and an index above the maximum, with and
# without brackets. "{max}" becomes MaxArraySize, which is one above the
# maximum index.
INVALID_ARRAY_EXPRS = ["_[3-2]", "_[0-10:0]", "_[{max}]", "_{max}"]
# Expressions with the error each one must report.
INVALID_ARRAY_MESSAGES = [
("_[3-2]", "Job Array ID range end can not be less than its start"),
("_[{max}]", "Job Array ID larger than acceptable range"),
("_{max}", "Job Array ID larger than acceptable range"),
]
UPDATE_OPERATION = "update JobId={job_id_str} TimeLimit=1"
REQUEUE_OPERATION = "requeue {job_id_str}"
SUSPEND_OPERATION = "suspend {job_id_str}"
RESUME_OPERATION = "resume {job_id_str}"
# Only a privileged user or coordinator may suspend and resume a job, not its
# owner, so those run as SlurmUser to reach the array expression rather than
# stop at the access check.
ADMIN_OPERATIONS = [SUSPEND_OPERATION, RESUME_OPERATION]
REJECTING_OPERATIONS = [
UPDATE_OPERATION,
REQUEUE_OPERATION,
SUSPEND_OPERATION,
RESUME_OPERATION,
]
ACCEPTING_OPERATIONS = [UPDATE_OPERATION, REQUEUE_OPERATION]
array_size = 3
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter("MaxArraySize", array_size, ">=")
atf.require_slurm_running()
@pytest.fixture
def array_job_id():
"""Submits a job array and returns its job id."""
return atf.submit_job_sbatch(
f"-a 0-{array_size - 1} -t5 --hold --wrap 'sleep infinity'",
fatal=True,
)
def scontrol_command(operation, job_id_str):
"""Returns the scontrol command applying operation to job_id_str."""
return f"scontrol {operation.format(job_id_str=job_id_str)}"
def array_task_time_limits(job_id):
"""Returns dict from task ids to the time limits of a job array."""
output = atf.run_command_output(
f"squeue --noheader --array --states=all --Format=ArrayTaskId,TimeLimit "
f"--jobs={job_id}",
fatal=True,
).strip()
limits = {}
for line in output.split("\n"):
if line:
task_id, limit = line.split()
limits[int(task_id)] = limit
return limits
# Only update is checked here. The array is held, so requeue, suspend and
# resume refuse any of its tasks whatever the expression.
@pytest.mark.parametrize("expr", INVALID_ARRAY_EXPRS)
def test_invalid_array_expression(array_job_id, expr):
"""Test that an invalid array expression is rejected and changes no task."""
max_array_size = atf.get_config_parameter("MaxArraySize")
job_id_str = f"{array_job_id}{expr.format(max=max_array_size)}"
before = array_task_time_limits(array_job_id)
command = scontrol_command(UPDATE_OPERATION, job_id_str)
assert (
atf.run_command_exit(command, xfail=True) != 0
), f"{command} should exit non-zero"
assert (
array_task_time_limits(array_job_id) == before
), f"{command} should have changed no task"
# slurmctld picks the error but scontrol renders it from its own copy of the
# error table, so scontrol has to be new enough to expect the specific message.
# slurmctld is never older than its client commands, so this covers it too.
@pytest.mark.skipif(
atf.get_version("bin/scontrol") < (26, 11),
reason="Ticket 25423: an invalid array expression was reported as a generic invalid job id before 26.11",
)
@pytest.mark.parametrize("operation", REJECTING_OPERATIONS)
@pytest.mark.parametrize("expr,message", INVALID_ARRAY_MESSAGES)
def test_invalid_array_expression_message(array_job_id, expr, message, operation):
"""Test that scontrol names why an array expression was rejected."""
max_array_size = atf.get_config_parameter("MaxArraySize")
job_id_str = f"{array_job_id}{expr.format(max=max_array_size)}"
command = scontrol_command(operation, job_id_str)
user = None
if operation in ADMIN_OPERATIONS:
user = atf.properties["slurm-user"]
result = atf.run_command(command, user=user, xfail=True)
assert (
message in result["stderr"]
), f"{command} should report {message!r}, but printed {result['stderr']!r}"
@pytest.mark.parametrize("operation", ACCEPTING_OPERATIONS)
def test_valid_array_expression(array_job_id, operation):
"""Test that a valid array expression is not rejected for its syntax.
Only the parse is under test here. An operation can still refuse a task
for its own reasons, such as requeueing one that is still held, so this
asserts the array errors are absent rather than that the command succeeds.
"""
command = scontrol_command(operation, f"{array_job_id}_0")
stderr = atf.run_command_error(command, xfail=True)
assert (
"Job Array ID" not in stderr
), f"{command} should not report an array id error, but printed {stderr!r}"
def test_valid_array_expression_applies(array_job_id):
"""Test that scontrol update changes only the task a valid expression names."""
before = array_task_time_limits(array_job_id)
command = scontrol_command(UPDATE_OPERATION, f"{array_job_id}_0")
assert atf.run_command_exit(command) == 0, f"{command} should exit zero"
after = array_task_time_limits(array_job_id)
assert after[0] != before[0], f"{command} should have changed task 0"
assert {t: v for t, v in after.items() if t != 0} == {
t: v for t, v in before.items() if t != 0
}, f"{command} should have changed no other task"