Skip to main content

dynamo_runtime/
health_check.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::DistributedRuntime;
5use crate::config::HealthStatus;
6use crate::engine::AsyncEngine;
7use crate::pipeline::SingleIn;
8use crate::protocols::maybe_error::MaybeError;
9use futures::StreamExt;
10use parking_lot::Mutex;
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::task::JoinHandle;
15use tracing::{debug, error, info, warn};
16
17/// Configuration for health check behavior
18pub struct HealthCheckConfig {
19    /// Wait time before sending canary health checks (when no activity)
20    pub canary_wait_time: Duration,
21    /// Timeout for health check requests
22    pub request_timeout: Duration,
23}
24
25impl Default for HealthCheckConfig {
26    fn default() -> Self {
27        Self {
28            canary_wait_time: Duration::from_secs(crate::config::DEFAULT_CANARY_WAIT_TIME_SECS),
29            request_timeout: Duration::from_secs(
30                crate::config::DEFAULT_HEALTH_CHECK_REQUEST_TIMEOUT_SECS,
31            ),
32        }
33    }
34}
35
36/// Health check manager that monitors endpoint health
37pub struct HealthCheckManager {
38    drt: DistributedRuntime,
39    config: HealthCheckConfig,
40    /// Track per-endpoint health check tasks
41    /// Maps: endpoint_subject -> task_handle
42    endpoint_tasks: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
43}
44
45impl HealthCheckManager {
46    pub fn new(drt: DistributedRuntime, config: HealthCheckConfig) -> Self {
47        Self {
48            drt,
49            config,
50            endpoint_tasks: Arc::new(Mutex::new(HashMap::new())),
51        }
52    }
53
54    /// Start the health check manager by spawning per-endpoint monitoring tasks
55    pub async fn start(self: Arc<Self>) -> anyhow::Result<()> {
56        // Get all registered endpoints at startup
57        let targets = self.drt.system_health().lock().get_health_check_targets();
58
59        info!(
60            "Starting health check tasks for {} endpoints with canary_wait_time: {:?}",
61            targets.len(),
62            self.config.canary_wait_time
63        );
64
65        // Spawn a health check task for each registered endpoint
66        for (endpoint_subject, _target) in targets {
67            self.spawn_endpoint_health_check_task(endpoint_subject);
68        }
69
70        // CRITICAL: Spawn a task to monitor for NEW endpoints registered after startup
71        // This uses a channel-based approach to guarantee no lost notifications
72        // Will return an error if the receiver has already been taken
73        self.spawn_new_endpoint_monitor().await?;
74
75        info!("HealthCheckManager started successfully with channel-based endpoint discovery");
76        Ok(())
77    }
78
79    /// Spawn a dedicated health check task for a specific endpoint
80    fn spawn_endpoint_health_check_task(self: &Arc<Self>, endpoint_subject: String) {
81        let manager = self.clone();
82        let canary_wait = self.config.canary_wait_time;
83        let endpoint_subject_clone = endpoint_subject.clone();
84
85        // Get the endpoint-specific notifier
86        let notifier = self
87            .drt
88            .system_health()
89            .lock()
90            .get_endpoint_health_check_notifier(&endpoint_subject)
91            .expect("Notifier should exist for registered endpoint");
92
93        let task = tokio::spawn(async move {
94            let endpoint_subject = endpoint_subject_clone;
95            info!("Health check task started for: {}", endpoint_subject);
96
97            loop {
98                // Wait for either timeout or activity notification
99                tokio::select! {
100                    _ = tokio::time::sleep(canary_wait) => {
101                        // Timeout - send health check for this specific endpoint
102                        debug!("Canary timer expired for {}, sending health check", endpoint_subject);
103
104                        // Get the health check payload for this endpoint
105                        let target = manager.drt.system_health().lock().get_health_check_target(&endpoint_subject);
106
107                        if let Some(target) = target {
108                            if let Err(e) = manager.send_health_check_request(&endpoint_subject, &target.payload).await {
109                                error!("Failed to send health check for {}: {}", endpoint_subject, e);
110                            }
111                        } else {
112                            // This should never happen - targets are registered at startup and never removed
113                            error!(
114                                "CRITICAL: Health check target for {} disappeared unexpectedly! This indicates a bug. Stopping health check task.",
115                                endpoint_subject
116                            );
117                            break;
118                        }
119                    }
120
121                    _ = notifier.notified() => {
122                        // Activity detected - reset timer for this endpoint only.
123                        // A notification means push_handler successfully streamed
124                        // a non-error response chunk, proving the engine is healthy.
125                        debug!("Activity detected for {}, resetting health check timer", endpoint_subject);
126                        manager.drt.system_health().lock().set_endpoint_health_status(
127                            &endpoint_subject,
128                            crate::config::HealthStatus::Ready,
129                        );
130                    }
131                }
132            }
133
134            info!("Health check task for {} exiting", endpoint_subject);
135        });
136
137        // Store the task handle
138        self.endpoint_tasks
139            .lock()
140            .insert(endpoint_subject.clone(), task);
141
142        info!(
143            "Spawned health check task for endpoint: {}",
144            endpoint_subject
145        );
146    }
147
148    /// Spawn a task to monitor for newly registered endpoints
149    /// Returns an error if duplicate endpoints are detected, indicating a bug in the system
150    async fn spawn_new_endpoint_monitor(self: &Arc<Self>) -> anyhow::Result<()> {
151        let manager = self.clone();
152
153        // Get the receiver (can only be taken once)
154        let mut rx = manager
155            .drt
156            .system_health()
157            .lock()
158            .take_new_endpoint_receiver()
159            .ok_or_else(|| {
160                anyhow::anyhow!("Endpoint receiver already taken - this should only be called once")
161            })?;
162
163        tokio::spawn(async move {
164            info!("Starting dynamic endpoint discovery monitor with channel-based notifications");
165
166            while let Some(endpoint_subject) = rx.recv().await {
167                debug!(
168                    "Received endpoint registration via channel: {}",
169                    endpoint_subject
170                );
171
172                let already_exists = {
173                    let tasks = manager.endpoint_tasks.lock();
174                    tasks.contains_key(&endpoint_subject)
175                };
176
177                if already_exists {
178                    error!(
179                        "CRITICAL: Received registration for endpoint '{}' that already has a health check task!",
180                        endpoint_subject
181                    );
182                    break;
183                }
184
185                info!(
186                    "Spawning health check task for new endpoint: {}",
187                    endpoint_subject
188                );
189                manager.spawn_endpoint_health_check_task(endpoint_subject);
190            }
191
192            info!("Endpoint discovery monitor exiting - no new endpoints will be monitored!");
193        });
194
195        info!("Dynamic endpoint discovery monitor started");
196        Ok(())
197    }
198
199    /// Send a health check request via the local endpoint registry (in-process).
200    async fn send_health_check_request(
201        &self,
202        endpoint_subject: &str,
203        payload: &serde_json::Value,
204    ) -> anyhow::Result<()> {
205        debug!(
206            "Sending health check to {} via local registry",
207            endpoint_subject
208        );
209
210        let engine = self
211            .drt
212            .local_endpoint_registry()
213            .get(endpoint_subject)
214            .ok_or_else(|| {
215                anyhow::anyhow!(
216                    "Endpoint '{}' not found in local registry, engine may still be initializing",
217                    endpoint_subject
218                )
219            })?;
220
221        // Clone what we need for the spawned task
222        let system_health = self.drt.system_health().clone();
223        let endpoint_subject_owned = endpoint_subject.to_string();
224        let payload = payload.clone();
225        let timeout = self.config.request_timeout;
226
227        // Spawn task to send health check and wait for response
228        tokio::spawn(async move {
229            let result = tokio::time::timeout(timeout, async {
230                let request = SingleIn::new(payload);
231                match engine.generate(request).await {
232                    Ok(mut response_stream) => {
233                        // Get the first response to verify endpoint is alive.
234                        // Check for errors
235                        let is_healthy = if let Some(response) = response_stream.next().await {
236                            if let Some(error) = response.err() {
237                                warn!(
238                                    "Health check error response from {}: {:?}",
239                                    endpoint_subject_owned, error
240                                );
241                                false
242                            } else {
243                                debug!("Health check successful for {}", endpoint_subject_owned);
244                                true
245                            }
246                        } else {
247                            warn!(
248                                "Health check got no response from {}",
249                                endpoint_subject_owned
250                            );
251                            false
252                        };
253
254                        tokio::spawn(async move {
255                            // We need to consume the rest of the stream to avoid warnings on the frontend.
256                            response_stream.for_each(|_| async {}).await;
257                        });
258
259                        // Update health status based on response
260                        system_health.lock().set_endpoint_health_status(
261                            &endpoint_subject_owned,
262                            if is_healthy {
263                                HealthStatus::Ready
264                            } else {
265                                HealthStatus::NotReady
266                            },
267                        );
268                    }
269                    Err(e) => {
270                        error!(
271                            "Health check request failed for {}: {}",
272                            endpoint_subject_owned, e
273                        );
274                        system_health.lock().set_endpoint_health_status(
275                            &endpoint_subject_owned,
276                            HealthStatus::NotReady,
277                        );
278                    }
279                }
280            })
281            .await;
282
283            // Handle timeout
284            if result.is_err() {
285                warn!("Health check timeout for {}", endpoint_subject_owned);
286                system_health
287                    .lock()
288                    .set_endpoint_health_status(&endpoint_subject_owned, HealthStatus::NotReady);
289            }
290
291            debug!("Health check completed for {}", endpoint_subject_owned);
292        });
293
294        Ok(())
295    }
296}
297
298/// Start health check manager for the distributed runtime
299pub async fn start_health_check_manager(
300    drt: DistributedRuntime,
301    config: Option<HealthCheckConfig>,
302) -> anyhow::Result<()> {
303    let config = config.unwrap_or_default();
304    let manager = Arc::new(HealthCheckManager::new(drt, config));
305
306    // Start the health check manager (this spawns per-endpoint tasks internally)
307    manager.start().await?;
308
309    Ok(())
310}
311
312/// Get health check status for all endpoints
313pub async fn get_health_check_status(
314    drt: &DistributedRuntime,
315) -> anyhow::Result<serde_json::Value> {
316    // Get endpoints list from SystemHealth
317    let endpoint_subjects: Vec<String> = drt.system_health().lock().get_health_check_endpoints();
318
319    let mut endpoint_statuses = HashMap::new();
320
321    // Check each endpoint's health status
322    {
323        let system_health = drt.system_health();
324        let system_health_lock = system_health.lock();
325        for endpoint_subject in &endpoint_subjects {
326            let health_status = system_health_lock
327                .get_endpoint_health_status(endpoint_subject)
328                .unwrap_or(HealthStatus::NotReady);
329
330            let is_healthy = matches!(health_status, HealthStatus::Ready);
331
332            endpoint_statuses.insert(
333                endpoint_subject.clone(),
334                serde_json::json!({
335                    "healthy": is_healthy,
336                    "status": format!("{:?}", health_status),
337                }),
338            );
339        }
340    }
341
342    let overall_healthy = endpoint_statuses
343        .values()
344        .all(|v| v["healthy"].as_bool().unwrap_or(false));
345
346    Ok(serde_json::json!({
347        "status": if overall_healthy { "ready" } else { "notready" },
348        "endpoints_checked": endpoint_subjects.len(),
349        "endpoint_statuses": endpoint_statuses,
350    }))
351}
352
353// ============================================================
354// Full pipeline tests: push_handler → notify → HealthCheckManager
355// These tests use the real HealthCheckManager (spawn_endpoint_health_check_task)
356// and the real push_handler pipeline (TwoPartCodec + TCP + engine.generate()).
357// ============================================================
358#[cfg(all(test, feature = "integration"))]
359mod push_handler_notify_tests {
360    use super::*;
361    use crate::component::{Instance, TransportType};
362    use crate::config::HealthStatus;
363    use crate::distributed::distributed_test_utils::create_test_drt_async;
364    use crate::engine::{AsyncEngine, AsyncEngineContextProvider};
365    use crate::local_endpoint_registry::LocalAsyncEngine;
366    use crate::pipeline::network::codec::{TwoPartCodec, TwoPartMessage};
367    use crate::pipeline::network::{
368        ConnectionInfo, Ingress, PushWorkHandler, quic_response::QuicResponseServer,
369    };
370    use crate::pipeline::{ManyOut, ResponseStream, SingleIn};
371    use crate::protocols::annotated::Annotated;
372    use async_trait::async_trait;
373    use bytes::Bytes;
374    use futures::stream;
375    use std::sync::Arc;
376    use std::time::Duration;
377
378    type TestRequest = serde_json::Value;
379    type TestResponse = Annotated<serde_json::Value>;
380
381    /// A mock engine that streams a configurable sequence of success/error chunks.
382    /// Used both as the push_handler pipeline engine and registered in
383    /// the local endpoint registry for health check requests.
384    struct MockStreamingEngine {
385        num_chunks: usize,
386        /// If set, chunks at these indices will be error responses.
387        error_indices: Vec<usize>,
388    }
389
390    impl MockStreamingEngine {
391        fn success(num_chunks: usize) -> Arc<Self> {
392            Arc::new(Self {
393                num_chunks,
394                error_indices: vec![],
395            })
396        }
397
398        fn all_errors(num_chunks: usize) -> Arc<Self> {
399            Arc::new(Self {
400                num_chunks,
401                error_indices: (0..num_chunks).collect(),
402            })
403        }
404
405        fn with_error_at(num_chunks: usize, error_indices: Vec<usize>) -> Arc<Self> {
406            Arc::new(Self {
407                num_chunks,
408                error_indices,
409            })
410        }
411    }
412
413    #[async_trait]
414    impl AsyncEngine<SingleIn<TestRequest>, ManyOut<TestResponse>, anyhow::Error>
415        for MockStreamingEngine
416    {
417        async fn generate(
418            &self,
419            input: SingleIn<TestRequest>,
420        ) -> anyhow::Result<ManyOut<TestResponse>> {
421            let (_data, ctx) = input.into_parts();
422            let chunks: Vec<TestResponse> = (0..self.num_chunks)
423                .map(|i| {
424                    if self.error_indices.contains(&i) {
425                        Annotated::from_error(format!("mock error at chunk {i}"))
426                    } else {
427                        Annotated::from_data(serde_json::json!({"token": i}))
428                    }
429                })
430                .collect();
431            Ok(ResponseStream::new(
432                Box::pin(stream::iter(chunks)),
433                ctx.context(),
434            ))
435        }
436    }
437
438    /// Encodes a request as a TwoPartCodec payload with the given connection info.
439    fn encode_request(
440        request_id: &str,
441        connection_info: ConnectionInfo,
442        request_body: &serde_json::Value,
443    ) -> Bytes {
444        let control = serde_json::json!({
445            "id": request_id,
446            "request_type": "single_in",
447            "response_type": "many_out",
448            "connection_info": connection_info,
449        });
450        let header = serde_json::to_vec(&control).unwrap();
451        let data = serde_json::to_vec(request_body).unwrap();
452        let msg = TwoPartMessage::new(Bytes::from(header), Bytes::from(data));
453        TwoPartCodec::default().encode_message(msg).unwrap()
454    }
455
456    /// Sets up a QUIC server and registers a response stream for push_handler
457    /// to connect back to.
458    async fn setup_quic_receiver(
459        request_id: &str,
460    ) -> (
461        Arc<QuicResponseServer>,
462        ConnectionInfo,
463        crate::pipeline::network::StreamProvider<crate::pipeline::network::StreamReceiver>,
464    ) {
465        let shutdown = crate::CancellationToken::new();
466        let address = "127.0.0.1:0".parse().unwrap();
467        let server = QuicResponseServer::new(address, address, shutdown).unwrap();
468        let context = crate::pipeline::Context::with_id_and_metadata(
469            (),
470            request_id.to_string(),
471            Default::default(),
472        );
473        let registered = server.register_response(context.context());
474        let (connection_info, provider) = registered.into_parts();
475
476        (server, connection_info, provider)
477    }
478
479    /// Registers an endpoint in the DRT with the given engine in local registry.
480    /// Returns the notifier that the real HealthCheckManager will listen on.
481    fn register_endpoint(
482        drt: &crate::DistributedRuntime,
483        endpoint_name: &str,
484        local_engine: LocalAsyncEngine,
485    ) -> Arc<tokio::sync::Notify> {
486        let payload = serde_json::json!({
487            "prompt": "health",
488            "_health_check": true
489        });
490        drt.system_health().lock().register_health_check_target(
491            endpoint_name,
492            Instance {
493                component: "test_component".to_string(),
494                endpoint: endpoint_name.to_string(),
495                namespace: "test_namespace".to_string(),
496                instance_id: 0,
497                transport: TransportType::Nats(endpoint_name.to_string()),
498                device_type: None,
499                request_plane_codec: None,
500            },
501            payload,
502        );
503
504        drt.local_endpoint_registry()
505            .register(endpoint_name.to_string(), local_engine);
506
507        drt.system_health()
508            .lock()
509            .get_endpoint_health_check_notifier(endpoint_name)
510            .expect("Notifier should exist for registered endpoint")
511    }
512
513    /// Helper: send a request through the ingress pipeline.
514    async fn send_request(ingress: &Ingress<SingleIn<TestRequest>, ManyOut<TestResponse>>) {
515        let request_id = uuid::Uuid::new_v4().to_string();
516        let (_server, connection_info, _provider) = setup_quic_receiver(&request_id).await;
517        let payload = encode_request(
518            &request_id,
519            connection_info,
520            &serde_json::json!({"prompt": "test"}),
521        );
522        let result = ingress.handle_payload(payload, Some(request_id)).await;
523        assert!(result.is_ok(), "handle_payload should succeed");
524    }
525
526    /// Helper: assert endpoint health status.
527    fn assert_status(
528        drt: &crate::DistributedRuntime,
529        endpoint_name: &str,
530        expected: HealthStatus,
531        msg: &str,
532    ) {
533        let status = drt
534            .system_health()
535            .lock()
536            .get_endpoint_health_status(endpoint_name);
537        assert_eq!(status, Some(expected), "{msg}");
538    }
539
540    /// Helper: create ingress pipeline with given engine and notifier.
541    fn create_ingress(
542        _drt: &crate::DistributedRuntime,
543        engine: Arc<MockStreamingEngine>,
544        notifier: Arc<tokio::sync::Notify>,
545    ) -> Arc<Ingress<SingleIn<TestRequest>, ManyOut<TestResponse>>> {
546        let ingress =
547            Ingress::<SingleIn<TestRequest>, ManyOut<TestResponse>>::for_engine(engine).unwrap();
548        ingress
549            .set_endpoint_health_check_notifier(notifier)
550            .unwrap();
551        ingress
552            .set_quic_response_client_pool(
553                crate::pipeline::network::quic_response::process_client_pool_from_env().unwrap(),
554            )
555            .unwrap();
556        ingress
557    }
558
559    /// Helper: start HealthCheckManager with given canary wait.
560    async fn start_manager(drt: &crate::DistributedRuntime, canary_wait_ms: u64) {
561        let config = HealthCheckConfig {
562            canary_wait_time: Duration::from_millis(canary_wait_ms),
563            request_timeout: Duration::from_secs(1),
564        };
565        let manager = Arc::new(HealthCheckManager::new(drt.clone(), config));
566        manager.start().await.unwrap();
567    }
568
569    // =================================================================
570    // Test 1: Successful streaming → notification → Ready
571    // Canary engine returns errors, so Ready can only come from notify.
572    // =================================================================
573    #[tokio::test]
574    async fn test_successful_streaming_sets_ready() {
575        let drt = create_test_drt_async().await;
576        let endpoint = "test.successful_streaming";
577
578        let notifier = register_endpoint(&drt, endpoint, MockStreamingEngine::all_errors(1));
579        assert_status(&drt, endpoint, HealthStatus::NotReady, "initial");
580
581        let ingress = create_ingress(&drt, MockStreamingEngine::success(5), notifier);
582        start_manager(&drt, 500).await;
583
584        send_request(&ingress).await;
585        tokio::time::sleep(Duration::from_millis(200)).await;
586
587        // Ready can only come from notification (canary engine errors)
588        assert_status(
589            &drt,
590            endpoint,
591            HealthStatus::Ready,
592            "successful streaming should set Ready via notification path",
593        );
594    }
595
596    // =================================================================
597    // Test 2: Idle engine → canary fires → successful health check → Ready
598    // =================================================================
599    #[tokio::test]
600    async fn test_canary_fires_on_idle_engine() {
601        let drt = create_test_drt_async().await;
602        let endpoint = "test.canary_idle";
603
604        let _notifier = register_endpoint(&drt, endpoint, MockStreamingEngine::success(1));
605        assert_status(&drt, endpoint, HealthStatus::NotReady, "initial");
606
607        start_manager(&drt, 50).await;
608        tokio::time::sleep(Duration::from_millis(300)).await;
609
610        // No requests sent — canary fired and succeeded
611        assert_status(
612            &drt,
613            endpoint,
614            HealthStatus::Ready,
615            "canary should fire and set Ready on idle engine",
616        );
617    }
618
619    // =================================================================
620    // Test 3: Error streaming → no notification → canary errors → NotReady
621    // =================================================================
622    #[tokio::test]
623    async fn test_error_streaming_stays_not_ready() {
624        let drt = create_test_drt_async().await;
625        let endpoint = "test.error_streaming";
626
627        let notifier = register_endpoint(&drt, endpoint, MockStreamingEngine::all_errors(1));
628        assert_status(&drt, endpoint, HealthStatus::NotReady, "initial");
629
630        // Pipeline streams only errors — no notifications sent
631        let ingress = create_ingress(&drt, MockStreamingEngine::all_errors(3), notifier);
632        start_manager(&drt, 50).await;
633
634        send_request(&ingress).await;
635        // Wait for canary to fire (50ms wait + margin)
636        tokio::time::sleep(Duration::from_millis(300)).await;
637
638        // Error streaming didn't notify, canary fired but engine also errored
639        assert_status(
640            &drt,
641            endpoint,
642            HealthStatus::NotReady,
643            "error streaming should not notify, canary also errors — stays NotReady",
644        );
645    }
646
647    // =================================================================
648    // Test 4: Idle engine → canary fires → failing health check → NotReady
649    // =================================================================
650    #[tokio::test]
651    async fn test_idle_engine_with_failing_canary() {
652        let drt = create_test_drt_async().await;
653        let endpoint = "test.canary_fails";
654
655        let _notifier = register_endpoint(&drt, endpoint, MockStreamingEngine::all_errors(1));
656        assert_status(&drt, endpoint, HealthStatus::NotReady, "initial");
657
658        start_manager(&drt, 50).await;
659        tokio::time::sleep(Duration::from_millis(300)).await;
660
661        // No requests sent, canary fired but engine returned error
662        assert_status(
663            &drt,
664            endpoint,
665            HealthStatus::NotReady,
666            "canary fired but engine errored, status stays NotReady",
667        );
668    }
669
670    // =================================================================
671    // Test 5: Mixed streaming (success + trailing error) → Ready
672    // Successful chunks notify before the error, so status becomes Ready.
673    // Canary engine errors, proving Ready came from notification path.
674    // =================================================================
675    #[tokio::test]
676    async fn test_mixed_streaming_sets_ready() {
677        let drt = create_test_drt_async().await;
678        let endpoint = "test.mixed_streaming";
679
680        let notifier = register_endpoint(&drt, endpoint, MockStreamingEngine::all_errors(1));
681        assert_status(&drt, endpoint, HealthStatus::NotReady, "initial");
682
683        // 5 chunks: 4 success + error at index 4
684        let ingress = create_ingress(
685            &drt,
686            MockStreamingEngine::with_error_at(5, vec![4]),
687            notifier,
688        );
689        start_manager(&drt, 500).await;
690
691        send_request(&ingress).await;
692        tokio::time::sleep(Duration::from_millis(200)).await;
693
694        // Successful chunks notified before the error chunk
695        assert_status(
696            &drt,
697            endpoint,
698            HealthStatus::Ready,
699            "successful chunks should set Ready despite trailing error",
700        );
701    }
702}
703
704// ===============================
705// Integration Tests (require DRT)
706// ===============================
707#[cfg(all(test, feature = "integration"))]
708mod integration_tests {
709    use super::*;
710    use crate::distributed::distributed_test_utils::create_test_drt_async;
711    use std::sync::Arc;
712    use std::time::Duration;
713
714    #[tokio::test]
715    async fn test_initialization() {
716        let drt = create_test_drt_async().await;
717
718        let canary_wait_time = Duration::from_secs(5);
719        let request_timeout = Duration::from_secs(3);
720
721        let config = HealthCheckConfig {
722            canary_wait_time,
723            request_timeout,
724        };
725
726        let manager = HealthCheckManager::new(drt.clone(), config);
727
728        assert_eq!(manager.config.canary_wait_time, canary_wait_time);
729        assert_eq!(manager.config.request_timeout, request_timeout);
730    }
731
732    #[tokio::test]
733    async fn test_payload_registration() {
734        let drt = create_test_drt_async().await;
735
736        let endpoint = "test.endpoint";
737        let payload = serde_json::json!({
738            "prompt": "test",
739            "_health_check": true
740        });
741
742        drt.system_health().lock().register_health_check_target(
743            endpoint,
744            crate::component::Instance {
745                component: "test_component".to_string(),
746                endpoint: "test_endpoint".to_string(),
747                namespace: "test_namespace".to_string(),
748                instance_id: 12345,
749                transport: crate::component::TransportType::Nats(endpoint.to_string()),
750                device_type: None,
751                request_plane_codec: None,
752            },
753            payload.clone(),
754        );
755
756        let retrieved = drt
757            .system_health()
758            .lock()
759            .get_health_check_target(endpoint)
760            .map(|t| t.payload);
761        assert!(retrieved.is_some());
762        assert_eq!(retrieved.unwrap(), payload);
763
764        // Verify endpoint appears in the list
765        let endpoints = drt.system_health().lock().get_health_check_endpoints();
766        assert!(endpoints.contains(&endpoint.to_string()));
767    }
768
769    #[tokio::test]
770    async fn test_spawn_per_endpoint_tasks() {
771        let drt = create_test_drt_async().await;
772
773        for i in 0..3 {
774            let endpoint = format!("test.endpoint.{}", i);
775            let payload = serde_json::json!({
776                "prompt": format!("test{}", i),
777                "_health_check": true
778            });
779            drt.system_health().lock().register_health_check_target(
780                &endpoint,
781                crate::component::Instance {
782                    component: "test_component".to_string(),
783                    endpoint: format!("test_endpoint_{}", i),
784                    namespace: "test_namespace".to_string(),
785                    instance_id: i,
786                    transport: crate::component::TransportType::Nats(endpoint.clone()),
787                    device_type: None,
788                    request_plane_codec: None,
789                },
790                payload,
791            );
792        }
793
794        let config = HealthCheckConfig {
795            canary_wait_time: Duration::from_secs(5),
796            request_timeout: Duration::from_secs(1),
797        };
798
799        let manager = Arc::new(HealthCheckManager::new(drt.clone(), config));
800        manager.clone().start().await.unwrap();
801
802        // Verify all endpoints have their own health check tasks
803        let tasks = manager.endpoint_tasks.lock();
804        // Should have 3 tasks (one for each endpoint)
805        assert_eq!(tasks.len(), 3);
806        // Check that all endpoints are represented in tasks
807        let endpoints: Vec<String> = tasks.keys().cloned().collect();
808        assert!(endpoints.contains(&"test.endpoint.0".to_string()));
809        assert!(endpoints.contains(&"test.endpoint.1".to_string()));
810        assert!(endpoints.contains(&"test.endpoint.2".to_string()));
811    }
812
813    #[tokio::test]
814    async fn test_endpoint_health_check_notifier_created() {
815        let drt = create_test_drt_async().await;
816
817        let endpoint = "test.endpoint.notifier";
818        let payload = serde_json::json!({
819            "prompt": "test",
820            "_health_check": true
821        });
822
823        // Register the endpoint
824        drt.system_health().lock().register_health_check_target(
825            endpoint,
826            crate::component::Instance {
827                component: "test_component".to_string(),
828                endpoint: "test_endpoint_notifier".to_string(),
829                namespace: "test_namespace".to_string(),
830                instance_id: 999,
831                transport: crate::component::TransportType::Nats(endpoint.to_string()),
832                device_type: None,
833                request_plane_codec: None,
834            },
835            payload.clone(),
836        );
837
838        // Verify that a notifier was created for this endpoint
839        let notifier = drt
840            .system_health()
841            .lock()
842            .get_endpoint_health_check_notifier(endpoint);
843
844        assert!(
845            notifier.is_some(),
846            "Endpoint should have a notifier created"
847        );
848
849        // Verify we can notify it without panicking
850        if let Some(notifier) = notifier {
851            notifier.notify_one();
852        }
853
854        // Initially, the endpoint should be Ready (default after registration)
855        let status = drt
856            .system_health()
857            .lock()
858            .get_endpoint_health_status(endpoint);
859        assert_eq!(status, Some(HealthStatus::NotReady));
860    }
861}