1use std::collections::HashSet;
7use std::sync::{Arc, Mutex};
8use std::time::Instant;
9
10use camel_api::{CamelError, MetricsCollector, RouteController, SupervisionConfig};
11use tokio::sync::mpsc;
12use tokio::task::JoinHandle;
13use tracing::{debug, error, info, warn};
14
15pub(crate) use super::controller_actor_commands::RouteControllerCommand;
16pub use super::controller_actor_commands::RouteControllerHandle;
17use super::route_controller::DefaultRouteController;
18use super::route_helpers::CrashNotification;
19
20pub fn spawn_controller_actor(
21 controller: DefaultRouteController,
22) -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
23 let (tx, mut rx) = mpsc::channel::<RouteControllerCommand>(256);
24 let cohort = Arc::clone(&controller.cohort);
28 let tx_for_spawn = tx.clone();
32 let handle = tokio::spawn(async move {
33 let mut controller = controller;
34 let restarting: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
39 while let Some(cmd) = rx.recv().await {
40 match cmd {
41 RouteControllerCommand::StartRoute { route_id, reply } => {
42 if restarting
44 .lock()
45 .expect("restarting mutex poisoned") .contains(&route_id)
47 {
48 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
49 "route {} is restarting",
50 route_id
51 ))));
52 continue;
53 }
54 let _ = reply.send(controller.start_route(&route_id).await);
55 }
56 RouteControllerCommand::StopRoute {
57 route_id,
58 stop_budget,
59 reply,
60 } => {
61 if restarting
63 .lock()
64 .expect("restarting mutex poisoned") .contains(&route_id)
66 {
67 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
68 "route {} is restarting",
69 route_id
70 ))));
71 continue;
72 }
73 let stop_result = match stop_budget {
74 Some(budget) => controller.stop_route_with_budget(&route_id, budget).await,
75 None => controller.stop_route(&route_id).await,
76 };
77 let _ = reply.send(stop_result);
78 }
79 RouteControllerCommand::RestartRoute { route_id, reply } => {
80 {
83 let mut guard = restarting.lock().expect("restarting mutex poisoned"); if guard.contains(&route_id) {
85 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
86 "route {} is restarting",
87 route_id
88 ))));
89 continue;
90 }
91 guard.insert(route_id.clone());
92 }
93
94 let stop_result = controller.stop_route(&route_id).await;
99 if let Err(ref e) = stop_result {
100 restarting
102 .lock()
103 .expect("restarting mutex poisoned") .remove(&route_id);
105 let _ = reply.send(Err(e.clone()));
106 continue;
107 }
108
109 let tx_clone = tx_for_spawn.clone();
114 let restarting_clone = restarting.clone();
115 let route_id_for_start = route_id.clone();
116 let route_id_for_cleanup = route_id.clone();
117 tokio::spawn(async move {
118 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
119 restarting_clone
125 .lock()
126 .expect("restarting mutex poisoned") .remove(&route_id_for_cleanup);
128 if tx_clone
129 .send(RouteControllerCommand::StartRoute {
130 route_id: route_id_for_start,
131 reply,
132 })
133 .await
134 .is_err()
135 {
136 warn!(
137 "route {} restart: StartRoute send failed (actor shutting down)",
138 route_id_for_cleanup
139 );
140 }
141 });
142 }
144 RouteControllerCommand::SuspendRoute { route_id, reply } => {
145 if restarting
147 .lock()
148 .expect("restarting mutex poisoned") .contains(&route_id)
150 {
151 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
152 "route {} is restarting",
153 route_id
154 ))));
155 continue;
156 }
157 let _ = reply.send(controller.suspend_route(&route_id).await);
158 }
159 RouteControllerCommand::ResumeRoute { route_id, reply } => {
160 if restarting
162 .lock()
163 .expect("restarting mutex poisoned") .contains(&route_id)
165 {
166 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
167 "route {} is restarting",
168 route_id
169 ))));
170 continue;
171 }
172 let _ = reply.send(controller.resume_route(&route_id).await);
173 }
174 RouteControllerCommand::StartAllRoutes { reply } => {
175 let _ = reply.send(controller.start_all_routes().await);
176 }
177 RouteControllerCommand::StopAllRoutes { reply } => {
178 let _ = reply.send(controller.stop_all_routes().await);
179 }
180 RouteControllerCommand::AddRoute { definition, reply } => {
181 let _ = reply.send(controller.add_route(definition).await);
182 }
183 RouteControllerCommand::RemoveRoute { route_id, reply } => {
184 if restarting
186 .lock()
187 .expect("restarting mutex poisoned") .contains(&route_id)
189 {
190 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
191 "route {} is restarting",
192 route_id
193 ))));
194 continue;
195 }
196 let _ = reply.send(controller.remove_route(&route_id).await);
197 }
198 RouteControllerCommand::SwapPipeline {
199 route_id,
200 pipeline,
201 reply,
202 } => {
203 let _ = reply.send(controller.swap_pipeline(&route_id, pipeline));
204 }
205 RouteControllerCommand::SwapPipelineRaw {
206 route_id,
207 pipeline,
208 lifecycle,
209 reply,
210 } => {
211 let _ =
212 reply.send(controller.swap_pipeline_raw(&route_id, pipeline, lifecycle));
213 }
214 RouteControllerCommand::CompileRouteDefinition { definition, reply } => {
215 let _ = reply.send(controller.compile_route_definition(definition));
216 }
217 RouteControllerCommand::CompileRouteDefinitionWithGeneration {
218 definition,
219 generation,
220 reply,
221 } => {
222 let _ = reply.send(
223 controller.compile_route_definition_with_generation(definition, generation),
224 );
225 }
226 RouteControllerCommand::CompileRouteDefinitionPipeline {
227 definition,
228 generation,
229 reply,
230 } => {
231 let _ = reply
232 .send(controller.compile_route_definition_pipeline(definition, generation));
233 }
234 RouteControllerCommand::CompileRouteDefinitionDryPipeline { definition, reply } => {
235 let _ =
236 reply.send(controller.compile_route_definition_dry_pipeline(definition));
237 }
238 RouteControllerCommand::PrepareRouteDefinitionWithGeneration {
239 definition,
240 generation,
241 reply,
242 } => {
243 let _ = reply.send(
244 controller.prepare_route_definition_with_generation(definition, generation),
245 );
246 }
247 RouteControllerCommand::InsertPreparedRoute { prepared, reply } => {
248 let _ = reply.send(controller.insert_prepared_route(prepared));
249 }
250 RouteControllerCommand::DiscardPreparedStaging { route_id, reply } => {
251 controller.discard_prepared_staging(&route_id);
252 let _ = reply.send(Ok(()));
253 }
254 RouteControllerCommand::RemoveRoutePreservingFunctions { route_id, reply } => {
255 if restarting
257 .lock()
258 .expect("restarting mutex poisoned") .contains(&route_id)
260 {
261 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
262 "route {} is restarting",
263 route_id
264 ))));
265 continue;
266 }
267 let _ = reply.send(
268 controller
269 .remove_route_preserving_functions(&route_id)
270 .await,
271 );
272 }
273 RouteControllerCommand::RouteFromUri { route_id, reply } => {
274 let _ = reply.send(controller.route_from_uri(&route_id));
275 }
276 RouteControllerCommand::SetErrorHandler { config } => {
277 controller.set_error_handler(config);
278 }
279 RouteControllerCommand::SetTracerConfig { config } => {
280 controller.set_tracer_config(&config);
281 }
282 RouteControllerCommand::SetBindExposureAcks { acks } => {
283 controller.set_bind_exposure_acks(acks);
284 }
285 RouteControllerCommand::RouteCount { reply } => {
286 let _ = reply.send(controller.route_count());
287 }
288 RouteControllerCommand::InFlightCount { route_id, reply } => {
289 let _ = reply.send(controller.in_flight_count(&route_id));
290 }
291 RouteControllerCommand::RouteExists { route_id, reply } => {
292 let _ = reply.send(controller.route_exists(&route_id));
293 }
294 RouteControllerCommand::RouteIds { reply } => {
295 let _ = reply.send(controller.route_ids());
296 }
297 RouteControllerCommand::ListEndpoints { reply } => {
298 let _ = reply.send(controller.list_endpoint_uris());
299 }
300 RouteControllerCommand::RoutesForEndpoint { uri, reply } => {
301 let _ = reply.send(controller.routes_for_endpoint(&uri));
302 }
303 RouteControllerCommand::HealthCheckEndpoint { uri, reply } => {
304 let route_ids = controller.routes_for_endpoint(&uri);
305 if route_ids.is_empty() {
306 let _ =
307 reply.send(Err(CamelError::RouteError("endpoint not found".into())));
308 } else {
309 let health_registry = controller.health_registry();
310 tokio::spawn(async move {
314 let futures: Vec<_> = route_ids
315 .iter()
316 .map(|rid| health_registry.check_route(rid))
317 .collect();
318 let results = futures::future::join_all(futures).await;
319 let worst = results.into_iter().fold(
320 camel_api::HealthStatus::Healthy,
321 crate::health_registry::combine_worst,
322 );
323 let _ = reply.send(Ok(worst));
324 });
325 }
326 }
327 RouteControllerCommand::AutoStartupRouteIds { reply } => {
328 let _ = reply.send(controller.auto_startup_route_ids());
329 }
330 RouteControllerCommand::ShutdownRouteIds { reply } => {
331 let _ = reply.send(controller.shutdown_route_ids());
332 }
333 RouteControllerCommand::GetPipeline { route_id, reply } => {
334 let _ = reply.send(controller.get_pipeline(&route_id));
335 }
336 RouteControllerCommand::StartRouteReload { route_id, reply } => {
337 if restarting
339 .lock()
340 .expect("restarting mutex poisoned") .contains(&route_id)
342 {
343 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
344 "route {} is restarting",
345 route_id
346 ))));
347 continue;
348 }
349 let _ = reply.send(controller.start_route_reload(&route_id).await);
350 }
351 RouteControllerCommand::StopRouteReload { route_id, reply } => {
352 if restarting
354 .lock()
355 .expect("restarting mutex poisoned") .contains(&route_id)
357 {
358 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
359 "route {} is restarting",
360 route_id
361 ))));
362 continue;
363 }
364 let _ = reply.send(controller.stop_route_reload(&route_id).await);
365 }
366 RouteControllerCommand::SetRuntimeHandle { runtime } => {
367 controller.set_runtime_handle(runtime);
368 }
369 RouteControllerCommand::SetInterceptRules { rules, reply } => {
370 let _ = reply.send(controller.set_intercept_rules(rules));
371 }
372 RouteControllerCommand::MarkStarted { reply } => {
373 controller.mark_started();
374 let _ = reply.send(Ok(()));
375 }
376 RouteControllerCommand::SetFunctionInvoker { invoker } => {
377 controller.set_function_invoker(invoker);
378 }
379 RouteControllerCommand::RouteSourceHash { route_id, reply } => {
380 let _ = reply.send(controller.route_source_hash(&route_id));
381 }
382 RouteControllerCommand::RouteHasLifecycle { route_id, reply } => {
383 let _ = reply.send(controller.route_has_lifecycle(&route_id));
384 }
385 RouteControllerCommand::Shutdown => {
386 break;
387 }
388 }
389 }
390 });
391 (RouteControllerHandle { tx, cohort }, handle)
392}
393
394pub fn spawn_supervision_task(
395 controller: RouteControllerHandle,
396 config: SupervisionConfig,
397 _metrics: Option<Arc<dyn MetricsCollector>>,
398 mut crash_rx: mpsc::Receiver<CrashNotification>,
399) -> JoinHandle<()> {
400 tokio::spawn(async move {
401 let mut attempts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
402 let mut last_restart_time: std::collections::HashMap<String, Instant> =
403 std::collections::HashMap::new();
404 let mut currently_restarting: std::collections::HashSet<String> =
405 std::collections::HashSet::new();
406
407 debug!("Supervision loop started");
408
409 while let Some(notification) = crash_rx.recv().await {
410 let route_id = notification.route_id;
411 if currently_restarting.contains(&route_id) {
412 continue;
413 }
414
415 if let Some(last_time) = last_restart_time.get(&route_id)
416 && last_time.elapsed() >= config.initial_delay
417 {
418 attempts.insert(route_id.clone(), 0);
419 }
420
421 let current_attempt = attempts.entry(route_id.clone()).or_insert(0);
422 *current_attempt += 1;
423
424 if config
425 .max_attempts
426 .is_some_and(|max| *current_attempt > max)
427 {
428 error!(
430 route_id = %route_id,
431 attempts = *current_attempt,
432 "Route exceeded max restart attempts, giving up"
433 );
434 continue;
435 }
436
437 let delay = config.next_delay(*current_attempt);
438 currently_restarting.insert(route_id.clone());
439 tokio::time::sleep(delay).await;
440
441 match controller.restart_route(route_id.clone()).await {
442 Ok(()) => {
443 info!(route_id = %route_id, "Route restarted successfully");
444 last_restart_time.insert(route_id.clone(), Instant::now());
445 }
446 Err(err) => {
447 error!(route_id = %route_id, error = %err, "Failed to restart route");
449 }
450 }
451
452 currently_restarting.remove(&route_id);
453 }
454
455 debug!("Supervision loop ended");
456 })
457}
458
459#[cfg(test)]
460mod tests {
461 use super::{
462 RouteControllerCommand, RouteControllerHandle, spawn_controller_actor,
463 spawn_supervision_task,
464 };
465 use crate::lifecycle::CohortActivationGate;
466 use crate::lifecycle::adapters::route_controller::DefaultRouteController;
467 use crate::lifecycle::adapters::route_helpers::CrashNotification;
468 use crate::lifecycle::application::route_definition::RouteDefinition;
469 use crate::shared::components::domain::Registry;
470 use crate::shared::observability::domain::TracerConfig;
471 use camel_api::function::PrepareToken;
472 use camel_api::{
473 CamelError, ErrorHandlerConfig, Exchange, ExchangePatch, FunctionDefinition, FunctionDiff,
474 FunctionId, FunctionInvocationError, FunctionInvoker, FunctionInvokerSync, RuntimeCommand,
475 RuntimeCommandBus, RuntimeCommandResult, RuntimeQuery, RuntimeQueryBus, RuntimeQueryResult,
476 SupervisionConfig,
477 };
478 use std::sync::Arc;
479 use std::time::Duration;
480 use tokio::sync::mpsc;
481 use tokio::time::sleep;
482 use tokio::time::timeout;
483
484 fn build_actor_with_components() -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
485 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
486 {
487 let mut guard = registry.lock().expect("lock");
488 guard.register(std::sync::Arc::new(
489 camel_component_timer::TimerComponent::new(),
490 ));
491 guard.register(std::sync::Arc::new(
492 camel_component_mock::MockComponent::new(),
493 ));
494 }
495 let controller = DefaultRouteController::new(
496 Arc::clone(®istry),
497 Arc::new(camel_api::NoopPlatformService::default()),
498 );
499 spawn_controller_actor(controller)
500 }
501
502 fn build_actor_with_components_and_health(
503 health: Arc<crate::health_registry::HealthCheckRegistry>,
504 ) -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
505 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
506 {
507 let mut guard = registry.lock().expect("lock");
508 guard.register(std::sync::Arc::new(
509 camel_component_timer::TimerComponent::new(),
510 ));
511 guard.register(std::sync::Arc::new(
512 camel_component_mock::MockComponent::new(),
513 ));
514 }
515 let mut controller = DefaultRouteController::new(
516 Arc::clone(®istry),
517 Arc::new(camel_api::NoopPlatformService::default()),
518 );
519 controller.set_health_registry(health);
520 spawn_controller_actor(controller)
521 }
522
523 fn build_empty_actor() -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
524 let controller = DefaultRouteController::new(
525 Arc::new(std::sync::Mutex::new(Registry::new())),
526 Arc::new(camel_api::NoopPlatformService::default()),
527 );
528 spawn_controller_actor(controller)
529 }
530
531 fn route_def(route_id: &str, from_uri: &str) -> RouteDefinition {
532 RouteDefinition::new(from_uri, vec![]).with_route_id(route_id)
533 }
534
535 struct NoopRuntime;
536 struct NoopInvoker;
537
538 #[async_trait::async_trait]
539 impl RuntimeCommandBus for NoopRuntime {
540 async fn execute(&self, _cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError> {
541 Ok(RuntimeCommandResult::Accepted)
542 }
543 }
544
545 #[async_trait::async_trait]
546 impl RuntimeQueryBus for NoopRuntime {
547 async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
548 Ok(match query {
549 RuntimeQuery::GetRouteStatus { route_id }
550 | RuntimeQuery::InFlightCount { route_id } => {
551 RuntimeQueryResult::RouteNotFound { route_id }
552 }
553 _ => RuntimeQueryResult::Routes {
555 route_ids: Vec::new(),
556 },
557 })
558 }
559 }
560
561 impl FunctionInvokerSync for NoopInvoker {
562 fn stage_pending(
563 &self,
564 _def: FunctionDefinition,
565 _route_id: Option<&str>,
566 _generation: u64,
567 ) {
568 }
569 fn discard_staging(&self, _generation: u64) {}
570 fn begin_reload(&self) -> u64 {
571 1
572 }
573 fn function_refs_for_route(&self, _route_id: &str) -> Vec<(FunctionId, Option<String>)> {
574 vec![]
575 }
576 fn staged_refs_for_route(
577 &self,
578 _route_id: &str,
579 _generation: u64,
580 ) -> Vec<(FunctionId, Option<String>)> {
581 vec![]
582 }
583 fn staged_defs_for_route(
584 &self,
585 _route_id: &str,
586 _generation: u64,
587 ) -> Vec<(FunctionDefinition, Option<String>)> {
588 vec![]
589 }
590 }
591
592 #[async_trait::async_trait]
593 impl FunctionInvoker for NoopInvoker {
594 async fn register(
595 &self,
596 _def: FunctionDefinition,
597 _route_id: Option<&str>,
598 ) -> Result<(), FunctionInvocationError> {
599 Ok(())
600 }
601 async fn unregister(
602 &self,
603 _id: &FunctionId,
604 _route_id: Option<&str>,
605 ) -> Result<(), FunctionInvocationError> {
606 Ok(())
607 }
608 async fn invoke(
609 &self,
610 _id: &FunctionId,
611 _exchange: &Exchange,
612 ) -> Result<ExchangePatch, FunctionInvocationError> {
613 Ok(ExchangePatch::default())
614 }
615 async fn prepare_reload(
616 &self,
617 _diff: FunctionDiff,
618 _generation: u64,
619 ) -> Result<PrepareToken, FunctionInvocationError> {
620 Ok(PrepareToken::default())
621 }
622 async fn finalize_reload(
623 &self,
624 _diff: &FunctionDiff,
625 _generation: u64,
626 ) -> Result<(), FunctionInvocationError> {
627 Ok(())
628 }
629 async fn rollback_reload(
630 &self,
631 _token: PrepareToken,
632 _generation: u64,
633 ) -> Result<(), FunctionInvocationError> {
634 Ok(())
635 }
636 async fn commit_staged(&self) -> Result<(), FunctionInvocationError> {
637 Ok(())
638 }
639 }
640
641 #[tokio::test]
642 async fn start_route_sends_command_and_returns_reply() {
643 let (tx, mut rx) = mpsc::channel(1);
644 let handle = RouteControllerHandle {
645 tx,
646 cohort: Arc::new(CohortActivationGate::new_closed()),
647 };
648
649 let task = tokio::spawn(async move { handle.start_route("route-a").await });
650
651 let command = timeout(Duration::from_secs(2), rx.recv())
652 .await
653 .expect("command should be received within 2s")
654 .expect("command channel alive");
655 match command {
656 RouteControllerCommand::StartRoute { route_id, reply } => {
657 assert_eq!(route_id, "route-a");
658 let _ = reply.send(Ok(()));
659 }
660 _ => panic!("unexpected command variant"),
661 }
662
663 let result = task.await.expect("join should succeed"); assert!(result.is_ok());
665 }
666
667 #[tokio::test]
668 async fn start_route_returns_error_when_actor_stops() {
669 let (tx, rx) = mpsc::channel(1);
670 drop(rx);
671
672 let handle = RouteControllerHandle {
673 tx,
674 cohort: Arc::new(CohortActivationGate::new_closed()),
675 };
676 let result = handle.start_route("route-a").await;
677
678 assert!(matches!(result, Err(CamelError::ProcessorError(_))));
679 }
680
681 #[tokio::test]
682 async fn spawn_controller_actor_processes_commands_and_shutdown() {
683 let controller = DefaultRouteController::new(
684 Arc::new(std::sync::Mutex::new(Registry::new())),
685 Arc::new(camel_api::NoopPlatformService::default()),
686 );
687 let (handle, join_handle) = spawn_controller_actor(controller);
688
689 assert_eq!(handle.route_count().await.expect("route_count"), 0);
690 assert_eq!(
691 handle.route_ids().await.expect("route_ids"),
692 Vec::<String>::new()
693 );
694
695 handle.shutdown().await.expect("shutdown send");
696 join_handle.await.expect("actor join");
697 }
698
699 #[tokio::test]
700 async fn actor_handle_introspection_and_mutation_commands() {
701 let (handle, join_handle) = build_actor_with_components();
702 let definition = route_def("h-1", "timer:tick?period=100");
703
704 handle.add_route(definition).await.expect("add route");
705 assert!(handle.route_exists("h-1").await.expect("route exists h-1"));
706 assert!(
707 !handle
708 .route_exists("no-such")
709 .await
710 .expect("route exists no-such")
711 );
712
713 let from_uri = handle.route_from_uri("h-1").await.expect("route_from_uri");
714 assert_eq!(from_uri.as_deref(), Some("timer:tick?period=100"));
715 assert_eq!(handle.route_count().await.expect("route_count"), 1);
716
717 let auto_ids = handle
718 .auto_startup_route_ids()
719 .await
720 .expect("auto_startup_route_ids");
721 assert!(auto_ids.iter().any(|id| id == "h-1"));
722
723 let shutdown_ids = handle
724 .shutdown_route_ids()
725 .await
726 .expect("shutdown_route_ids");
727 assert!(shutdown_ids.iter().any(|id| id == "h-1"));
728
729 let compiled = handle
730 .compile_route_definition(route_def("h-1", "timer:tick?period=100"))
731 .await
732 .expect("compile_route_definition");
733
734 assert!(
735 handle
736 .get_pipeline("h-1")
737 .await
738 .expect("get_pipeline")
739 .is_some()
740 );
741 handle
742 .swap_pipeline("h-1", compiled)
743 .await
744 .expect("swap_pipeline");
745
746 let _ = handle
747 .in_flight_count("h-1")
748 .await
749 .expect("in_flight_count");
750 let _ = handle.route_source_hash("h-1").await;
751
752 handle
753 .set_error_handler(ErrorHandlerConfig::dead_letter_channel("log:dlq"))
754 .await
755 .expect("set_error_handler");
756 handle
757 .set_tracer_config(TracerConfig::default())
758 .await
759 .expect("set_tracer_config");
760 handle
761 .set_runtime_handle(Arc::new(NoopRuntime))
762 .await
763 .expect("set_runtime_handle");
764
765 handle.remove_route("h-1").await.expect("remove_route");
766 assert_eq!(
767 handle
768 .route_count()
769 .await
770 .expect("route_count after remove"),
771 0
772 );
773 handle
774 .stop_all_routes()
775 .await
776 .expect("stop_all_routes on empty");
777
778 handle.shutdown().await.expect("shutdown send");
779 join_handle.await.expect("actor join");
780 }
781
782 #[tokio::test]
783 async fn actor_handle_lifecycle_start_stop_restart_suspend_resume() {
784 let (handle, join_handle) = build_actor_with_components();
785 handle
786 .add_route(route_def("lc-1", "timer:tick?period=50"))
787 .await
788 .expect("add route lc-1");
789
790 handle.start_route("lc-1").await.expect("start_route");
791 sleep(Duration::from_millis(20)).await;
792
793 handle.restart_route("lc-1").await.expect("restart_route");
794 sleep(Duration::from_millis(20)).await;
795
796 handle.suspend_route("lc-1").await.expect("suspend_route");
797 handle.resume_route("lc-1").await.expect("resume_route");
798 sleep(Duration::from_millis(20)).await;
799
800 handle.stop_route("lc-1").await.expect("stop_route");
801 handle.start_all_routes().await.expect("start_all_routes");
802 sleep(Duration::from_millis(20)).await;
803 handle.stop_all_routes().await.expect("stop_all_routes");
804
805 handle
806 .start_route_reload("lc-1")
807 .await
808 .expect("start_route_reload");
809 handle
810 .stop_route_reload("lc-1")
811 .await
812 .expect("stop_route_reload");
813
814 handle.shutdown().await.expect("shutdown send");
815 join_handle.await.expect("actor join");
816 }
817
818 #[tokio::test]
825 async fn actor_budgeted_stop_deactivates_route_health() {
826 use crate::health_registry::HealthCheckRegistry;
827 use camel_api::AsyncHealthCheck;
828
829 struct AlwaysUnhealthy;
830
831 #[async_trait::async_trait]
832 impl AsyncHealthCheck for AlwaysUnhealthy {
833 fn name(&self) -> &str {
834 "probe"
835 }
836
837 async fn check(&self) -> camel_api::health::CheckResult {
838 camel_api::health::CheckResult::unhealthy("probe", "boom")
839 }
840 }
841
842 let health = Arc::new(HealthCheckRegistry::new(Duration::from_secs(1)));
843 health.register_for_route("health-1", Arc::new(AlwaysUnhealthy));
844 let (handle, join_handle) = build_actor_with_components_and_health(Arc::clone(&health));
845
846 handle
847 .add_route(route_def("health-1", "timer:tick?period=10"))
848 .await
849 .expect("add route health-1");
850 handle.start_route("health-1").await.expect("start_route");
851 assert_eq!(
852 health.check_all().await.status,
853 camel_api::HealthStatus::Unhealthy,
854 "a started route must report its registered probe"
855 );
856
857 handle
858 .stop_route_with_budget("health-1", Some(Duration::from_millis(50)))
859 .await
860 .expect("budgeted stop through actor");
861
862 let report = health.check_all().await;
863 assert_eq!(
864 report.status,
865 camel_api::HealthStatus::Healthy,
866 "actor budgeted stop must mark the route stopped"
867 );
868 assert!(
869 report.services.is_empty(),
870 "actor budgeted stop must deactivate the route's probes: {report:?}"
871 );
872
873 handle.shutdown().await.expect("shutdown send");
874 join_handle.await.expect("actor join");
875 }
876
877 #[tokio::test]
878 async fn spawn_supervision_restarts_route_on_crash() {
879 let (handle, join_handle) = build_actor_with_components();
880 handle
881 .add_route(route_def("sup-1", "timer:tick?period=100"))
882 .await
883 .expect("add route sup-1");
884 handle
885 .start_route("sup-1")
886 .await
887 .expect("start_route sup-1");
888
889 let (crash_tx, crash_rx) = mpsc::channel(8);
890 let supervision = spawn_supervision_task(
891 handle.clone(),
892 SupervisionConfig {
893 initial_delay: Duration::from_millis(10),
894 max_attempts: Some(2),
895 ..SupervisionConfig::default()
896 },
897 None,
898 crash_rx,
899 );
900
901 crash_tx
902 .send(CrashNotification {
903 route_id: "sup-1".to_string(),
904 error: "simulated".to_string(),
905 })
906 .await
907 .expect("send crash notification");
908
909 sleep(Duration::from_millis(150)).await;
910 drop(crash_tx);
911 supervision.await.expect("supervision join");
912
913 handle.shutdown().await.expect("shutdown send");
914 join_handle.await.expect("actor join");
915 }
916
917 #[tokio::test]
918 async fn supervision_skips_duplicate_and_gives_up_after_max_attempts() {
919 let (handle, join_handle) = build_actor_with_components();
920 handle
921 .add_route(route_def("sup-2", "timer:tick?period=100"))
922 .await
923 .expect("add route sup-2");
924 handle
925 .start_route("sup-2")
926 .await
927 .expect("start_route sup-2");
928
929 let (crash_tx, crash_rx) = mpsc::channel(8);
930 let supervision = spawn_supervision_task(
931 handle.clone(),
932 SupervisionConfig {
933 initial_delay: Duration::from_millis(10),
934 max_attempts: Some(1),
935 ..SupervisionConfig::default()
936 },
937 None,
938 crash_rx,
939 );
940
941 crash_tx
942 .send(CrashNotification {
943 route_id: "sup-2".to_string(),
944 error: "attempt-1".to_string(),
945 })
946 .await
947 .expect("send crash attempt-1");
948 crash_tx
949 .send(CrashNotification {
950 route_id: "sup-2".to_string(),
951 error: "attempt-2".to_string(),
952 })
953 .await
954 .expect("send crash attempt-2");
955
956 sleep(Duration::from_millis(200)).await;
957 drop(crash_tx);
958 supervision.await.expect("supervision join");
959
960 handle.shutdown().await.expect("shutdown send");
961 join_handle.await.expect("actor join");
962 }
963
964 #[tokio::test]
965 async fn try_set_runtime_handle_succeeds_on_fresh_actor() {
966 let (handle, join_handle) = build_empty_actor();
967
968 handle
969 .try_set_runtime_handle(Arc::new(NoopRuntime))
970 .expect("try_set_runtime_handle should succeed");
971
972 handle.shutdown().await.expect("shutdown send");
973 join_handle.await.expect("actor join");
974 }
975
976 #[tokio::test]
977 async fn shutdown_returns_error_when_actor_stopped() {
978 let (tx, rx) = mpsc::channel(1);
979 drop(rx);
980
981 let handle = RouteControllerHandle {
982 tx,
983 cohort: Arc::new(CohortActivationGate::new_closed()),
984 };
985 let result = handle.shutdown().await;
986
987 assert!(matches!(result, Err(CamelError::ProcessorError(_))));
988 }
989
990 #[tokio::test]
991 async fn handle_methods_send_expected_commands_and_receive_replies() {
992 let (tx, mut rx) = mpsc::channel(16);
993 let handle = RouteControllerHandle {
994 tx,
995 cohort: Arc::new(CohortActivationGate::new_closed()),
996 };
997
998 let stop_task = tokio::spawn({
999 let h = handle.clone();
1000 async move { h.stop_route("r-1").await }
1001 });
1002 let cmd = timeout(Duration::from_secs(2), rx.recv())
1003 .await
1004 .expect("stop command within 2s")
1005 .expect("command channel alive");
1006 match cmd {
1007 RouteControllerCommand::StopRoute {
1008 route_id, reply, ..
1009 } => {
1010 assert_eq!(route_id, "r-1");
1011 let _ = reply.send(Ok(()));
1012 }
1013 _ => panic!("unexpected command"),
1014 }
1015 assert!(stop_task.await.expect("join").is_ok());
1016
1017 let exists_task = tokio::spawn({
1018 let h = handle.clone();
1019 async move { h.route_exists("r-2").await }
1020 });
1021 let cmd = timeout(Duration::from_secs(2), rx.recv())
1022 .await
1023 .expect("exists command within 2s")
1024 .expect("command channel alive");
1025 match cmd {
1026 RouteControllerCommand::RouteExists { route_id, reply } => {
1027 assert_eq!(route_id, "r-2");
1028 let _ = reply.send(true);
1029 }
1030 _ => panic!("unexpected command"),
1031 }
1032 assert!(exists_task.await.expect("join").expect("ok"));
1033
1034 let hash_task = tokio::spawn({
1035 let h = handle.clone();
1036 async move { h.route_source_hash("r-3").await }
1037 });
1038 let cmd = timeout(Duration::from_secs(2), rx.recv())
1039 .await
1040 .expect("hash command within 2s")
1041 .expect("command channel alive");
1042 match cmd {
1043 RouteControllerCommand::RouteSourceHash { route_id, reply } => {
1044 assert_eq!(route_id, "r-3");
1045 let _ = reply.send(Some(77));
1046 }
1047 _ => panic!("unexpected command"),
1048 }
1049 assert_eq!(hash_task.await.expect("join"), Some(77));
1050 }
1051
1052 #[tokio::test]
1053 async fn handle_methods_error_on_dropped_reply_channel() {
1054 let (tx, mut rx) = mpsc::channel(16);
1055 let handle = RouteControllerHandle {
1056 tx,
1057 cohort: Arc::new(CohortActivationGate::new_closed()),
1058 };
1059
1060 let count_task = tokio::spawn({
1061 let h = handle.clone();
1062 async move { h.route_count().await }
1063 });
1064 let cmd = timeout(Duration::from_secs(2), rx.recv())
1065 .await
1066 .expect("route_count command within 2s")
1067 .expect("command channel alive");
1068 match cmd {
1069 RouteControllerCommand::RouteCount { reply } => drop(reply),
1070 _ => panic!("unexpected command"),
1071 }
1072 assert!(matches!(
1073 count_task.await.expect("join"),
1074 Err(CamelError::ProcessorError(_))
1075 ));
1076
1077 let stop_task = tokio::spawn({
1078 let h = handle.clone();
1079 async move { h.stop_route("x").await }
1080 });
1081 let cmd = timeout(Duration::from_secs(2), rx.recv())
1082 .await
1083 .expect("stop command within 2s")
1084 .expect("command channel alive");
1085 match cmd {
1086 RouteControllerCommand::StopRoute { reply, .. } => drop(reply),
1087 _ => panic!("unexpected command"),
1088 }
1089 assert!(matches!(
1090 stop_task.await.expect("join"),
1091 Err(CamelError::ProcessorError(_))
1092 ));
1093
1094 let maybe_hash = tokio::spawn({
1095 let h = handle.clone();
1096 async move { h.route_source_hash("x").await }
1097 });
1098 let cmd = timeout(Duration::from_secs(2), rx.recv())
1099 .await
1100 .expect("hash command within 2s")
1101 .expect("command channel alive");
1102 match cmd {
1103 RouteControllerCommand::RouteSourceHash { reply, .. } => drop(reply),
1104 _ => panic!("unexpected command"),
1105 }
1106 assert_eq!(maybe_hash.await.expect("join"), None);
1107 }
1108
1109 #[test]
1110 fn try_set_function_invoker_returns_mailbox_full() {
1111 let (tx, mut rx) = mpsc::channel(1);
1112 tx.try_send(RouteControllerCommand::Shutdown)
1113 .expect("fill mailbox");
1114 let handle = RouteControllerHandle {
1115 tx,
1116 cohort: Arc::new(CohortActivationGate::new_closed()),
1117 };
1118
1119 let result = handle.try_set_function_invoker(Arc::new(NoopInvoker));
1120 assert!(matches!(result, Err(CamelError::ProcessorError(_))));
1121
1122 rx.try_recv().expect("mailbox still has first message");
1123 }
1124
1125 #[tokio::test]
1126 async fn test_restart_does_not_block_other_routes() {
1127 let (handle, join_handle) = build_actor_with_components();
1131 handle
1132 .add_route(route_def("route-a", "timer:tick?period=100"))
1133 .await
1134 .expect("add route-a");
1135 handle
1136 .add_route(route_def("route-b", "timer:tick?period=100"))
1137 .await
1138 .expect("add route-b");
1139 handle
1140 .add_route(route_def("route-c", "timer:tick?period=100"))
1141 .await
1142 .expect("add route-c");
1143
1144 handle.start_route("route-a").await.expect("start route-a");
1145 handle.start_route("route-b").await.expect("start route-b");
1146
1147 let restart_handle = handle.clone();
1150 let restart_fut =
1151 tokio::spawn(async move { restart_handle.restart_route("route-a").await });
1152
1153 tokio::task::yield_now().await;
1155
1156 let start_c_t0 = std::time::Instant::now();
1158 let start_result = handle.start_route("route-c").await;
1159 let start_c_elapsed = start_c_t0.elapsed();
1160
1161 assert!(start_result.is_ok(), "route-c start should succeed");
1162 assert!(
1163 start_c_elapsed < Duration::from_millis(80),
1164 "route-c start blocked by route-a restart (took {start_c_elapsed:?}); \
1165 expected to complete before restart's 100ms sleep"
1166 );
1167
1168 let restart_result = restart_fut.await.expect("restart join"); assert!(restart_result.is_ok(), "route-a restart should succeed");
1170
1171 handle.shutdown().await.expect("shutdown send");
1172 join_handle.await.expect("actor join");
1173 }
1174
1175 #[tokio::test]
1176 async fn test_command_to_restarting_route_is_rejected() {
1177 let (handle, join_handle) = build_actor_with_components();
1180 handle
1181 .add_route(route_def("route-a", "timer:tick?period=100"))
1182 .await
1183 .expect("add route-a");
1184 handle.start_route("route-a").await.expect("start route-a");
1185
1186 let restart_handle = handle.clone();
1189 let restart_fut =
1190 tokio::spawn(async move { restart_handle.restart_route("route-a").await });
1191
1192 tokio::time::sleep(Duration::from_millis(20)).await;
1196
1197 let stop_result = handle.stop_route("route-a").await;
1199 assert!(
1200 stop_result.is_err(),
1201 "stop during restart should be rejected"
1202 );
1203 assert!(
1204 stop_result.unwrap_err().to_string().contains("restarting"),
1205 "error should mention restarting"
1206 );
1207
1208 let restart_result = restart_fut.await.expect("restart join"); assert!(restart_result.is_ok(), "route-a restart should succeed");
1211
1212 handle.shutdown().await.expect("shutdown send");
1213 join_handle.await.expect("actor join");
1214 }
1215}