blob: 1fd9e88a1f9898a988bb1c528806acc90c99c4ca [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test scontrol release/update of pending jobs in a partition with no nodes."""
import pytest
import atf
FEATURE = "emptyfeat"
NODESET = "ns_emptyfeat"
PARTITION = "emptypart"
CPU_PARTITION = "cpupart"
MAXCPU_PARTITION = "maxcpupart"
# A job in a partition with no nodes requests more CPUs than the partition
# has (zero), so it reports PartitionCpuLimit and stays pending. The same
# reason also covers a populated partition that simply doesn't have enough
# CPUs for the job.
REASON = "PartitionCpuLimit"
def expected_reason():
"""Reason name rendered for these jobs by this combination of versions.
Ticket 25769: PartitionCpuLimit replaces PartitionConfig in 26.11. A
slurmctld that sends the new reason to an older scontrol is rendered as
InvalidReason, so there is nothing to compare against in that case.
"""
if atf.get_version("sbin/slurmctld") < (26, 11):
return "PartitionConfig"
if atf.get_version("bin/scontrol") < (26, 11):
pytest.skip("scontrol older than 26.11 can't render PartitionCpuLimit")
return REASON
@pytest.fixture(scope="module", autouse=True)
def setup():
# Two CPUs per node let MAXCPU_PARTITION tell its MaxCPUsPerNode limit
# apart from the CPUs the nodes have.
atf.require_nodes(1, [("CPUs", 2)])
atf.require_config_parameter("EnforcePartLimits", "NO")
atf.require_config_parameter("Nodeset", {NODESET: {"Feature": FEATURE}})
atf.require_config_parameter(
"PartitionName",
{
PARTITION: {"Nodes": NODESET},
CPU_PARTITION: {"Nodes": "ALL"},
MAXCPU_PARTITION: {"Nodes": "ALL", "MaxCPUsPerNode": 1},
},
)
atf.require_slurm_running()
# No node has FEATURE, so the NodeSet resolves to nothing.
nodes = atf.run_command_output(f"sinfo -h -p {PARTITION} -o %N", fatal=True).strip()
if nodes:
pytest.fail(
f"Precondition: partition {PARTITION} should have no nodes, got '{nodes}'"
)
def test_submit_to_empty_partition_succeeds():
"""EnforcePartLimits=NO permits submission to an empty partition.
Guards the documented behavior that the update-path fix must not regress.
"""
reason = expected_reason()
job_id = atf.submit_job_sbatch(
f"-p {PARTITION} --wrap='sleep infinity'", fatal=True
)
assert atf.wait_for_job_state(
job_id, "PENDING", desired_reason=reason
), f"Job {job_id} should be PENDING with Reason={reason}"
@pytest.mark.skipif(
min(atf.get_version("sbin/slurmctld"), atf.get_version("bin/scontrol")) < (26, 11),
reason="Ticket 25769: scontrol update returned an error for jobs "
"exceeding a partition's CPU limit with EnforcePartLimits=NO before "
"26.11.",
)
@pytest.mark.parametrize(
"partition,size_from",
[
(PARTITION, None),
(CPU_PARTITION, "TotalCPUs"),
(MAXCPU_PARTITION, "TotalNodes"),
],
ids=["empty", "total_cpus", "max_cpus_per_node"],
)
def test_update_job_exceeding_partition_cpu_limit(partition, size_from):
"""scontrol update succeeds for a job pending on PartitionCpuLimit.
An empty partition is only the degenerate case (0 total CPUs). A job
that asks for one CPU more than a populated partition has pends the same
way. Every node has at least 2 CPUs and MAXCPU_PARTITION has
MaxCPUsPerNode=1, so a job asking for one CPU more than the node count
fits that partition's CPUs but exceeds its limit.
"""
ntasks = 1
if size_from:
ntasks = atf.get_partitions()[partition][size_from] + 1
job_id = atf.submit_job_sbatch(
f"-p {partition} -n{ntasks} --wrap='sleep infinity'", fatal=True
)
assert atf.wait_for_job_state(
job_id, "PENDING", desired_reason=REASON
), f"Job {job_id} should be PENDING with Reason={REASON}"
result = atf.run_command(f"scontrol update jobid={job_id} nice=100")
assert result["exit_code"] == 0, (
f"scontrol update should succeed for a job exceeding its "
f"partition's CPU limit. Got stderr: {result['stderr']}"
)
assert result["stderr"] == "", (
f"scontrol update should not report an error. "
f"Got stderr: {result['stderr']}"
)
assert (
atf.get_job_parameter(job_id, "Nice") == 100
), f"Job {job_id}'s Nice value should have been updated to 100"
@pytest.mark.skipif(
min(atf.get_version("sbin/slurmctld"), atf.get_version("bin/scontrol")) < (26, 11),
reason="Ticket 25769: scontrol release returned exit code 1 for a held job "
"in an empty partition with EnforcePartLimits=NO before 26.11.",
)
def test_release_held_job_in_empty_partition():
"""scontrol release succeeds for a held job in an empty partition.
The hold was always lifted, but the RPC returned
ESLURM_REQUESTED_PART_CONFIG_UNAVAILABLE, so scontrol exited 1.
"""
job_id = atf.submit_job_sbatch(
f"-p {PARTITION} -H --wrap='sleep infinity'", fatal=True
)
assert atf.wait_for_job_state(
job_id, "PENDING", desired_reason="JobHeldUser"
), f"Job {job_id} should start out held"
result = atf.run_command(f"scontrol release {job_id}")
assert result["exit_code"] == 0, (
f"scontrol release should succeed for a held job in an empty "
f"partition. Got stderr: {result['stderr']}"
)
assert result["stderr"] == "", (
f"scontrol release should not report an error. "
f"Got stderr: {result['stderr']}"
)
# The hold is gone and the job now waits on the partition instead.
assert atf.wait_for_job_state(
job_id, "PENDING", desired_reason=REASON
), f"Released job {job_id} should be PENDING with Reason={REASON}"
@pytest.fixture
def restore_features():
saved = {
n: (
atf.get_node_parameter(n, "features", default=[]),
atf.get_node_parameter(n, "active_features", default=[]),
)
for n in atf.nodes
}
yield
for node, (avail, active) in saved.items():
avail_str = ",".join(avail) if avail else ""
active_str = ",".join(active) if active else ""
atf.run_command(
f"scontrol update nodename={node} "
f"activefeatures={active_str} availablefeatures={avail_str}",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 11),
reason="Issue 50815: nodes did not join feature-based NodeSet partitions "
"after scontrol update before 26.11.",
)
def test_job_starts_once_partition_gains_a_node(restore_features, request):
"""A PartitionCpuLimit job un-pends once its partition gains a node.
Guards the scheduler actually retrying the job as the cluster changes,
not just suppressing the misleading error.
"""
reason = expected_reason()
node = sorted(atf.nodes)[0]
# select/linear keeps its own per-node partition list, which a feature
# update does not refresh, so the job picks the node but can't start.
if atf.get_config_parameter("SelectType", quiet=True) == "select/linear":
request.applymarker(
pytest.mark.xfail(
reason="Nodes joining a NodeSet partition after a feature "
"update are unknown to select/linear"
)
)
job_id = atf.submit_job_sbatch(
f"-p {PARTITION} --wrap='sleep infinity'", fatal=True
)
assert atf.wait_for_job_state(
job_id, "PENDING", desired_reason=reason
), f"Job {job_id} should be PENDING with Reason={reason}"
atf.run_command(
f"scontrol update nodename={node} availablefeatures={FEATURE}",
user=atf.properties["slurm-user"],
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), f"Job {job_id} should start once {node} joins {PARTITION}"