1717
1818from typing import List , Optional , Dict , Any
1919import argparse
20+ import random
21+ import time
22+ import subprocess
23+ import datetime
2024from datetime import timedelta
2125import shlex
2226import json
2327import logging
28+ import error_handler
2429import os
2530import yaml
2631import 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
0 commit comments