Skip to content

Commit 5dd0296

Browse files
Merge pull request #621 from lmiccini/instanceha-async-detection
Decouple detection from evacuation and evaluate safety gates cumulatively across poll cycles
2 parents a3202d3 + 7d62a1d commit 5dd0296

12 files changed

Lines changed: 341 additions & 153 deletions

docs/instanceha_architecture.md

Lines changed: 61 additions & 34 deletions
Large diffs are not rendered by default.

docs/instanceha_guide.md

Lines changed: 33 additions & 21 deletions
Large diffs are not rendered by default.

templates/instanceha/bin/instanceha.py

Lines changed: 86 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -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

34923520
def _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

35013545
def _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
35153559
def _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

test/instanceha/functional_test.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2418,10 +2418,10 @@ def test_fencing_race_condition_prevention(self):
24182418
# Capture the filtered hosts that would be processed
24192419
filtered_hosts_before = [svc.host for svc in compute_nodes]
24202420

2421-
# Call _process_stale_services (this should filter out the host being processed)
2421+
# Call _admit_stale_services (this should filter out the host being processed)
24222422
with patch('instanceha._establish_nova_connection', return_value=mock_conn), \
24232423
patch('instanceha.process_service', return_value=True) as mock_process:
2424-
instanceha._process_stale_services(mock_conn, self.env.service,
2424+
instanceha._admit_stale_services(mock_conn, self.env.service,
24252425
self.env.mock_nova.services.list(),
24262426
compute_nodes, to_resume)
24272427

@@ -2484,10 +2484,10 @@ def test_fencing_race_condition_expired_cleanup(self):
24842484
from unittest.mock import Mock, patch
24852485
mock_conn = Mock()
24862486

2487-
# Call _process_stale_services which should trigger cleanup
2487+
# Call _admit_stale_services which should trigger cleanup
24882488
with patch('instanceha._establish_nova_connection', return_value=mock_conn), \
24892489
patch('instanceha.process_service', return_value=True):
2490-
instanceha._process_stale_services(mock_conn, self.env.service,
2490+
instanceha._admit_stale_services(mock_conn, self.env.service,
24912491
self.env.mock_nova.services.list(),
24922492
stale_services, [])
24932493

@@ -2522,10 +2522,10 @@ def test_fencing_race_condition_multiple_hosts(self):
25222522
mock_conn = Mock()
25232523
mock_conn.services.list.return_value = []
25242524

2525-
# Call _process_stale_services
2525+
# Call _admit_stale_services
25262526
with patch('instanceha._establish_nova_connection', return_value=mock_conn), \
25272527
patch('instanceha.process_service', return_value=True) as mock_process:
2528-
instanceha._process_stale_services(mock_conn, self.env.service,
2528+
instanceha._admit_stale_services(mock_conn, self.env.service,
25292529
self.env.mock_nova.services.list(),
25302530
stale_services, [])
25312531

test/instanceha/integration_test.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ def test_complete_evacuation_workflow(self):
448448
with patch('instanceha.process_service') as mock_process_service:
449449
mock_process_service.return_value = True
450450

451-
instanceha._process_stale_services(
451+
instanceha._admit_stale_services(
452452
nova_client, service, self.mock_env.services, compute_nodes, to_resume
453453
)
454454

@@ -475,7 +475,7 @@ def test_evacuation_with_threshold_protection(self):
475475
)
476476

477477
with patch('instanceha.process_service') as mock_process_service:
478-
instanceha._process_stale_services(
478+
instanceha._admit_stale_services(
479479
nova_client, service, self.mock_env.services, compute_nodes, to_resume
480480
)
481481

@@ -807,7 +807,7 @@ def mock_process_service(failed_service, reserved_hosts, resume, service_instanc
807807

808808
# Test concurrent processing
809809
start_time = time.time()
810-
instanceha._process_stale_services(
810+
instanceha._admit_stale_services(
811811
nova_client, service, self.mock_env.services, compute_nodes, to_resume
812812
)
813813
processing_time = time.time() - start_time
@@ -913,8 +913,8 @@ def test_count_evacuable_hosts_with_incomplete_services(self):
913913
count = instanceha._count_evacuable_hosts(nova_client, service, self.mock_env.services)
914914
self.assertEqual(count, 3)
915915

916-
def test_process_stale_services_with_incomplete_services(self):
917-
"""_process_stale_services should not crash when incomplete services are in the list."""
916+
def test_admit_stale_services_with_incomplete_services(self):
917+
"""_admit_stale_services should not crash when incomplete services are in the list."""
918918
self.mock_env.add_incomplete_compute_service('compute-unknown')
919919
self.mock_env.add_compute_service('compute-ok', state='up', status='enabled')
920920

@@ -927,7 +927,7 @@ def test_process_stale_services_with_incomplete_services(self):
927927
)
928928

929929
with patch('instanceha.process_service') as mock_process:
930-
instanceha._process_stale_services(
930+
instanceha._admit_stale_services(
931931
nova_client, service, self.mock_env.services,
932932
compute_nodes, to_resume
933933
)
@@ -978,7 +978,7 @@ def mock_process_service(failed_service, reserved_hosts, resume, service_instanc
978978
self.mock_env.services, target_date
979979
)
980980

981-
instanceha._process_stale_services(
981+
instanceha._admit_stale_services(
982982
nova_client, service, self.mock_env.services, compute_nodes, to_resume
983983
)
984984

test/instanceha/test_aggregate_threshold.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def _make_service_obj(host):
2929
def _make_instanceha_service(evacuable_tag='evacuable'):
3030
service = Mock()
3131
service.evacuable_tag = evacuable_tag
32+
service.hosts_processing = {}
3233
service._is_resource_evacuable = instanceha.InstanceHAService._is_resource_evacuable.__get__(service)
3334
service._check_evacuable_tag = instanceha.InstanceHAService._check_evacuable_tag.__get__(service)
3435
return service

0 commit comments

Comments
 (0)