Skip to content

Commit 8b6f025

Browse files
[serve] Push-based replica health: self-checks ride replica metric reports
P1 of the push-health stack. Replicas run their own health check on a periodic task (twice per health-check period) and push the result: on the metric reports they already send when those are frequent enough to keep the controller's view fresh, or on a lightweight heartbeat otherwise. The controller keeps a push registry; pull probes become the fallback for stale pushes, guarded by a per-deployment systemic-stall verdict (mass staleness reads as controller ingest lag and defers probes, capped per episode) so an overloaded controller cannot trigger a probe storm. Review fixes folded in: - Registry pruning is rate-limited to once per 30s; the over-threshold prune used to re-run an O(N) dict rebuild on every record() once all entries were fresh. The stall window defaults to the stock health-check period so deployments without target info (recovery, deletion) cannot misread every push as stale. - An unhealthy self-check is never suppressed: suppression assumed a recent report carried current health, but a flip to unhealthy after the last carry would be delayed a full period. - The push-stall tally accumulates across ticks rather than per dirty-set slice, which only engaged the guard above ~3200 replicas per deployment. Extract _reconcile_sweep_ticks (shared with the dirty set). Known follow-up: many small deployments can each stay under the per-deployment floor while the controller lags overall. - Gap stats live in two windows instead of accumulating over the controller's lifetime, where the reported p50/p99/max went insensitive to current lag exactly when the numbers are worth reading; count stays cumulative. - A timed-out self-check logs its cause, and marks itself unhealthy on cancellation: wait_for enforces the timeout by cancelling the check, and CancelledError is not an Exception, so the cached result stayed healthy and a fallback pull probe would answer healthy for a wedged check. - A probe still in flight when a newer push is applied no longer overwrites it on resolving: its result predates the push, so it is dropped instead of flapping the failure count across pull-to-push transitions. ACTOR_CRASHED is exempt -- a crash is authoritative and a dead replica pushes nothing. - Probe deferral no longer borrows _last_push_consume_time. That field now means strictly "a push was applied"; deferral carries its own deadline, supplied by the deployment as the stall episode's cap. Before, the gate honoured the borrowed stamp for a further freshness window past the cap (+1.5x the period, so 570s against a documented 120s at a 300s period), and the drop guard above read a deferral as a superseding observation and destroyed every probe result that resolved during an episode -- exactly when a probe is the only signal. - The stall tally counts each replica once per window. The dirty set re-visits a replica with an in-flight probe every tick, and those are precisely the stale-push ones, so the undeduped tally could read a small failed cohort as fleet-wide ingest lag and defer probes for everyone. The MIN_TRACKED floor now means 64 distinct replicas, matching what it always claimed to mean. - A replica with no registry entry is no longer deferred: probes are its only health signal and it cannot sway the verdict either way. - health_check_failures_counter follows the observation actually acted on. Only the probe paths set the failure flag, so the counter had gone silent for push-detected failures while still counting probe results the controller discarded -- inverted precisely when push health is doing its job. - Heartbeat suppression compares the metric-report interval against the configured health-check period, not the half-period evaluation cadence -- under the stock config (10s period, 10s metric interval) the latter suppressed nothing, so every reporting replica heartbeated anyway. Signed-off-by: john.taylor <john.taylor@anyscale.com>
1 parent e1eaf64 commit 8b6f025

10 files changed

Lines changed: 1224 additions & 12 deletions

python/ray/serve/_private/common.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,11 @@ class ReplicaMetricReport:
10611061
aggregated_metrics: Dict[str, float]
10621062
metrics: Dict[str, TimeSeries]
10631063
timestamp: float
1064+
# Replica-pushed self-health (None = sender does not push health; the
1065+
# controller then falls back to pull probes).
1066+
healthy: Optional[bool] = None
1067+
health_checked_at: Optional[float] = None
1068+
health_consecutive_failures: Optional[int] = None
10641069

10651070

10661071
@dataclass

python/ray/serve/_private/controller.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from ray.serve._private.deployment_info import DeploymentInfo
6262
from ray.serve._private.deployment_state import (
6363
DeploymentStateManager,
64+
ReplicaHealthPushRegistry,
6465
)
6566
from ray.serve._private.endpoint_state import EndpointState
6667
from ray.serve._private.exceptions import ExternalScalerDisabledError
@@ -254,13 +255,15 @@ async def __init__(
254255
]
255256

