1use 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
17pub struct HealthCheckConfig {
19 pub canary_wait_time: Duration,
21 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
36pub struct HealthCheckManager {
38 drt: DistributedRuntime,
39 config: HealthCheckConfig,
40 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 pub async fn start(self: Arc<Self>) -> anyhow::Result<()> {
56 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 for (endpoint_subject, _target) in targets {
67 self.spawn_endpoint_health_check_task(endpoint_subject);
68 }
69
70 self.spawn_new_endpoint_monitor().await?;
74
75 info!("HealthCheckManager started successfully with channel-based endpoint discovery");
76 Ok(())
77 }
78
79 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 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 tokio::select! {
100 _ = tokio::time::sleep(canary_wait) => {
101 debug!("Canary timer expired for {}, sending health check", endpoint_subject);
103
104 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 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 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 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 async fn spawn_new_endpoint_monitor(self: &Arc<Self>) -> anyhow::Result<()> {
151 let manager = self.clone();
152
153 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 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 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 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 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 response_stream.for_each(|_| async {}).await;
257 });
258
259 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 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
298pub 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 manager.start().await?;
308
309 Ok(())
310}
311
312pub async fn get_health_check_status(
314 drt: &DistributedRuntime,
315) -> anyhow::Result<serde_json::Value> {
316 let endpoint_subjects: Vec<String> = drt.system_health().lock().get_health_check_endpoints();
318
319 let mut endpoint_statuses = HashMap::new();
320
321 {
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#[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 struct MockStreamingEngine {
385 num_chunks: usize,
386 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 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 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 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 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 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 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 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 #[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 assert_status(
589 &drt,
590 endpoint,
591 HealthStatus::Ready,
592 "successful streaming should set Ready via notification path",
593 );
594 }
595
596 #[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 assert_status(
612 &drt,
613 endpoint,
614 HealthStatus::Ready,
615 "canary should fire and set Ready on idle engine",
616 );
617 }
618
619 #[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 let ingress = create_ingress(&drt, MockStreamingEngine::all_errors(3), notifier);
632 start_manager(&drt, 50).await;
633
634 send_request(&ingress).await;
635 tokio::time::sleep(Duration::from_millis(300)).await;
637
638 assert_status(
640 &drt,
641 endpoint,
642 HealthStatus::NotReady,
643 "error streaming should not notify, canary also errors — stays NotReady",
644 );
645 }
646
647 #[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 assert_status(
663 &drt,
664 endpoint,
665 HealthStatus::NotReady,
666 "canary fired but engine errored, status stays NotReady",
667 );
668 }
669
670 #[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 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 assert_status(
696 &drt,
697 endpoint,
698 HealthStatus::Ready,
699 "successful chunks should set Ready despite trailing error",
700 );
701 }
702}
703
704#[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 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 let tasks = manager.endpoint_tasks.lock();
804 assert_eq!(tasks.len(), 3);
806 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 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 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 if let Some(notifier) = notifier {
851 notifier.notify_one();
852 }
853
854 let status = drt
856 .system_health()
857 .lock()
858 .get_endpoint_health_status(endpoint);
859 assert_eq!(status, Some(HealthStatus::NotReady));
860 }
861}