blob: 004f0177bebe45f5452783e2b8c1141185669290 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test a completing multi-partition job keeps its partition on recovery.
Issue 50290: a completing job (epilog still running) is non-pending, so
restart and reconfigure must recover its allocated partition, not the
PriorityTier sorted list head.
"""
from pathlib import Path
import pytest
import atf
# A multi-partition job that is completing (its epilog still running) is
# non-pending, so state recovery must recover its allocated partition the same
# way it does for a running or suspended job, rather than the PriorityTier
# sorted list head.
EPILOG_TIMEOUT = 300
# Marker file the Epilog blocks on, created before each test and removed in the
# teardown so the COMPLETING window lasts exactly as long as the test body
# rather than a fixed timeout. Resolved to an absolute path under the test's cwd
# in setup(), since the isolated working directory is only in place once the
# module starts.
epilog_marker = None
# Three overlapping partitions of distinct PriorityTier. pthigh spans node1 only,
# ptmid spans node1,node2 and ptlow spans all three nodes. With node1 and node2
# busy a job submitted to all three can only run in ptlow (the lowest tier), so
# its allocated partition is the tail of the PriorityTier sorted list, not the
# head.
PARTITIONS = {
"pthigh": {"Nodes": "node1", "PriorityTier": 100},
"ptmid": {"Nodes": "node1,node2", "PriorityTier": 50},
"ptlow": {"Nodes": "node1,node2,node3", "PriorityTier": 1},
}
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_nodes(3, [("CPUs", 1)])
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
# Define the partitions in slurm.conf rather than with 'scontrol create' so
# they survive the slurmctld restart these tests perform (runtime created
# partitions are not recovered on restart).
atf.require_config_parameter("PartitionName", PARTITIONS)
# An Epilog that blocks while the marker file exists holds a finishing job
# in COMPLETING; the teardown removes the marker so it exits without hitting
# PrologEpilogTimeout (generous, so it fires only on a failed test).
global epilog_marker
epilog_marker = str(Path.cwd() / "epilog_block")
epilog = str(Path.cwd() / "epilog.sh")
atf.make_bash_script(
epilog,
f"while [ -f {epilog_marker} ]; do sleep 0.5; done\nexit 0\n",
)
atf.require_config_parameter("Epilog", epilog)
atf.require_config_parameter("PrologEpilogTimeout", EPILOG_TIMEOUT)
atf.require_slurm_running()
@pytest.fixture(scope="function", autouse=True)
def hold_completing(setup):
# Create the marker so a cancelled job's epilog blocks and the job stays
# COMPLETING for the test body.
Path(epilog_marker).touch()
yield
# Remove the marker so every cancelled job's epilog exits and the job leaves
# COMPLETING promptly, then wait the jobs out. Without this the epilog would
# block until PrologEpilogTimeout and slurmctld would drain the node.
Path(epilog_marker).unlink(missing_ok=True)
atf.cancel_all_jobs(fatal=True)
# Tests delete a partition at runtime or drop one from slurm.conf; rewrite
# the canonical layout (this reconfigures Slurm) so the next test starts
# clean with all three partitions present.
atf.set_config_parameter("PartitionName", PARTITIONS)
# If an epilog timed out earlier its node was drained (a reconfigure does
# not clear that); resume all three so the next test's blockers can start.
# Safety net: removing the marker above should keep nodes from draining.
for node in ("node1", "node2", "node3"):
atf.run_command(
f"scontrol update nodename={node} state=RESUME",
user=atf.properties["slurm-user"],
quiet=True,
)
atf.wait_for_node_state(node, "IDLE", fatal=True)
def _submit_completing_job_in_ptlow():
"""Fill node1,node2 then submit a multi-partition job, cancel it completing.
Returns the completing job's id. The job lands in ptlow (the lowest tier,
so the tail of the sorted list) since the higher tiers are full; cancelling
it leaves it COMPLETING while the marker epilog blocks.
"""
# Fill node1 (pthigh's only node) and node2 so only node3 stays free. Both
# blockers run in ptlow and are pinned with -w, so neither holds ptmid (the
# secondary the delete tests remove) and node3 is reserved for the multi
# job (reached through ptlow, the lowest tier partition).
for node in ("node1", "node2"):
blocker_id = atf.submit_job_sbatch(
f"-p ptlow -w {node} -J blocker -t 10 --wrap='sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(
blocker_id, "RUNNING"
), f"Blocker job on {node} should run"
job_id = atf.submit_job_sbatch(
"-p ptlow,ptmid,pthigh -J multi -t 10 --wrap='sleep infinity'", fatal=True
)
assert atf.wait_for_job_state(job_id, "RUNNING"), "Multi-partition job should run"
partition = atf.get_job_parameter(job_id, "Partition")
assert (
partition == "ptlow"
), f"Job should be allocated the lowest tier partition, got {partition}"
# Signal the job and observe it enter COMPLETING; the marker epilog holds it
# there. A bare scancel is used rather than atf.cancel_jobs() because the
# latter waits for a terminal state, which a job intentionally held in
# COMPLETING never reaches before timing out.
atf.run_command(f"scancel {job_id}", user=atf.properties["slurm-user"], fatal=True)
assert atf.wait_for_job_state(
job_id, "COMPLETING"
), "Job should be completing while its epilog runs"
return job_id
def test_completing_multi_partition_partition_kept_on_restart():
"""A completing multi-partition job keeps its allocated partition on restart.
A completing job (terminal base state, epilog still running) is non-pending,
so recovery must restore its allocated partition (ptlow), not the sorted
head (pthigh). A marker epilog holds it COMPLETING across both recovery
paths -- a restart and a reconfigure. Guarded here because Issue 50290
reworked recovery.
"""
atf.require_version(
(24, 11),
reason="Ticket 22010: allocated partition recovered on restart",
)
job_id = _submit_completing_job_in_ptlow()
atf.restart_slurmctld()
assert atf.wait_for_job_state(
job_id, "COMPLETING"
), "Job should still be completing after restart"
# The completing job must report the partition it ran in (ptlow). A
# regression that recovered the sorted head would report pthigh.
partition = atf.get_job_parameter(job_id, "Partition")
assert partition == "ptlow", (
f"Completing job's allocated partition changed across restart to "
f"{partition}, expected ptlow"
)
# A reconfigure re-derives part_ptr through the other recovery path while
# the job is still completing; the allocated partition must survive it too.
atf.run_command(
"scontrol reconfigure",
fatal=True,
user=atf.properties["slurm-user"],
)
assert atf.wait_for_job_state(
job_id, "COMPLETING"
), "Job should still be completing after reconfigure"
partition = atf.get_job_parameter(job_id, "Partition")
assert partition == "ptlow", (
f"Completing job's allocated partition changed across reconfigure to "
f"{partition}, expected ptlow"
)
def test_completing_keeps_partition_when_secondary_removed_on_reconfigure():
"""Reconfigure dropping a secondary keeps a completing job's partition.
The reconfigure path (_sync_jobs_to_conf) must also keep a completing job on
its allocated partition (ptlow) when a surviving secondary (ptmid) is
dropped from slurm.conf, not re-derive the sorted head.
"""
atf.require_version(
(25, 11),
reason="Ticket 22929: partition kept when secondary removed on reconfigure",
)
job_id = _submit_completing_job_in_ptlow()
surviving = {k: v for k, v in PARTITIONS.items() if k != "ptmid"}
atf.set_config_parameter("PartitionName", surviving)
assert atf.wait_for_job_state(
job_id, "COMPLETING"
), "Job should stay completing after dropping a secondary partition"
partition = atf.get_job_parameter(job_id, "Partition")
assert partition == "ptlow", (
f"Dropping a secondary partition moved the completing job to "
f"{partition}, expected ptlow"
)
def test_completing_keeps_partition_when_secondary_deleted():
"""Deleting a secondary partition must not move a completing job's partition.
A completing job is IS_JOB_FINISHED, so partition_in_use() does not block
deleting its secondaries. Deleting the middle one (ptmid) must leave the
allocated partition (ptlow) unchanged; the old recovery repointed part_ptr
to the list head (pthigh).
"""
atf.require_version(
(26, 11),
reason="Issue 50290: allocated partition kept on partition delete",
)
job_id = _submit_completing_job_in_ptlow()
# ptmid holds no job, so deleting it is allowed; the completing job's
# allocated partition (ptlow) must be left untouched and the job not killed.
atf.run_command(
"scontrol delete PartitionName=ptmid",
fatal=True,
user=atf.properties["slurm-user"],
)
assert atf.wait_for_job_state(
job_id, "COMPLETING"
), "Job should stay completing after deleting a secondary partition"
partition = atf.get_job_parameter(job_id, "Partition")
assert partition == "ptlow", (
f"Deleting a secondary partition moved the completing job to "
f"{partition}, expected ptlow"
)