256257
self.autoscaling_state_manager = AutoscalingStateManager()
258+
self._replica_health_push_registry = ReplicaHealthPushRegistry()
257259
self.deployment_state_manager = DeploymentStateManager(
258260
self.kv_store,
259261
self.long_poll_host,
260262
all_serve_actor_names,
261263
get_all_live_placement_group_names(),
262264
self.cluster_node_info_cache,
263265
self.autoscaling_state_manager,
266+
health_push_registry=self._replica_health_push_registry,
264267
)
265268

266269
# Manage all applications' state
@@ -391,10 +394,30 @@ def record_autoscaling_metrics_from_replica(
391394
)
392395
# Track in health metrics
393396
self._health_metrics_tracker.record_replica_metrics_delay(latency_ms)
397+
if replica_metric_report.healthy is not None:
398+
self._replica_health_push_registry.record(
399+
replica_metric_report.replica_id.unique_id,
400+
replica_metric_report.health_checked_at
401+
or replica_metric_report.timestamp,
402+
replica_metric_report.healthy,
403+
replica_metric_report.health_consecutive_failures,
404+
)
394405
self.autoscaling_state_manager.record_request_metrics_for_replica(
395406
replica_metric_report
396407
)
397408

409+
def record_replica_health(
410+
self,
411+
replica_unique_id: str,
412+
checked_at: float,
413+
healthy: bool,
414+
consecutive_failures: Optional[int] = None,
415+
):
416+
"""Self-health heartbeat from replicas that do not push metric reports."""
417+
self._replica_health_push_registry.record(
418+
replica_unique_id, checked_at, healthy, consecutive_failures
419+
)
420+
398421
def record_autoscaling_metrics_from_handle(
399422
self, handle_metric_report: Union[HandleMetricReport, bytes]
400423
):
@@ -619,6 +642,14 @@ async def run_control_loop_step(
619642
dsm_duration = time.time() - dsm_update_start_time
620643
self.dsm_update_duration_gauge_s.set(dsm_duration)
621644
self._health_metrics_tracker.record_dsm_update_duration(dsm_duration)
645+
try:
646+
_gs = self._replica_health_push_registry.gap_stats()
647+
self._health_metrics_tracker.push_checks_recorded = _gs["count"]
648+
self._health_metrics_tracker.push_check_gap_p50_s = _gs["p50_s"]
649+
self._health_metrics_tracker.push_check_gap_p99_s = _gs["p99_s"]
650+
self._health_metrics_tracker.push_check_gap_max_s = _gs["max_s"]
651+
except Exception:
652+
pass
622653
if not self.done_recovering_event.is_set() and not any_recovering:
623654
self.done_recovering_event.set()
624655
if num_loops > 0:

python/ray/serve/_private/controller_health_metrics_tracker.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ class ControllerHealthMetricsTracker:
5050
num_control_loops: int = 0
5151
last_control_loop_time: float = 0.0
5252

53+
# Replica self-check intervals observed via health pushes (set by the
54+
# controller loop from the push registry).
55+
push_checks_recorded: int = 0
56+
push_check_gap_p50_s: float = 0.0
57+
push_check_gap_p99_s: float = 0.0
58+
push_check_gap_max_s: float = 0.0
59+
5360
def record_loop_duration(self, duration: float):
5461
self.loop_durations.append(duration)
5562

@@ -145,4 +152,8 @@ def collect_metrics(self) -> ControllerHealthMetrics:
145152
handle_metrics_delay_ms=handle_delay_stats,
146153
replica_metrics_delay_ms=replica_delay_stats,
147154
process_memory_mb=process_memory_mb,
155+
push_checks_recorded=self.push_checks_recorded,
156+
push_check_gap_p50_s=self.push_check_gap_p50_s,
157+
push_check_gap_p99_s=self.push_check_gap_p99_s,
158+
push_check_gap_max_s=self.push_check_gap_max_s,
148159
)

0 commit comments

Comments
 (0)