| ############################################################################ |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| ############################################################################ |
| """Test a multi-partition job keeps its partition across runtime changes. |
| |
| Issue 50290: a job allocated the lowest-tier partition must keep or |
| correctly rebuild that partition across delete, reconfigure, suspend, |
| requeue, PriorityTier updates and restart, never jumping to the sorted |
| list head. |
| """ |
| |
| import pytest |
| |
| import atf |
| |
| pytestmark = pytest.mark.slow |
| |
| # Canonical partition layout: 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 partition 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 some of these tests perform (runtime |
| # created partitions are not recovered on restart). |
| atf.require_config_parameter("PartitionName", PARTITIONS) |
| atf.require_accounting(modify=True) |
| atf.require_slurm_running() |
| |
| |
| @pytest.fixture(scope="function", autouse=True) |
| def restore_partitions(): |
| yield |
| # 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) |
| |
| |
| def _submit_job_running_in_ptlow(extra_args=""): |
| """Submit blockers filling node1,node2 plus a job that lands in ptlow. |
| |
| Returns (blocker_ids, job_id). The job lands in ptlow (lowest tier) since |
| the higher tiers are full. Blockers use ptlow (spans every node), so none |
| holds ptmid -- it stays a deletable secondary while node3 is left free for |
| the job. extra_args is appended to the job submission. |
| """ |
| |
| # 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 test removes) and node3 is reserved for the multi |
| # job. |
| blocker_ids = [] |
| 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" |
| blocker_ids.append(blocker_id) |
| |
| # The job prefers the higher tiers but only node3 (ptlow) is free. |
| args = "-p ptlow,ptmid,pthigh -J multi -t 10 --wrap='sleep infinity'" |
| if extra_args: |
| args = f"{extra_args} {args}" |
| job_id = atf.submit_job_sbatch(args, 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}" |
| |
| return blocker_ids, job_id |
| |
| |
| def _assert_partition_matches_accounting(job_id, scontrol_partition, context): |
| # The accounting partition column is written once at job start by |
| # as_mysql_job_start() and not re-derived by the delete/reconfigure/ |
| # PriorityTier-update paths here, so sacct holds the frozen start value |
| # (ptlow): the ground truth for where the job ran. Wait for the |
| # asynchronous job-start record first (fatal), then check that the |
| # in-memory partition matches that frozen value. |
| atf.wait_for_job_accounted(job_id, field="Partition", value="ptlow", fatal=True) |
| sacct_partition = atf.run_command_output( |
| f"sacct -XPnj {job_id} -o Partition", fatal=True |
| ).strip() |
| assert scontrol_partition == sacct_partition, ( |
| f"{context}: kept partition does not match accounting: scontrol " |
| f"reports {scontrol_partition}, sacct reports the frozen-at-start " |
| f"{sacct_partition}" |
| ) |
| |
| |
| def test_suspended_multi_partition_partition_kept_on_restart(): |
| """A suspended multi-partition job keeps its partition across a restart. |
| |
| Recovery treats suspended jobs like running ones, so a suspended job |
| allocated ptlow must keep it across a restart, not be reassigned to the |
| sorted head (pthigh), and resume correctly. |
| """ |
| |
| atf.require_version( |
| (24, 11), |
| reason="Ticket 22010: allocated partition recovered on restart", |
| ) |
| |
| _, job_id = _submit_job_running_in_ptlow() |
| |
| atf.run_command( |
| f"scontrol suspend {job_id}", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state(job_id, "SUSPENDED"), "Job should be suspended" |
| |
| atf.restart_slurmctld() |
| assert atf.wait_for_job_state( |
| job_id, "SUSPENDED" |
| ), "Job should still be suspended after restart" |
| |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert partition == "ptlow", ( |
| f"Suspended job's allocated partition changed across restart to " |
| f"{partition}, expected ptlow" |
| ) |
| |
| atf.run_command( |
| f"scontrol resume {job_id}", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state(job_id, "RUNNING"), "Job should resume to running" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert ( |
| partition == "ptlow" |
| ), f"Resumed job's allocated partition changed to {partition}, expected ptlow" |
| |
| |
| def test_priority_tier_update_keeps_running_job_partition_on_restart(): |
| """A runtime PriorityTier update keeps a running job's partition on restart. |
| |
| Ticket 22010: a runtime 'scontrol update ... PriorityTier=...' re-sorts |
| every job's part_ptr_list and rebuilds its string. A running job's part_ptr |
| must stay on its allocated partition (ptlow), not move to the new sorted |
| head -- here raising ptmid above pthigh makes ptmid the head. The allocated |
| partition must survive both the update and a restart. Guarded here because |
| Issue 50290 reworked recovery. |
| """ |
| |
| atf.require_version( |
| (24, 11), |
| reason="Ticket 22010: allocated partition kept across PriorityTier update", |
| ) |
| |
| _, job_id = _submit_job_running_in_ptlow() |
| |
| # Raise ptmid above pthigh so the PriorityTier sorted head changes from |
| # pthigh to ptmid. ptmid spans only the busy node1,node2, so this frees |
| # nothing and the job keeps running in ptlow. |
| atf.run_command( |
| "scontrol update PartitionName=ptmid PriorityTier=200", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| |
| # The running job must keep its allocated partition (ptlow); a regression |
| # that repointed part_ptr to the new sorted head would report ptmid. |
| assert atf.wait_for_job_state( |
| job_id, "RUNNING" |
| ), "Job should stay running across the PriorityTier update" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert partition == "ptlow", ( |
| f"Updating a partition's PriorityTier moved the running job to " |
| f"{partition}, expected ptlow" |
| ) |
| |
| # A restart must recover the same allocated partition rather than the new |
| # sorted head. |
| atf.restart_slurmctld() |
| assert atf.wait_for_job_state( |
| job_id, "RUNNING" |
| ), "Job should still be running after restart" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| _assert_partition_matches_accounting( |
| job_id, |
| partition, |
| "Running job's allocated partition changed across restart", |
| ) |
| |
| |
| def test_delete_secondary_partition_keeps_running_job_partition(): |
| """Deleting a secondary partition must not move a running job's partition. |
| |
| Ticket 22010: a running job allocated ptlow has the sorted list |
| [pthigh, ptmid, ptlow]. Deleting the middle secondary (ptmid) must leave the |
| allocated partition (ptlow) unchanged, not repoint part_ptr to the head. |
| Three partitions are needed: with two, the surviving head equals the |
| allocated partition and the regression would not show. |
| """ |
| |
| atf.require_version( |
| (26, 11), |
| reason="Issue 50290: allocated partition kept on partition delete", |
| ) |
| |
| _, job_id = _submit_job_running_in_ptlow() |
| |
| # Deleting the job's allocated partition is refused while it is in use. |
| result = atf.run_command( |
| "scontrol delete PartitionName=ptlow", |
| user=atf.properties["slurm-user"], |
| ) |
| assert ( |
| result["exit_code"] != 0 |
| ), "Deleting the allocated partition of a running job should fail" |
| assert ( |
| "in use" in result["stderr"].lower() |
| ), f"Expected a partition-in-use error, got: {result['stderr']}" |
| |
| # Deleting the middle secondary partition is allowed and must not move the |
| # running job off its allocated partition. |
| atf.run_command( |
| "scontrol delete PartitionName=ptmid", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state( |
| job_id, "RUNNING" |
| ), "Job should stay running after deleting a secondary partition" |
| |
| # The regression repoints the recovered part_ptr to the list head, which |
| # scontrol reports, so the load-bearing check is that scontrol still reports |
| # ptlow after the delete. A regression would flip it to pthigh. |
| partition = atf.get_job_parameter(job_id, "Partition") |
| _assert_partition_matches_accounting( |
| job_id, |
| partition, |
| "Deleting the middle secondary partition moved the running job", |
| ) |
| |
| |
| def test_reconfigure_keeps_running_job_partition(): |
| """A plain reconfigure keeps a running job's allocated partition. |
| |
| The reconfigure recovery path (_sync_jobs_to_conf) must keep a running |
| multi-partition job on its allocated ptlow when slurm.conf is unchanged, |
| not re-derive the PriorityTier sorted head. |
| """ |
| |
| atf.require_version( |
| (24, 11), |
| reason="Ticket 22010: allocated partition recovered on reconfigure", |
| ) |
| |
| _, job_id = _submit_job_running_in_ptlow() |
| |
| atf.run_command( |
| "scontrol reconfigure", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state( |
| job_id, "RUNNING" |
| ), "Job should stay running across a plain reconfigure" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| _assert_partition_matches_accounting( |
| job_id, |
| partition, |
| "Running job's allocated partition changed across a plain reconfigure", |
| ) |
| |
| |
| def test_reconfigure_keeps_suspended_job_partition(): |
| """A plain reconfigure keeps a suspended job's allocated partition. |
| |
| Recovery treats suspended jobs like running ones, so the reconfigure |
| recovery path (_sync_jobs_to_conf) must keep a suspended multi-partition |
| job on its allocated ptlow when slurm.conf is unchanged, not re-derive the |
| PriorityTier sorted head, and it must resume correctly. |
| """ |
| |
| atf.require_version( |
| (24, 11), |
| reason="Ticket 22010: suspended job's partition recovered on reconfigure", |
| ) |
| |
| _, job_id = _submit_job_running_in_ptlow() |
| |
| atf.run_command( |
| f"scontrol suspend {job_id}", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state(job_id, "SUSPENDED"), "Job should be suspended" |
| |
| atf.run_command( |
| "scontrol reconfigure", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state( |
| job_id, "SUSPENDED" |
| ), "Job should stay suspended across a plain reconfigure" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert partition == "ptlow", ( |
| f"Suspended job's allocated partition changed across a plain " |
| f"reconfigure to {partition}, expected ptlow" |
| ) |
| |
| atf.run_command( |
| f"scontrol resume {job_id}", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state(job_id, "RUNNING"), "Job should resume to running" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert ( |
| partition == "ptlow" |
| ), f"Resumed job's allocated partition changed to {partition}, expected ptlow" |
| |
| |
| def test_reconfigure_dropping_secondary_keeps_running_job_partition(): |
| """Reconfigure dropping a secondary rebuilds a running job's partition list. |
| |
| Ticket 22010: dropping a surviving secondary from slurm.conf makes |
| _sync_jobs_to_conf() see an invalid token and rebuild the string -- a path |
| a plain reconfigure never reaches. The allocated ptlow must be kept and the |
| rebuilt sorted list must drop ptmid. A running job shows only its single |
| partition, so the rebuilt list is checked after requeuing it to pending. |
| """ |
| |
| atf.require_version( |
| (26, 11), |
| reason="Issue 50290: partition list rebuilt on reconfigure", |
| ) |
| |
| _, job_id = _submit_job_running_in_ptlow("--requeue") |
| |
| # Drop the surviving secondary ptmid from slurm.conf and reconfigure. The |
| # allocated ptlow survives, so the job keeps running on it; ptmid becomes an |
| # invalid token in the partition string and the list is rebuilt without it. |
| 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, "RUNNING" |
| ), "Job should stay running when only a secondary partition is dropped" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert partition == "ptlow", ( |
| f"Dropping a secondary partition moved the running job to {partition}, " |
| "expected ptlow" |
| ) |
| |
| # Requeue back to pending to expose the rebuilt partition list. It must list |
| # the surviving partitions in PriorityTier order with the dropped secondary |
| # (ptmid) gone; a regression that did not rebuild would still list ptmid. |
| atf.run_command( |
| f"scontrol requeuehold {job_id}", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state( |
| job_id, "PENDING" |
| ), "Requeued job should return to pending" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert partition == "pthigh,ptlow", ( |
| f"Rebuilt partition list should drop the removed secondary and stay in " |
| f"PriorityTier order, got {partition}" |
| ) |
| |
| |
| def test_delete_secondary_partition_keeps_suspended_job_partition(): |
| """Deleting a secondary partition must not move a suspended job's partition. |
| |
| The suspended counterpart of the running-job delete path: a suspended job |
| allocated ptlow must keep it when a secondary (ptmid) is deleted, not be |
| repointed to the head (pthigh) or killed, and resume correctly. |
| """ |
| |
| atf.require_version( |
| (26, 11), |
| reason="Issue 50290: allocated partition kept on partition delete", |
| ) |
| |
| _, job_id = _submit_job_running_in_ptlow() |
| |
| atf.run_command( |
| f"scontrol suspend {job_id}", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state(job_id, "SUSPENDED"), "Job should be suspended" |
| |
| # ptmid holds no job, so deleting it is allowed even while the job is |
| # suspended; the allocated partition (ptlow) must be left untouched. |
| atf.run_command( |
| "scontrol delete PartitionName=ptmid", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state( |
| job_id, "SUSPENDED" |
| ), "Job should stay suspended after deleting a secondary partition" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert partition == "ptlow", ( |
| f"Deleting a secondary partition moved the suspended job to " |
| f"{partition}, expected ptlow" |
| ) |
| |
| atf.run_command( |
| f"scontrol resume {job_id}", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state(job_id, "RUNNING"), "Job should resume to running" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert ( |
| partition == "ptlow" |
| ), f"Resumed job's allocated partition changed to {partition}, expected ptlow" |
| |
| |
| def test_requeued_multi_partition_restores_partition_list(): |
| """Requeuing a running multi-partition job restores its full partition set. |
| |
| A running job is allocated a single partition (ptlow); once requeued back to |
| pending it must surface its full PriorityTier sorted partition list again, |
| with no single allocated partition prepended. That list survives an slurmctld |
| restart unchanged. |
| """ |
| |
| atf.require_version( |
| (26, 11), |
| reason="Issue 50290: requeued job's partition list in PriorityTier order", |
| ) |
| |
| _, job_id = _submit_job_running_in_ptlow("--requeue") |
| |
| # Requeue and hold so the job stays pending deterministically while node3 |
| # is still free. |
| atf.run_command( |
| f"scontrol requeuehold {job_id}", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state( |
| job_id, "PENDING" |
| ), "Requeued job should return to pending" |
| |
| # A pending multi-partition job surfaces its PriorityTier sorted list, not |
| # the single partition it had been allocated. |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert partition == "pthigh,ptmid,ptlow", ( |
| f"Requeued pending job should list all partitions in PriorityTier " |
| f"order, got {partition}" |
| ) |
| |
| atf.restart_slurmctld() |
| assert atf.wait_for_job_state( |
| job_id, "PENDING" |
| ), "Requeued job should still be pending after restart" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert partition == "pthigh,ptmid,ptlow", ( |
| f"Requeued pending job changed its partition list across restart to " |
| f"{partition}, expected pthigh,ptmid,ptlow" |
| ) |
| |
| |
| def test_delete_secondary_partition_keeps_pending_job_partition(): |
| """Deleting a secondary partition keeps a pending job's primary partition. |
| |
| A pending job's part_ptr is the sorted list head (pthigh), now set by |
| rebuild_job_part_list() rather than an explicit peek in the delete handler. |
| Deleting the middle secondary (ptmid) goes through |
| _foreach_kill_job_by_part_name(), which rebuilds the list and must keep |
| part_ptr on the surviving head and the string in PriorityTier order |
| (pthigh,ptmid,ptlow -> pthigh,ptlow). Deleting the primary (pthigh) is |
| refused, since a pending job's part_ptr marks it in use. |
| """ |
| |
| # Fill every node so the multi-partition job cannot start and stays pending. |
| # The blockers run in ptlow (which spans all nodes) so none of them holds |
| # ptmid or pthigh as its part_ptr, leaving both deletable as far as job use |
| # is concerned. |
| atf.require_version( |
| (26, 11), |
| reason="Issue 50290: pending job's partition list in PriorityTier order", |
| ) |
| |
| for node in ("node1", "node2", "node3"): |
| 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, "PENDING" |
| ), "Multi-partition job should be pending while every node is busy" |
| |
| # The pending job lists all partitions in PriorityTier order; the head |
| # (pthigh) is its primary partition. |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert partition == "pthigh,ptmid,ptlow", ( |
| f"Pending multi-partition job should list its partitions in PriorityTier " |
| f"order, got {partition}" |
| ) |
| |
| # Deleting the primary partition (the pending job's part_ptr) is refused |
| # because a pending job marks it in use. |
| result = atf.run_command( |
| "scontrol delete PartitionName=pthigh", |
| user=atf.properties["slurm-user"], |
| ) |
| assert ( |
| result["exit_code"] != 0 |
| ), "Deleting the primary partition of a pending job should fail" |
| assert ( |
| "in use" in result["stderr"].lower() |
| ), f"Expected a partition-in-use error, got: {result['stderr']}" |
| |
| # Deleting the middle secondary (ptmid) is allowed and must rebuild the |
| # partition list, dropping ptmid and keeping PriorityTier order, with the |
| # primary partition (the head, pthigh) unchanged. |
| atf.run_command( |
| "scontrol delete PartitionName=ptmid", |
| fatal=True, |
| user=atf.properties["slurm-user"], |
| ) |
| assert atf.wait_for_job_state( |
| job_id, "PENDING" |
| ), "Job should stay pending after deleting a secondary partition" |
| partition = atf.get_job_parameter(job_id, "Partition") |
| assert partition == "pthigh,ptlow", ( |
| f"Deleting the middle secondary should drop ptmid and keep PriorityTier " |
| f"order, got {partition}" |
| ) |