Skip to main content

dynamo_runtime/
system_health.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! System health monitoring and health check management
17
18use std::{
19    collections::{HashMap, HashSet},
20    sync::{Arc, OnceLock},
21    time::Instant,
22};
23use tokio::sync::mpsc;
24
25use crate::component;
26use crate::config::HealthStatus;
27use crate::metrics::{MetricsHierarchy, prometheus_names::distributed_runtime};
28
29/// Withholds readiness for one endpoint until dropped, so the endpoint's owner
30/// decides when it is serviceable rather than its transport registration.
31///
32/// Two effects, matching [`SystemHealth::hold_endpoint_readiness`]: transport
33/// registration no longer marks the named endpoint ready, and while any hold is
34/// outstanding [`SystemHealth::get_health_status`] reports not-ready for the
35/// whole process.
36///
37/// Take the hold *before* the endpoint registers with its transport — that is
38/// what makes it race-free on both request planes, since the NATS path publishes
39/// readiness from a spawned task.
40///
41/// Never let one drop while the `SystemHealth` mutex is held: `Drop` takes that
42/// lock and it is not reentrant.
43pub struct ReadinessHold {
44    system_health: Arc<parking_lot::Mutex<SystemHealth>>,
45    endpoint: String,
46}
47
48impl ReadinessHold {
49    pub fn take(system_health: Arc<parking_lot::Mutex<SystemHealth>>, endpoint: &str) -> Self {
50        system_health.lock().hold_endpoint_readiness(endpoint);
51        Self {
52            system_health,
53            endpoint: endpoint.to_string(),
54        }
55    }
56}
57
58impl Drop for ReadinessHold {
59    fn drop(&mut self) {
60        self.system_health
61            .lock()
62            .release_endpoint_readiness(&self.endpoint);
63    }
64}
65
66/// Health check target containing instance info and payload
67#[derive(Clone, Debug)]
68pub struct HealthCheckTarget {
69    pub instance: component::Instance,
70    pub payload: serde_json::Value,
71}
72
73/// Current Health Status
74/// If use_endpoint_health_status is set then
75/// initialize the endpoint_health hashmap to the
76/// starting health status
77#[derive(Clone)]
78pub struct SystemHealth {
79    system_health: HealthStatus,
80    endpoint_health: Arc<std::sync::RwLock<HashMap<String, HealthStatus>>>,
81    /// Maps endpoint subject to health check target (instance + payload)
82    health_check_targets: Arc<std::sync::RwLock<HashMap<String, HealthCheckTarget>>>,
83    /// Maps endpoint subject to its specific health check notifier
84    health_check_notifiers: Arc<std::sync::RwLock<HashMap<String, Arc<tokio::sync::Notify>>>>,
85    /// Endpoints whose owner publishes readiness itself. Transport registration
86    /// does not mark these ready; see [`SystemHealth::hold_endpoint_readiness`].
87    readiness_holds: Arc<std::sync::RwLock<HashSet<String>>>,
88    /// Channel for new endpoint registrations
89    /// This solves the race condition where HealthCheckManager starts before endpoints are registered
90    /// Using a channel ensures no registrations are lost.
91    new_endpoint_tx: mpsc::UnboundedSender<String>,
92    new_endpoint_rx: Arc<parking_lot::Mutex<Option<mpsc::UnboundedReceiver<String>>>>,
93    use_endpoint_health_status: Vec<String>,
94    health_check_enabled: bool,
95    health_path: String,
96    live_path: String,
97    start_time: Instant,
98    uptime_gauge: OnceLock<prometheus::Gauge>,
99}
100
101impl SystemHealth {
102    pub fn new(
103        starting_health_status: HealthStatus,
104        use_endpoint_health_status: Vec<String>,
105        health_check_enabled: bool,
106        health_path: String,
107        live_path: String,
108    ) -> Self {
109        // Force NotReady when canary is enabled — canary verifies before marking Ready.
110        let initial_endpoint_status = if health_check_enabled {
111            HealthStatus::NotReady
112        } else {
113            starting_health_status.clone()
114        };
115        let mut endpoint_health = HashMap::new();
116        for endpoint in &use_endpoint_health_status {
117            endpoint_health.insert(endpoint.clone(), initial_endpoint_status.clone());
118        }
119
120        // Create the channel for endpoint registration notifications
121        let (tx, rx) = mpsc::unbounded_channel();
122
123        SystemHealth {
124            system_health: starting_health_status,
125            endpoint_health: Arc::new(std::sync::RwLock::new(endpoint_health)),
126            health_check_targets: Arc::new(std::sync::RwLock::new(HashMap::new())),
127            health_check_notifiers: Arc::new(std::sync::RwLock::new(HashMap::new())),
128            readiness_holds: Arc::new(std::sync::RwLock::new(HashSet::new())),
129            new_endpoint_tx: tx,
130            new_endpoint_rx: Arc::new(parking_lot::Mutex::new(Some(rx))),
131            use_endpoint_health_status,
132            health_check_enabled,
133            health_path,
134            live_path,
135            start_time: Instant::now(),
136            uptime_gauge: OnceLock::new(),
137        }
138    }
139
140    pub fn health_check_enabled(&self) -> bool {
141        self.health_check_enabled
142    }
143
144    /// Signal endpoint transport registration. Endpoints with a canary target stay
145    /// NotReady until verification succeeds. Payload-less endpoints cannot run a
146    /// canary, so transport registration is their readiness signal.
147    pub fn set_endpoint_registered(&self, endpoint: &str) {
148        if self.readiness_holds.read().unwrap().contains(endpoint) {
149            return;
150        }
151        let has_health_check_target = self
152            .health_check_targets
153            .read()
154            .unwrap()
155            .contains_key(endpoint);
156        if !self.health_check_enabled || !has_health_check_target {
157            self.set_endpoint_health_status(endpoint, HealthStatus::Ready);
158        }
159    }
160
161    /// Withhold readiness for `endpoint` until [`release_endpoint_readiness`].
162    ///
163    /// Two effects, because one alone is not enough: transport registration no
164    /// longer marks this endpoint ready, and while any hold is outstanding
165    /// [`get_health_status`] reports not-ready for the whole process. Without the
166    /// second, a sibling endpoint registering unheld would answer the health route
167    /// on the held endpoint's behalf.
168    ///
169    /// Take the hold *before* the endpoint registers with its transport — that is
170    /// what makes this race-free on both request planes, since the NATS path
171    /// publishes readiness from a spawned task. Prefer [`ReadinessHold`], which
172    /// pairs this with its release.
173    ///
174    /// [`get_health_status`]: SystemHealth::get_health_status
175    ///
176    /// [`release_endpoint_readiness`]: SystemHealth::release_endpoint_readiness
177    pub fn hold_endpoint_readiness(&self, endpoint: &str) {
178        self.readiness_holds
179            .write()
180            .unwrap()
181            .insert(endpoint.to_string());
182    }
183
184    /// Drop a [`hold_endpoint_readiness`] hold. Readiness is not published here —
185    /// the owner writes it.
186    ///
187    /// [`hold_endpoint_readiness`]: SystemHealth::hold_endpoint_readiness
188    pub fn release_endpoint_readiness(&self, endpoint: &str) {
189        self.readiness_holds.write().unwrap().remove(endpoint);
190    }
191
192    pub fn set_health_status(&mut self, status: HealthStatus) {
193        self.system_health = status;
194    }
195
196    pub fn set_endpoint_health_status(&self, endpoint: &str, status: HealthStatus) {
197        let mut endpoint_health = self.endpoint_health.write().unwrap();
198        endpoint_health.insert(endpoint.to_string(), status);
199    }
200
201    /// Returns the overall health status and endpoint health statuses
202    /// System health is determined by ALL endpoints that have registered health checks
203    pub fn get_health_status(&self) -> (bool, HashMap<String, String>) {
204        let health_check_targets = self.health_check_targets.read().unwrap();
205        let endpoint_health = self.endpoint_health.read().unwrap();
206        let mut endpoints: HashMap<String, String> = HashMap::new();
207
208        for (endpoint, status) in endpoint_health.iter() {
209            endpoints.insert(
210                endpoint.clone(),
211                if *status == HealthStatus::Ready {
212                    "ready".to_string()
213                } else {
214                    "notready".to_string()
215                },
216            );
217        }
218
219        // An owner that has withheld an endpoint's readiness has not yet declared
220        // itself serviceable. Report not-ready for the whole process rather than
221        // letting some other endpoint's registration answer for it.
222        if !self.readiness_holds.read().unwrap().is_empty() {
223            return (false, endpoints);
224        }
225
226        let healthy = if !self.use_endpoint_health_status.is_empty() {
227            self.use_endpoint_health_status.iter().all(|endpoint| {
228                endpoint_health
229                    .get(endpoint)
230                    .is_some_and(|status| *status == HealthStatus::Ready)
231            })
232        } else {
233            // If we have registered health check targets, use them to determine health
234            if !health_check_targets.is_empty() {
235                health_check_targets
236                    .iter()
237                    .all(|(endpoint_subject, _target)| {
238                        endpoint_health
239                            .get(endpoint_subject)
240                            .is_some_and(|status| *status == HealthStatus::Ready)
241                    })
242            } else if self.health_check_enabled && !endpoint_health.is_empty() {
243                // A payload-less endpoint cannot register a canary target. When
244                // canaries are enabled, its transport registration is therefore
245                // the strongest available readiness signal.
246                endpoint_health
247                    .values()
248                    .all(|status| *status == HealthStatus::Ready)
249            } else {
250                // No health check targets registered, use simple system health
251                self.system_health == HealthStatus::Ready
252            }
253        };
254
255        (healthy, endpoints)
256    }
257
258    /// Register a health check target for an endpoint
259    pub fn register_health_check_target(
260        &self,
261        endpoint_subject: &str,
262        instance: component::Instance,
263        payload: serde_json::Value,
264    ) {
265        let key = endpoint_subject.to_owned();
266
267        // Atomically check+insert under a single write lock to avoid races.
268        let inserted = {
269            let mut targets = self.health_check_targets.write().unwrap();
270            match targets.entry(key.clone()) {
271                std::collections::hash_map::Entry::Occupied(_) => false,
272                std::collections::hash_map::Entry::Vacant(v) => {
273                    v.insert(HealthCheckTarget { instance, payload });
274                    true
275                }
276            }
277        };
278
279        if !inserted {
280            tracing::warn!(
281                "Attempted to re-register health check for endpoint '{}'; ignoring.",
282                key
283            );
284            return;
285        }
286
287        // Create and store a unique notifier for this endpoint (idempotent).
288        {
289            let mut notifiers = self.health_check_notifiers.write().unwrap();
290            notifiers
291                .entry(key.clone())
292                .or_insert_with(|| Arc::new(tokio::sync::Notify::new()));
293        }
294
295        // Initialize endpoint health status conservatively to NotReady.
296        {
297            let mut endpoint_health = self.endpoint_health.write().unwrap();
298            endpoint_health
299                .entry(key.clone())
300                .or_insert(HealthStatus::NotReady);
301        }
302
303        if let Err(e) = self.new_endpoint_tx.send(key.clone()) {
304            tracing::error!(
305                "Failed to send endpoint '{}' registration to health check manager: {}. \
306                 Health checks will not be performed for this endpoint.",
307                key,
308                e
309            );
310        }
311    }
312
313    /// Get all health check targets
314    pub fn get_health_check_targets(&self) -> Vec<(String, HealthCheckTarget)> {
315        let targets = self.health_check_targets.read().unwrap();
316        targets
317            .iter()
318            .map(|(k, v)| (k.clone(), v.clone()))
319            .collect()
320    }
321
322    /// Check if any health check targets are registered
323    pub fn has_health_check_targets(&self) -> bool {
324        let targets = self.health_check_targets.read().unwrap();
325        !targets.is_empty()
326    }
327
328    /// Get list of endpoints with health check targets
329    pub fn get_health_check_endpoints(&self) -> Vec<String> {
330        let targets = self.health_check_targets.read().unwrap();
331        targets.keys().cloned().collect()
332    }
333
334    /// Get health check target for a specific endpoint
335    pub fn get_health_check_target(&self, endpoint: &str) -> Option<HealthCheckTarget> {
336        let targets = self.health_check_targets.read().unwrap();
337        targets.get(endpoint).cloned()
338    }
339
340    /// Get the endpoint health status (Ready/NotReady)
341    pub fn get_endpoint_health_status(&self, endpoint: &str) -> Option<HealthStatus> {
342        let endpoint_health = self.endpoint_health.read().unwrap();
343        endpoint_health.get(endpoint).cloned()
344    }
345
346    /// Get the endpoint-specific health check notifier
347    pub fn get_endpoint_health_check_notifier(
348        &self,
349        endpoint_subject: &str,
350    ) -> Option<Arc<tokio::sync::Notify>> {
351        let notifiers = self.health_check_notifiers.read().unwrap();
352        notifiers.get(endpoint_subject).cloned()
353    }
354
355    /// Take the receiver for new endpoint registrations (can only be called once)
356    /// This is used by HealthCheckManager to receive notifications of new endpoints
357    pub fn take_new_endpoint_receiver(&self) -> Option<mpsc::UnboundedReceiver<String>> {
358        self.new_endpoint_rx.lock().take()
359    }
360
361    /// Initialize the uptime gauge using the provided metrics registry
362    pub fn initialize_uptime_gauge<T: MetricsHierarchy>(&self, registry: &T) -> anyhow::Result<()> {
363        let gauge = registry.metrics().create_gauge(
364            distributed_runtime::UPTIME_SECONDS,
365            "Total uptime of the DistributedRuntime in seconds",
366            &[],
367        )?;
368        self.uptime_gauge
369            .set(gauge)
370            .map_err(|_| anyhow::anyhow!("uptime_gauge already initialized"))?;
371        Ok(())
372    }
373
374    /// Get the current uptime as a Duration
375    pub fn uptime(&self) -> std::time::Duration {
376        self.start_time.elapsed()
377    }
378
379    /// Update the uptime gauge with the current uptime value
380    pub fn update_uptime_gauge(&self) {
381        if let Some(gauge) = self.uptime_gauge.get() {
382            gauge.set(self.uptime().as_secs_f64());
383        }
384    }
385
386    /// Get the health check path
387    pub fn health_path(&self) -> &str {
388        &self.health_path
389    }
390
391    /// Get the liveness check path
392    pub fn live_path(&self) -> &str {
393        &self.live_path
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::component::{Instance, TransportType};
401
402    const ENDPOINT: &str = "generate";
403
404    fn system_health(health_check_enabled: bool) -> SystemHealth {
405        SystemHealth::new(
406            HealthStatus::NotReady,
407            // Deprecated and ignored in practice (see RuntimeConfig::from_settings),
408            // so the realistic case is an empty vector.
409            Vec::new(),
410            health_check_enabled,
411            "/health".to_string(),
412            "/live".to_string(),
413        )
414    }
415
416    fn instance() -> Instance {
417        Instance {
418            component: "backend".to_string(),
419            endpoint: ENDPOINT.to_string(),
420            namespace: "dynamo".to_string(),
421            instance_id: 1,
422            transport: TransportType::Tcp("127.0.0.1:0".to_string()),
423            device_type: None,
424            request_plane_codec: None,
425        }
426    }
427
428    /// A worker that registers a health-check payload reports ready once its
429    /// endpoint is registered, with the canary off.
430    #[test]
431    fn registered_target_makes_the_worker_ready_with_canary_off() {
432        let health = system_health(false);
433        health.register_health_check_target(ENDPOINT, instance(), serde_json::json!({}));
434        health.set_endpoint_registered(ENDPOINT);
435
436        let (healthy, endpoints) = health.get_health_status();
437        assert!(healthy, "a registered, ready endpoint must report healthy");
438        assert_eq!(endpoints.get(ENDPOINT).map(String::as_str), Some("ready"));
439    }
440
441    /// Regression guard for the push-egress health-check bug.
442    ///
443    /// `health_check_targets` is populated ONLY by passing a
444    /// `health_check_payload` to `serve_endpoint`. An earlier revision of the
445    /// push-egress path skipped that payload to avoid the `start_with_registration`
446    /// bail, on the theory that it merely disabled the canary. It does not: with
447    /// the map empty, `get_health_status` stops consulting endpoint status at all
448    /// and falls through to the process-wide `system_health`, which starts
449    /// `NotReady` and which the TRT-LLM worker never sets. The endpoint is marked
450    /// ready and the worker still reports 503 — on default settings, since this
451    /// path does not depend on the canary being enabled.
452    #[test]
453    fn ready_endpoint_without_a_registered_target_still_reports_unhealthy() {
454        let health = system_health(false);
455        // No register_health_check_target: this is the "skip the payload" case.
456        health.set_endpoint_registered(ENDPOINT);
457
458        let (healthy, endpoints) = health.get_health_status();
459        assert_eq!(
460            endpoints.get(ENDPOINT).map(String::as_str),
461            Some("ready"),
462            "the endpoint itself is ready"
463        );
464        assert!(
465            !healthy,
466            "with no health-check target the endpoint's readiness is ignored and \
467             the worker falls back to system_health (NotReady) — this is the 503"
468        );
469    }
470
471    /// A held endpoint does not become ready on transport registration, so its
472    /// owner can keep the route at 503 until every mandatory endpoint is up.
473    #[test]
474    fn held_endpoint_is_not_ready_on_transport_registration() {
475        let health = system_health(false);
476        health.hold_endpoint_readiness(ENDPOINT);
477        health.set_endpoint_registered(ENDPOINT);
478        assert_ne!(
479            health.get_endpoint_health_status(ENDPOINT),
480            Some(HealthStatus::Ready),
481            "a held endpoint must not be marked ready by transport registration"
482        );
483
484        health.release_endpoint_readiness(ENDPOINT);
485        health.set_endpoint_registered(ENDPOINT);
486        assert_eq!(
487            health.get_endpoint_health_status(ENDPOINT),
488            Some(HealthStatus::Ready),
489            "after release the owner's registration signal publishes readiness"
490        );
491    }
492
493    /// A sibling endpoint registering while another is held must not answer the
494    /// health route on the held endpoint's behalf. With the canary on and no
495    /// registered target, `get_health_status` otherwise reduces to "every
496    /// endpoint present in the map is ready", which a held endpoint is absent
497    /// from.
498    #[test]
499    fn an_unheld_sibling_endpoint_cannot_report_the_worker_ready() {
500        let health = system_health(true);
501        health.hold_endpoint_readiness(ENDPOINT);
502        health.set_endpoint_registered(ENDPOINT);
503        health.set_endpoint_registered("rl_system");
504
505        assert!(
506            !health.get_health_status().0,
507            "a held endpoint must keep the worker not-ready however many siblings register"
508        );
509
510        health.release_endpoint_readiness(ENDPOINT);
511        health.set_endpoint_registered(ENDPOINT);
512        assert!(health.get_health_status().0);
513    }
514
515    /// A hold suppresses the *whole process's* readiness, but the per-endpoint
516    /// suppression inside `set_endpoint_registered` stays scoped to the endpoint
517    /// it names — a sibling still records its own transport registration.
518    #[test]
519    fn a_hold_does_not_suppress_a_siblings_endpoint_flag() {
520        let health = system_health(false);
521        health.hold_endpoint_readiness(ENDPOINT);
522        health.set_endpoint_registered("other");
523        assert_eq!(
524            health.get_endpoint_health_status("other"),
525            Some(HealthStatus::Ready)
526        );
527    }
528
529    /// The fallthrough is only escapable by setting system health directly,
530    /// which in this repo only the vLLM worker does.
531    #[test]
532    fn without_targets_health_tracks_system_health_only() {
533        let mut health = system_health(false);
534        health.set_endpoint_registered(ENDPOINT);
535        assert!(!health.get_health_status().0);
536
537        health.set_health_status(HealthStatus::Ready);
538        assert!(
539            health.get_health_status().0,
540            "with no targets, system_health alone decides"
541        );
542    }
543
544    /// Payload-less workers cannot run a canary. With canaries enabled, endpoint
545    /// registration is therefore sufficient to make the worker healthy.
546    #[test]
547    fn payloadless_endpoint_is_ready_with_canary_on() {
548        let health = system_health(true);
549        health.set_endpoint_registered(ENDPOINT);
550
551        let (healthy, endpoints) = health.get_health_status();
552        assert!(
553            healthy,
554            "a registered payload-less endpoint must report healthy"
555        );
556        assert_eq!(endpoints.get(ENDPOINT).map(String::as_str), Some("ready"));
557    }
558
559    /// With the canary on, endpoint registration deliberately does NOT mark the
560    /// endpoint ready — the canary does, after verifying a real generation. A
561    /// push endpoint therefore needs a locally registered engine for the canary
562    /// to dispatch to, which is why `serve_endpoint` registers a pull engine
563    /// alongside the push ingress.
564    #[test]
565    fn canary_enabled_withholds_ready_until_verified() {
566        let health = system_health(true);
567        health.register_health_check_target(ENDPOINT, instance(), serde_json::json!({}));
568        health.set_endpoint_registered(ENDPOINT);
569
570        assert!(
571            !health.get_health_status().0,
572            "canary must verify before the worker reports ready"
573        );
574
575        health.set_endpoint_health_status(ENDPOINT, HealthStatus::Ready);
576        assert!(
577            health.get_health_status().0,
578            "after the canary marks it ready the worker is healthy"
579        );
580    }
581}