@@ -854,6 +854,12 @@ def __init__(self, config_manager: ConfigManager, cloud_client: Optional[OpenSta
854854 # Host processing tracking
855855 self .hosts_processing = defaultdict (float )
856856 self .processing_lock = threading .Lock ()
857+ try :
858+ workers = self .config .get_config_value ('WORKERS' )
859+ except (TypeError , ValueError , AttributeError ):
860+ workers = 4
861+ self .processing_executor = concurrent .futures .ThreadPoolExecutor (
862+ max_workers = workers if isinstance (workers , int ) and workers > 0 else 4 )
857863 self .reserved_hosts_lock = threading .Lock ()
858864
859865 logging .debug ("MAX_HOSTS_PER_CYCLE=%d" , self .config .get_config_value ('MAX_HOSTS_PER_CYCLE' ))
@@ -1326,6 +1332,18 @@ def do_GET(self):
13261332 f"Main loop stale ({ staleness :.0f} s since last poll)" .encode ())
13271333 return
13281334
1335+ if service_instance .hosts_processing :
1336+ now = time .monotonic ()
1337+ oldest_entry = min (service_instance .hosts_processing .values ())
1338+ max_processing = max (
1339+ service_instance .config .get_config_value ('FENCING_TIMEOUT' ),
1340+ service_instance .config .get_config_value ('EVACUATION_TIMEOUT' )) * 2
1341+ if now - oldest_entry > max_processing :
1342+ self ._respond (500 , "text/plain" ,
1343+ f"Processing stale ({ len (service_instance .hosts_processing )} hosts, "
1344+ f"oldest { now - oldest_entry :.0f} s)" .encode ())
1345+ return
1346+
13291347 self ._respond (200 , "text/plain" , service_instance .current_hash )
13301348
13311349 try :
@@ -3145,6 +3163,10 @@ def _filter_processing_hosts(service, compute_nodes, to_resume):
31453163 current_time = time .monotonic ()
31463164 stagger = service .config .get_config_value ('EVACUATION_STAGGER' )
31473165 max_threads = service .config .get_config_value ('EVACUATION_MAX_THREADS' )
3166+ # Use max_threads (not actual VM count) as the stagger multiplier: we don't
3167+ # know the per-host VM count at expiry-check time, so this is a deliberate
3168+ # safe upper bound that prevents premature expiry at the cost of holding the
3169+ # "in processing" slot longer than strictly necessary.
31483170 max_processing_time = max (service .config .get_config_value ('FENCING_TIMEOUT' ),
31493171 service .config .get_config_value ('EVACUATION_TIMEOUT' )
31503172 + (max_threads - 1 ) * stagger )
@@ -3253,11 +3275,13 @@ def _filter_reachable_hosts(service, compute_nodes):
32533275
32543276 timestamps_snapshot = dict (service .heartbeat_hosts_timestamp )
32553277
3278+ processing_hostnames = set (service .hosts_processing .keys ())
32563279 current_active = sum (
3257- 1 for ts in timestamps_snapshot .values ()
3258- if ts > 0 and (current_time - ts ) <= heartbeat_timeout
3280+ 1 for host , ts in timestamps_snapshot .items ()
3281+ if host not in processing_hostnames
3282+ and ts > 0 and (current_time - ts ) <= heartbeat_timeout
32593283 )
3260- previous_active = service .heartbeat_previous_active_count
3284+ previous_active = max ( 1 , service .heartbeat_previous_active_count - len ( processing_hostnames ))
32613285
32623286 cliff_detected = False
32633287 if previous_active >= 3 :
@@ -3311,15 +3335,16 @@ def _filter_reachable_hosts(service, compute_nodes):
33113335 return unreachable , skipped , False
33123336
33133337
3314- def _process_stale_services (conn , service , services , compute_nodes , to_resume ):
3315- """Process stale compute services for evacuation ."""
3338+ def _admit_stale_services (conn , service , services , compute_nodes , to_resume ):
3339+ """Evaluate safety gates and submit accepted hosts for background processing ."""
33163340 # Convert generators to lists - needed for multiple iterations and length checks
33173341 compute_nodes = list (compute_nodes )
33183342 to_resume = list (to_resume )
33193343
33203344 if not (compute_nodes or to_resume ):
33213345 return
33223346
3347+
33233348 # Filter out hosts already being processed
33243349 compute_nodes , to_resume , marked_hostnames , current_time = _filter_processing_hosts (service , compute_nodes , to_resume )
33253350
@@ -3386,14 +3411,18 @@ def _process_stale_services(conn, service, services, compute_nodes, to_resume):
33863411 compute_nodes , reserved_hosts , images , flavors = _prepare_evacuation_resources (
33873412 conn , service , services , compute_nodes , aggregates = aggregates )
33883413
3389- # Check evacuation threshold
3414+ # Check evacuation threshold: total cluster health (all down/forced_down
3415+ # hosts vs total hosts). Catches slow cascades where hosts fail one at a
3416+ # time -- previously-evacuated hosts that haven't recovered count as impacted.
33903417 if services and compute_nodes :
3391- # When TAGGED_AGGREGATES is enabled, calculate threshold against evacuable hosts only
33923418 if service .config .get_config_value ('TAGGED_AGGREGATES' ):
3419+ evacuable_hosts = _get_evacuable_hosts_from_aggregates (conn , service , aggregates = aggregates )
33933420 total_evacuable = _count_evacuable_hosts (conn , service , services , aggregates = aggregates )
3394- threshold_percent = (len (compute_nodes ) / total_evacuable * 100 ) if total_evacuable > 0 else 0
3421+ total_down = _count_down_hosts (services , compute_nodes = compute_nodes , evacuable_hosts = evacuable_hosts )
3422+ threshold_percent = (total_down / total_evacuable * 100 ) if total_evacuable > 0 else 0
33953423 else :
3396- threshold_percent = (len (compute_nodes ) / len (active_services )) * 100 if active_services else 0
3424+ total_down = _count_down_hosts (services , compute_nodes = compute_nodes )
3425+ threshold_percent = (total_down / len (services )) * 100 if services else 0
33973426
33983427 threshold = service .config .get_config_value ('THRESHOLD' )
33993428 if threshold_percent > threshold :
@@ -3446,27 +3475,26 @@ def _process_stale_services(conn, service, services, compute_nodes, to_resume):
34463475 to_evacuate = compute_nodes
34473476 kdump_fenced = []
34483477
3449- workers = service .config .get_config_value ('WORKERS' )
3450- poll_interval = service .config .get_config_value ('POLL' )
3451- stagger = service .config .get_config_value ('EVACUATION_STAGGER' )
3452- max_threads = service .config .get_config_value ('EVACUATION_MAX_THREADS' )
3453- outer_timeout = (service .config .get_config_value ('FENCING_TIMEOUT' )
3454- + service .config .get_config_value ('EVACUATION_TIMEOUT' )
3455- + (max_threads - 1 ) * stagger + 60 )
34563478 for batch_name , batch , resume in [
34573479 ('new evacuations' , to_evacuate , False ),
34583480 ('kdump-fenced' , kdump_fenced , True ),
34593481 ('resumed' , to_resume , True ),
34603482 ]:
34613483 if not batch :
34623484 continue
3463- _run_concurrent (
3464- lambda svc , _resume = resume : process_service (svc , reserved_hosts , _resume , service ),
3465- batch ,
3466- workers ,
3467- lambda svc : f"{ svc .host } ({ batch_name } )" ,
3468- timeout = outer_timeout ,
3469- )
3485+ logging .info ("Submitting %d host(s) for %s" , len (batch ), batch_name )
3486+ for svc in batch :
3487+ future = service .processing_executor .submit (
3488+ process_service , svc , reserved_hosts , resume , service )
3489+ _host = svc .host
3490+ _batch = batch_name
3491+ future .add_done_callback (
3492+ lambda f , h = _host , b = _batch : logging .warning (
3493+ "%s (%s) failed" , h , b
3494+ ) if f .exception () or not f .result () else logging .info (
3495+ "%s (%s) succeeded" , h , b
3496+ )
3497+ )
34703498
34713499 final_hostnames = {_extract_hostname (svc .host ) for svc in to_evacuate + kdump_fenced + to_resume }
34723500 else :
@@ -3490,12 +3518,28 @@ def _get_evacuable_hosts_from_aggregates(conn, service, aggregates=None):
34903518
34913519
34923520def _count_evacuable_hosts (conn , service , services , aggregates = None ):
3493- """Count total number of compute services in evacuable aggregates."""
3521+ """Count total number of compute services in evacuable aggregates (all states) ."""
34943522 evacuable_hosts = _get_evacuable_hosts_from_aggregates (conn , service , aggregates )
3495- active_services = [s for s in services if 'disabled' not in s .status and not getattr (s , 'forced_down' , False )]
34963523 if evacuable_hosts is None :
3497- return len (active_services )
3498- return sum (1 for svc in active_services if svc .host in evacuable_hosts )
3524+ return len (services )
3525+ return sum (1 for svc in services if svc .host in evacuable_hosts )
3526+
3527+
3528+ def _count_down_hosts (services , compute_nodes = None , evacuable_hosts = None ):
3529+ """Count compute services that are down, forced_down, or detected as stale.
3530+
3531+ Includes compute_nodes (hosts detected as stale by instanceha this cycle)
3532+ because Nova's state field may not have transitioned to 'down' yet --
3533+ instanceha detects staleness via timestamp before Nova does.
3534+ """
3535+ candidates = services
3536+ if evacuable_hosts is not None :
3537+ candidates = [s for s in services if s .host in evacuable_hosts ]
3538+ down_hosts = {s .host for s in candidates
3539+ if s .state == 'down' or getattr (s , 'forced_down' , False )}
3540+ if compute_nodes :
3541+ down_hosts .update (s .host for s in compute_nodes )
3542+ return len (down_hosts )
34993543
35003544
35013545def _filter_by_aggregates (conn , service , compute_nodes , services , aggregates = None ):
@@ -3515,6 +3559,7 @@ def _filter_by_aggregates(conn, service, compute_nodes, services, aggregates=Non
35153559def _filter_by_aggregate_threshold (compute_nodes , aggregates , service ):
35163560 """Filter out compute nodes whose aggregates exceeded their per-aggregate failure threshold."""
35173561 failed_hosts = {svc .host for svc in compute_nodes }
3562+ processing_hostnames = set (service .hosts_processing .keys ())
35183563 blocked_hosts = set ()
35193564
35203565 for agg in aggregates :
@@ -3537,16 +3582,21 @@ def _filter_by_aggregate_threshold(compute_nodes, aggregates, service):
35373582 agg .name , AGGREGATE_MAX_FAILURES_KEY , max_failures_str )
35383583 continue
35393584
3585+ agg_host_shortnames = {_extract_hostname (h ) for h in agg .hosts }
35403586 agg_failed = failed_hosts .intersection (agg .hosts )
3541- if len (agg_failed ) > max_failures :
3587+ agg_processing = len (processing_hostnames .intersection (agg_host_shortnames ))
3588+ total_agg_impacted = len (agg_failed ) + agg_processing
3589+ if total_agg_impacted > max_failures :
35423590 logging .error (
3543- "Aggregate '%s' has %d failed hosts (limit: %d). "
3591+ "Aggregate '%s' has %d impacted hosts (%d new + %d in-flight, limit: %d). "
35443592 "Blocking evacuation for hosts in this aggregate: %s" ,
3545- agg .name , len (agg_failed ), max_failures , sorted (agg_failed ))
3593+ agg .name , total_agg_impacted , len (agg_failed ), agg_processing ,
3594+ max_failures , sorted (agg_failed ))
35463595 _emit_k8s_event (
35473596 agg .name , 'AggregateThresholdExceeded' ,
3548- f'Aggregate has { len (agg_failed )} failed hosts '
3549- f'(limit: { max_failures } ), evacuation blocked' ,
3597+ f'Aggregate has { total_agg_impacted } impacted hosts '
3598+ f'({ len (agg_failed )} new + { agg_processing } in-flight, '
3599+ f'limit: { max_failures } ), evacuation blocked' ,
35503600 event_type = 'Warning' )
35513601 AGGREGATE_THRESHOLD_EXCEEDED_TOTAL .labels (aggregate = agg .name ).inc ()
35523602 blocked_hosts .update (agg_failed )
@@ -3718,6 +3768,7 @@ def _sigterm_handler(signum, frame):
37183768 service .shutdown_event .set ()
37193769 service .kdump_listener_stop_event .set ()
37203770 service .heartbeat_listener_stop_event .set ()
3771+ service .processing_executor .shutdown (wait = False )
37213772 if service ._http_server :
37223773 threading .Thread (target = service ._http_server .shutdown , daemon = True ).start ()
37233774
@@ -3747,7 +3798,7 @@ def _sigterm_handler(signum, frame):
37473798
37483799 compute_nodes_list = list (compute_nodes )
37493800
3750- _process_stale_services (conn , service , services , compute_nodes_list , to_resume )
3801+ _admit_stale_services (conn , service , services , compute_nodes_list , to_resume )
37513802 _process_reenabling (conn , service , to_reenable )
37523803
37533804 stale_count = len (compute_nodes_list )
@@ -3783,6 +3834,8 @@ def _sigterm_handler(signum, frame):
37833834
37843835 service .shutdown_event .wait (poll_interval )
37853836
3837+ logging .info ("Waiting for in-flight evacuations to complete" )
3838+ service .processing_executor .shutdown (wait = True )
37863839 logging .info ("Graceful shutdown complete" )
37873840
37883841
0 commit comments