Skip to content

Commit 075b91c

Browse files
committed
Enhance Slurm-GCP resilience to capacity constraints and quota errors
1 parent 7d63bff commit 075b91c

5 files changed

Lines changed: 133 additions & 19 deletions

File tree

community/modules/scheduler/schedmd-slurm-gcp-v6-controller/modules/slurm_files/README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@ limitations under the License.
1616
## Requirements
1717

1818
| Name | Version |
19+
<<<<<<< HEAD
1920
| ---- | ------- |
2021
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | >= 1.12.2 |
22+
=======
23+
|------|---------|
24+
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | = 1.12.2 |
25+
>>>>>>> be1982f3a (Enhance Slurm-GCP resilience to capacity constraints and quota errors)
2126
| <a name="requirement_archive"></a> [archive](#requirement\_archive) | ~> 2.0 |
2227
| <a name="requirement_google"></a> [google](#requirement\_google) | >= 6.41 |
2328
| <a name="requirement_local"></a> [local](#requirement\_local) | ~> 2.0 |
@@ -26,7 +31,7 @@ limitations under the License.
2631
## Providers
2732

2833
| Name | Version |
29-
| ---- | ------- |
34+
|------|---------|
3035
| <a name="provider_archive"></a> [archive](#provider\_archive) | ~> 2.0 |
3136
| <a name="provider_google"></a> [google](#provider\_google) | >= 6.41 |
3237
| <a name="provider_local"></a> [local](#provider\_local) | ~> 2.0 |
@@ -39,7 +44,7 @@ No modules.
3944
## Resources
4045

4146
| Name | Type |
42-
| ---- | ---- |
47+
|------|------|
4348
| [google_storage_bucket_object.config](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket_object) | resource |
4449
| [google_storage_bucket_object.controller_startup_scripts](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket_object) | resource |
4550
| [google_storage_bucket_object.devel](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket_object) | resource |
@@ -64,9 +69,13 @@ No modules.
6469
## Inputs
6570

6671
| Name | Description | Type | Default | Required |
72+
<<<<<<< HEAD
6773
| ---- | ----------- | ---- | ------- | :------: |
6874
| <a name="input_accounting_storage_backup_host"></a> [accounting\_storage\_backup\_host](#input\_accounting\_storage\_backup\_host) | The backup accounting storage host. | `string` | `null` | no |
6975
| <a name="input_backup_controller_key_timeout"></a> [backup\_controller\_key\_timeout](#input\_backup\_controller\_key\_timeout) | The time in seconds for the backup controller to wait for the shared key to become available. | `number` | `300` | no |
76+
=======
77+
|------|-------------|------|---------|:--------:|
78+
>>>>>>> be1982f3a (Enhance Slurm-GCP resilience to capacity constraints and quota errors)
7079
| <a name="input_bucket_dir"></a> [bucket\_dir](#input\_bucket\_dir) | Bucket directory for cluster files to be put into. | `string` | `null` | no |
7180
| <a name="input_bucket_name"></a> [bucket\_name](#input\_bucket\_name) | Name of GCS bucket to use. | `string` | n/a | yes |
7281
| <a name="input_cgroup_conf_tpl"></a> [cgroup\_conf\_tpl](#input\_cgroup\_conf\_tpl) | Slurm cgroup.conf template file path. | `string` | `null` | no |
@@ -117,7 +126,7 @@ No modules.
117126
## Outputs
118127

119128
| Name | Description |
120-
| ---- | ----------- |
129+
|------|-------------|
121130
| <a name="output_bucket_dir"></a> [bucket\_dir](#output\_bucket\_dir) | Path directory within `bucket_name` for Slurm cluster file storage. |
122131
| <a name="output_bucket_name"></a> [bucket\_name](#output\_bucket\_name) | GCS Bucket name of Slurm cluster file storage. |
123132
| <a name="output_config"></a> [config](#output\_config) | Cluster configuration. |

community/modules/scheduler/schedmd-slurm-gcp-v6-controller/modules/slurm_files/main.tf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ locals {
197197
"resume_wrapper.sh",
198198
"resume.py",
199199
"repair.py",
200+
"error_handler.py",
200201
"setup_network_storage.py",
201202
"setup.py",
202203
"slurmsync.py",
@@ -223,6 +224,7 @@ locals {
223224
"setup.py",
224225
"slurmsync.py",
225226
"sort_nodes.py",
227+
"error_handler.py",
226228
"repair.py",
227229
"suspend.py",
228230
"tpu.py",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright 2026 "Google LLC"
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import enum
16+
17+
class Action(enum.Enum):
18+
REQUEUE = "REQUEUE"
19+
IGNORE = "IGNORE"
20+
21+
def classify_gcp_error(error_code: str, error_message: str) -> tuple[Action, str]:
22+
"""
23+
Classifies a GCP API error into an actionable Slurm response.
24+
Returns: (Action Enum, Normalized Reason String)
25+
"""
26+
msg_lower = error_message.lower()
27+
28+
# 1. Quota Errors -> Transient (active usage limits), require REQUEUE
29+
if "quotaexceeded" in error_code.lower() or "quotaexceeded" in msg_lower or ("quota" in msg_lower and "exceeded" in msg_lower) or error_code == "QUOTA_EXCEEDED":
30+
return Action.REQUEUE, f"GCP Quota Exceeded: {error_message}"
31+
32+
# 2. Capacity Errors -> Transient, require REQUEUE
33+
capacity_codes = ["ZONE_RESOURCE_POOL_EXHAUSTED", "VM_MIN_COUNT_NOT_REACHED", "INSUFFICIENT_RESOURCE_CAPACITY"]
34+
if any(code in error_code for code in capacity_codes) or "sufficient capacity" in msg_lower:
35+
return Action.REQUEUE, f"GCP Capacity Exhausted: {error_message}"
36+
37+
# 3. Default fallback
38+
return Action.IGNORE, f"GCP Error [{error_code}]: {error_message}"

community/modules/scheduler/schedmd-slurm-gcp-v6-controller/modules/slurm_files/scripts/resume.py

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@
1717

1818
from typing import List, Optional, Dict, Any
1919
import argparse
20+
import random
21+
import time
22+
import subprocess
23+
import datetime
2024
from datetime import timedelta
2125
import shlex
2226
import json
2327
import logging
28+
import error_handler
2429
import os
2530
import yaml
2631
import collections
@@ -159,7 +164,7 @@ def dws_flex_duration(dws_flex: NSDict, job_id: Optional[int]) -> int:
159164
log.info("Job TimeLimit cannot be less than 30 seconds or exceed one week")
160165
return max_duration
161166

162-
def create_instances_request(nodes: List[str], placement_group: Optional[str], excl_job_id: Optional[int]):
167+
def create_instances_request(nodes: List[str], placement_group: Optional[str], excl_job_id: Optional[int], is_job_request: bool):
163168
"""Call regionInstances.bulkInsert to create instances"""
164169
assert 0 < len(nodes) <= BULK_INSERT_LIMIT
165170

@@ -181,9 +186,14 @@ def create_instances_request(nodes: List[str], placement_group: Optional[str], e
181186
),
182187
)
183188

184-
if placement_group and excl_job_id is not None:
185-
pass # do not set minCount to force "all or nothing" behavior
189+
if is_job_request:
190+
# By omitting minCount, GCP will default minCount to equal count.
191+
# This guarantees ATOMIC PROVISIONING. If GCP cannot provide all N nodes, it fails instantly
192+
# rather than partially provisioning M nodes which waste budget while waiting for the rest.
193+
pass
186194
else:
195+
# Static pools and non-job allocations don't require all nodes to boot simultaneously.
196+
# minCount=1 allows partial fulfillment so available static nodes can still service smaller jobs.
187197
body["minCount"] = 1
188198

189199
zone_allow = nodeset.zone_policy_allow or []
@@ -221,6 +231,7 @@ class BulkChunk:
221231
chunk_idx: int
222232
excl_job_id: Optional[int]
223233
placement_group: Optional[str] = None
234+
is_job_request: bool = False
224235

225236
@property
226237
def name(self):
@@ -240,21 +251,25 @@ def group_nodes_bulk(nodes: List[str], resume_data: Optional[ResumeData], lkp: u
240251
non_excl = nodes_set.copy()
241252
groups : Dict[Optional[int], List[PlacementAndNodes]] = {} # excl_job_id|none -> PlacementAndNodes
242253

243-
# expand all exclusive job nodelists
254+
# expand all job nodelists (both exclusive and non-exclusive)
244255
for job in resume_data.jobs:
245-
if not lkp.cfg.partitions[job.partition].enable_job_exclusive:
256+
# Intersect the job's allocated nodes with the nodes currently being resumed.
257+
# This fixes a bug where partially provisioned nodes in previous resume calls were improperly included
258+
# in the new bulkInsert, causing duplicate provisioning and blocking minCount atomic requests.
259+
job_nodes_in_resume = set(job.nodes_alloc) & non_excl
260+
if not job_nodes_in_resume:
246261
continue
247262

248263
groups[job.job_id] = []
249-
# placement group assignment is based on all allocated nodes, ...
264+
# placement group assignment requires full job.nodes_alloc to be accurate
250265
for pn in create_placements(job.nodes_alloc, job.job_id, lkp):
251266
groups[job.job_id].append(
252267
PlacementAndNodes(
253268
placement=pn.placement,
254-
#... but we only want to handle nodes in nodes_resume in this run.
255-
nodes = sorted(set(pn.nodes) & nodes_set)
269+
# safely intersect with job_nodes_in_resume to prevent duplicate overlapping node requests
270+
nodes = sorted(set(pn.nodes) & job_nodes_in_resume)
256271
))
257-
non_excl.difference_update(job.nodes_alloc)
272+
non_excl.difference_update(job_nodes_in_resume)
258273

259274
groups[None] = create_placements(sorted(non_excl), excl_job_id=None, lkp=lkp)
260275

@@ -280,7 +295,8 @@ def chunk_nodes(nodes: List[str]):
280295
prefix=lkp.node_prefix(nodes_chunk[0]), # <cluster_name>-<nodeset_name>
281296
excl_job_id = job_id,
282297
placement_group=pn.placement,
283-
chunk_idx=i)
298+
chunk_idx=i,
299+
is_job_request=(job_id is not None))
284300

285301
for job_id, placements in groups.items()
286302
for pn in placements if pn.nodes
@@ -331,7 +347,7 @@ def resume_nodes(nodes: List[str], resume_data: Optional[ResumeData]):
331347
flex_chunks.append(chunk)
332348
else:
333349
bi_inserts[group] = create_instances_request(
334-
chunk.nodes, chunk.placement_group, chunk.excl_job_id
350+
chunk.nodes, chunk.placement_group, chunk.excl_job_id, chunk.is_job_request
335351
)
336352

337353
for chunk in flex_chunks:
@@ -353,7 +369,9 @@ def resume_nodes(nodes: List[str], resume_data: Optional[ResumeData]):
353369
failed_reqs = [str(e) for e in failed.items()]
354370
log.error("bulkInsert API failures: {}".format("; ".join(failed_reqs)))
355371
for ident, exc in failed.items():
356-
down_nodes_notify_jobs(grouped_nodes[ident].nodes, f"GCP Error: {exc._get_reason()}", resume_data) # type: ignore
372+
reason = exc._get_reason() if hasattr(exc, "_get_reason") else str(exc) # type: ignore
373+
action, admin_comment = error_handler.classify_gcp_error(reason, reason)
374+
handle_resume_failure(grouped_nodes[ident].nodes, f"GCP Error: {reason}", resume_data, action, admin_comment)
357375

358376
if log.isEnabledFor(logging.DEBUG):
359377
for group, op in started.items():
@@ -449,7 +467,8 @@ def _handle_bulk_insert_op(op: Dict, nodes: List[str], resume_data: Optional[Res
449467
for err in failed_ops[0]["error"]["errors"]
450468
)
451469
if code != "RESOURCE_ALREADY_EXISTS":
452-
down_nodes_notify_jobs(failed_nodes, f"GCP Error: {msg}", resume_data)
470+
action, admin_comment = error_handler.classify_gcp_error(code, msg)
471+
handle_resume_failure(failed_nodes, f"GCP Error: {msg}", resume_data, action, admin_comment)
453472
log.error(
454473
f"errors from insert for node '{failed_nodes[0]}' ({failed_ops[0]['name']}): {msg}"
455474
)
@@ -470,6 +489,52 @@ def down_nodes_notify_jobs(nodes: List[str], reason: str, resume_data: Optional[
470489
nodelist = util.to_hostlist(nodes)
471490
log.error(f"Marking nodes {nodelist} as DOWN, reason: {reason}")
472491
run(f"{lookup().scontrol} update nodename={nodelist} state=down reason={reason_quoted}", check=False)
492+
493+
494+
def handle_resume_failure(nodes: List[str], reason: str, resume_data: Optional[ResumeData], action: error_handler.Action, admin_comment: str) -> None:
495+
"""Handle resume failures via node and job management based on error action."""
496+
nodes_set = set(nodes)
497+
jobs = resume_data.jobs if resume_data else []
498+
499+
admin_reason_quoted = shlex.quote(admin_comment)
500+
fallback_reason_quoted = shlex.quote(reason)
501+
502+
nodelist = util.to_hostlist(nodes)
503+
if action == error_handler.Action.REQUEUE:
504+
log.error(f"Resetting nodes {nodelist} to power_down. Reason: {reason}")
505+
# 1. Force DOWN to instantly strip the POWERING_UP (#) flag and bypass ResumeTimeout
506+
run(f"{lookup().scontrol} update nodename={nodelist} state=down reason='Force clear booting state'", check=False)
507+
# 2. Return to power_down so Slurm transitions it safely to idle~
508+
run(f"{lookup().scontrol} update nodename={nodelist} state=power_down", check=False)
509+
else:
510+
log.error(f"Marking nodes {nodelist} as DOWN. Reason: {reason}")
511+
run(f"{lookup().scontrol} update nodename={nodelist} state=down reason={fallback_reason_quoted}", check=False)
512+
513+
for job in jobs:
514+
if not (set(job.nodes_alloc) & nodes_set):
515+
continue
516+
517+
run(f"{lookup().scontrol} update jobid={job.job_id} admincomment={admin_reason_quoted}", check=False)
518+
519+
if action == error_handler.Action.REQUEUE:
520+
# To prevent the requeued job from immediately retrying and hammering
521+
# the GCP API during a stockout, we calculate a randomized backoff (e.g. 3-5 mins) and
522+
# explicitly delay the job's next evaluation via the StartTime parameter.
523+
delay_seconds = random.randint(180, 300)
524+
backoff_time = (datetime.datetime.now() + datetime.timedelta(seconds=delay_seconds)).strftime("%Y-%m-%dT%H:%M:%S")
525+
log.info(f"Setting job {job.job_id} backoff: {delay_seconds}s (until {backoff_time})")
526+
527+
# Wait up to 10 seconds for Slurm to natively requeue the job to PENDING
528+
for _ in range(10):
529+
try:
530+
res = subprocess.run([lookup().scontrol, "show", "job", str(job.job_id)], capture_output=True, text=True)
531+
if "JobState=PENDING" in res.stdout:
532+
break
533+
except Exception:
534+
pass
535+
time.sleep(1)
536+
537+
run(f"{lookup().scontrol} update JobId={job.job_id} StartTime={backoff_time}", check=False)
473538

474539

475540

community/modules/scheduler/schedmd-slurm-gcp-v6-controller/modules/slurm_files/scripts/tests/test_resume.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,13 @@ def mock_tpu_se(ns: str, lkp) -> TstTPU:
124124
"c-n:jobNone:g0:0": BulkChunk(
125125
nodes=["c-n-1", "c-n-2"], prefix="c-n", chunk_idx=0, excl_job_id=None, placement_group="g0"),
126126
"c-n:job1:g10:0": BulkChunk(
127-
nodes=["c-n-0"], prefix="c-n", chunk_idx=0, excl_job_id=1, placement_group="g10"),
127+
nodes=["c-n-0"], prefix="c-n", chunk_idx=0, excl_job_id=1, placement_group="g10", is_job_request=True),
128128
"c-t:0": BulkChunk(
129129
nodes=["c-t-8", "c-t-9"], prefix="c-t", chunk_idx=0, excl_job_id=None, placement_group=None),
130130
"c-t:job2:0": BulkChunk(
131-
nodes=["c-t-0", "c-t-1"], prefix="c-t", chunk_idx=0, excl_job_id=2, placement_group=None),
131+
nodes=["c-t-0", "c-t-1"], prefix="c-t", chunk_idx=0, excl_job_id=2, placement_group=None, is_job_request=True),
132132
"c-t:job2:1": BulkChunk(
133-
nodes=["c-t-2", "c-t-3"], prefix="c-t", chunk_idx=1, excl_job_id=2, placement_group=None),
133+
nodes=["c-t-2", "c-t-3"], prefix="c-t", chunk_idx=1, excl_job_id=2, placement_group=None, is_job_request=True),
134134
}
135135

136136

0 commit comments

Comments
 (0)