blob: a70a8c6780d70f368ed3463f15fcb8191da16133 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify scancel rejects an invalid job array expression.
Scripts branch on scancel's exit status, so an expression that cancels
nothing must be reported as a failure rather than as a successful no-op,
and must signal no task.
"""
import pytest
import atf
# Before 26.11 scancel exited zero for these: "_[0-]" cancelled task 0, and
# the others were treated like an already finished job. slurmctld decides
# whether these are rejected, so an older scancel rejects them too once
# slurmctld is 26.11.
previously_accepted = pytest.mark.xfail(
atf.get_version("sbin/slurmctld") < (26, 11),
reason="Ticket 25423: scancel exited zero for these array expressions before 26.11",
)
# Expressions that violate the documented array task id syntax. "{max}" is
# replaced with MaxArraySize.
INVALID_ARRAY_EXPRS = [
pytest.param("_[3-2]", id="end-before-start"),
pytest.param("_[0-10:0]", id="zero-step"),
pytest.param("_-1", id="negative"),
pytest.param("_abc", id="non-numeric"),
pytest.param("_[]", id="empty-brackets"),
pytest.param("_", id="empty", marks=previously_accepted),
pytest.param("_[0-]", id="range-without-end", marks=previously_accepted),
pytest.param("_{max}", id="too-large", marks=previously_accepted),
pytest.param("_[{max}]", id="too-large-bracketed", marks=previously_accepted),
]
# scancel.1 documents the same handling of an array_id with and without --ctld.
SCANCEL_MODES = ["", "--ctld"]
# Before 26.11 --ctld rejected some of INVALID_ARRAY_EXPRS and silently accepted
# others, so those cases cannot be stated as one expectation and are skipped
# there. Only tests that parametrize over INVALID_ARRAY_EXPRS need to skip.
INVALID_SCANCEL_MODES = [
"",
pytest.param(
"--ctld",
marks=pytest.mark.skipif(
atf.get_version("bin/scancel") < (26, 11),
reason="Ticket 25423: scancel --ctld rejected these expressions inconsistently before 26.11",
),
),
]
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."""
job_id = atf.submit_job_sbatch(
f"-a 0-{array_size - 1} -t5 --hold --wrap 'sleep infinity'",
fatal=True,
)
yield job_id
def scancel_command(mode, job_id_str):
"""Returns the scancel command for cancelling job_id_str with mode."""
mode_str = f"{mode} " if mode else ""
return f"scancel {mode_str}{job_id_str}"
def array_task_states(job_id):
"""Returns a dict of array task id to state for the tasks of a job array."""
output = atf.run_command_output(
f"squeue --noheader --jobs={job_id} --array --states=all "
"--Format=ArrayTaskId,State",
fatal=True,
).split()
return dict(zip(output[::2], output[1::2]))
def assert_array_intact(job_id):
"""Asserts that every task of the job array still exists."""
states = array_task_states(job_id)
assert sorted(states) == sorted(
str(task_id) for task_id in range(array_size)
), f"Every task of {job_id} should still exist, but found {states}"
@pytest.mark.parametrize("mode", INVALID_SCANCEL_MODES)
@pytest.mark.parametrize("expr", INVALID_ARRAY_EXPRS)
def test_invalid_array_expression(array_job_id, expr, mode):
"""Test that an invalid array expression is refused and cancels no task."""
# slurm.conf documents the maximum index as one less than MaxArraySize.
max_array_size = atf.get_config_parameter("MaxArraySize")
job_id_str = f"{array_job_id}{expr.format(max=max_array_size)}"
command = scancel_command(mode, job_id_str)
assert (
atf.run_command_exit(command, xfail=True) != 0
), f"{command} should exit non-zero"
assert_array_intact(array_job_id)
@pytest.mark.parametrize("mode", SCANCEL_MODES)
def test_max_index_expression(mode):
"""Test that scancel accepts and cancels the highest legal task index."""
max_index = int(atf.get_config_parameter("MaxArraySize")) - 1
job_id = atf.submit_job_sbatch(
f"-a 0,{max_index} -t5 --hold --wrap 'sleep infinity'", fatal=True
)
command = scancel_command(mode, f"{job_id}_{max_index}")
assert atf.run_command_exit(command) == 0, f"{command} should exit zero"
assert sorted(array_task_states(job_id)) == [
"0"
], f"Only task {max_index} of {job_id} should have been cancelled"
@pytest.mark.parametrize("mode", SCANCEL_MODES)
def test_valid_array_expression(array_job_id, mode):
"""Test that scancel still cancels a task for a valid array expression."""
cancelled = array_size - 1
job_id_str = f"{array_job_id}_{cancelled}"
command = scancel_command(mode, job_id_str)
assert atf.run_command_exit(command) == 0, f"{command} should exit zero"
assert sorted(array_task_states(array_job_id)) == sorted(
str(task_id) for task_id in range(cancelled)
), f"Only task {cancelled} of {array_job_id} should have been cancelled"
@pytest.mark.parametrize("mode", SCANCEL_MODES)
def test_finished_task_expression(mode):
"""Test that scancel treats naming an already finished task as no error."""
job_id = atf.submit_job_sbatch(
f"-a 0-{array_size - 1} -t1 -o /dev/null --wrap true", fatal=True
)
for _ in atf.timer(fatal=True):
states = array_task_states(job_id)
if len(states) == array_size and all(
state == "COMPLETED" for state in states.values()
):
break
command = scancel_command(mode, f"{job_id}_{array_size - 1}")
assert atf.run_command_exit(command) == 0, f"{command} should exit zero"
@pytest.mark.parametrize("mode", SCANCEL_MODES)
def test_absent_task_expression(mode):
"""Test that scancel treats naming a task never in the array as no error."""
job_id = atf.submit_job_sbatch(
"-a 0,2 -t5 --hold --wrap 'sleep infinity'", fatal=True
)
command = scancel_command(mode, f"{job_id}_1")
assert atf.run_command_exit(command) == 0, f"{command} should exit zero"
assert sorted(array_task_states(job_id)) == [
"0",
"2",
], f"{command} should leave every task of {job_id}"
@pytest.mark.parametrize("mode", SCANCEL_MODES)
@pytest.mark.parametrize(
"expr,remaining",
[
pytest.param("_[0-1]", ["2"], id="range"),
pytest.param("_[0,2]", ["1"], id="list"),
],
)
def test_bracketed_array_expression(array_job_id, expr, remaining, mode):
"""Test that scancel cancels exactly the tasks a bracketed expression names."""
command = scancel_command(mode, f"{array_job_id}{expr}")
assert atf.run_command_exit(command) == 0, f"{command} should exit zero"
assert (
sorted(array_task_states(array_job_id)) == remaining
), f"{command} should leave only task {remaining} of {array_job_id}"