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