1use std::collections::HashSet;
7use std::sync::{Arc, Mutex};
8use std::time::Instant;
9
10use camel_api::{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::RouteCount { reply } => {
271 let _ = reply.send(controller.route_count());
272 }
273 RouteControllerCommand::InFlightCount { route_id, reply } => {
274 let _ = reply.send(controller.in_flight_count(&route_id));
275 }
276 RouteControllerCommand::RouteExists { route_id, reply } => {
277 let _ = reply.send(controller.route_exists(&route_id));
278 }
279 RouteControllerCommand::RouteIds { reply } => {
280 let _ = reply.send(controller.route_ids());
281 }
282 RouteControllerCommand::AutoStartupRouteIds { reply } => {
283 let _ = reply.send(controller.auto_startup_route_ids());
284 }
285 RouteControllerCommand::ShutdownRouteIds { reply } => {
286 let _ = reply.send(controller.shutdown_route_ids());
287 }
288 RouteControllerCommand::GetPipeline { route_id, reply } => {
289 let _ = reply.send(controller.get_pipeline(&route_id));
290 }
291 RouteControllerCommand::StartRouteReload { route_id, reply } => {
292 if restarting
294 .lock()
295 .expect("restarting mutex poisoned") .contains(&route_id)
297 {
298 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
299 "route {} is restarting",
300 route_id
301 ))));
302 continue;
303 }
304 let _ = reply.send(controller.start_route_reload(&route_id).await);
305 }
306 RouteControllerCommand::StopRouteReload { route_id, reply } => {
307 if restarting
309 .lock()
310 .expect("restarting mutex poisoned") .contains(&route_id)
312 {
313 let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
314 "route {} is restarting",
315 route_id
316 ))));
317 continue;
318 }
319 let _ = reply.send(controller.stop_route_reload(&route_id).await);
320 }
321 RouteControllerCommand::SetRuntimeHandle { runtime } => {
322 controller.set_runtime_handle(runtime);
323 }
324 RouteControllerCommand::SetFunctionInvoker { invoker } => {
325 controller.set_function_invoker(invoker);
326 }
327 RouteControllerCommand::RouteSourceHash { route_id, reply } => {
328 let _ = reply.send(controller.route_source_hash(&route_id));
329 }
330 RouteControllerCommand::RouteHasLifecycle { route_id, reply } => {
331 let _ = reply.send(controller.route_has_lifecycle(&route_id));
332 }
333 RouteControllerCommand::Shutdown => {
334 break;
335 }
336 }
337 }
338 });
339 (RouteControllerHandle { tx }, handle)
340}
341
342pub fn spawn_supervision_task(
343 controller: RouteControllerHandle,
344 config: SupervisionConfig,
345 _metrics: Option<Arc<dyn MetricsCollector>>,
346 mut crash_rx: mpsc::Receiver<CrashNotification>,
347) -> JoinHandle<()> {
348 tokio::spawn(async move {
349 let mut attempts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
350 let mut last_restart_time: std::collections::HashMap<String, Instant> =
351 std::collections::HashMap::new();
352 let mut currently_restarting: std::collections::HashSet<String> =
353 std::collections::HashSet::new();
354
355 debug!("Supervision loop started");
356
357 while let Some(notification) = crash_rx.recv().await {
358 let route_id = notification.route_id;
359 if currently_restarting.contains(&route_id) {
360 continue;
361 }
362
363 if let Some(last_time) = last_restart_time.get(&route_id)
364 && last_time.elapsed() >= config.initial_delay
365 {
366 attempts.insert(route_id.clone(), 0);
367 }
368
369 let current_attempt = attempts.entry(route_id.clone()).or_insert(0);
370 *current_attempt += 1;
371
372 if config
373 .max_attempts
374 .is_some_and(|max| *current_attempt > max)
375 {
376 error!(
378 route_id = %route_id,
379 attempts = *current_attempt,
380 "Route exceeded max restart attempts, giving up"
381 );
382 continue;
383 }
384
385 let delay = config.next_delay(*current_attempt);
386 currently_restarting.insert(route_id.clone());
387 tokio::time::sleep(delay).await;
388
389 match controller.restart_route(route_id.clone()).await {
390 Ok(()) => {
391 info!(route_id = %route_id, "Route restarted successfully");
392 last_restart_time.insert(route_id.clone(), Instant::now());
393 }
394 Err(err) => {
395 error!(route_id = %route_id, error = %err, "Failed to restart route");
397 }
398 }
399
400 currently_restarting.remove(&route_id);
401 }
402
403 debug!("Supervision loop ended");
404 })
405}
406
407#[cfg(test)]
408mod tests {
409 use super::{
410 RouteControllerCommand, RouteControllerHandle, spawn_controller_actor,
411 spawn_supervision_task,
412 };
413 use crate::lifecycle::adapters::route_controller::DefaultRouteController;
414 use crate::lifecycle::adapters::route_helpers::CrashNotification;
415 use crate::lifecycle::application::route_definition::RouteDefinition;
416 use crate::shared::components::domain::Registry;
417 use crate::shared::observability::domain::TracerConfig;
418 use camel_api::function::PrepareToken;
419 use camel_api::{
420 CamelError, ErrorHandlerConfig, Exchange, ExchangePatch, FunctionDefinition, FunctionDiff,
421 FunctionId, FunctionInvocationError, FunctionInvoker, FunctionInvokerSync, RuntimeCommand,
422 RuntimeCommandBus, RuntimeCommandResult, RuntimeQuery, RuntimeQueryBus, RuntimeQueryResult,
423 SupervisionConfig,
424 };
425 use std::sync::Arc;
426 use std::time::Duration;
427 use tokio::sync::mpsc;
428 use tokio::time::sleep;
429
430 fn build_actor_with_components() -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
431 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
432 {
433 let mut guard = registry.lock().expect("lock");
434 guard.register(std::sync::Arc::new(
435 camel_component_timer::TimerComponent::new(),
436 ));
437 guard.register(std::sync::Arc::new(
438 camel_component_mock::MockComponent::new(),
439 ));
440 }
441 let controller = DefaultRouteController::new(
442 Arc::clone(®istry),
443 Arc::new(camel_api::NoopPlatformService::default()),
444 );
445 spawn_controller_actor(controller)
446 }
447
448 fn build_empty_actor() -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
449 let controller = DefaultRouteController::new(
450 Arc::new(std::sync::Mutex::new(Registry::new())),
451 Arc::new(camel_api::NoopPlatformService::default()),
452 );
453 spawn_controller_actor(controller)
454 }
455
456 fn route_def(route_id: &str, from_uri: &str) -> RouteDefinition {
457 RouteDefinition::new(from_uri, vec![]).with_route_id(route_id)
458 }
459
460 struct NoopRuntime;
461 struct NoopInvoker;
462
463 #[async_trait::async_trait]
464 impl RuntimeCommandBus for NoopRuntime {
465 async fn execute(&self, _cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError> {
466 Ok(RuntimeCommandResult::Accepted)
467 }
468 }
469
470 #[async_trait::async_trait]
471 impl RuntimeQueryBus for NoopRuntime {
472 async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
473 Ok(match query {
474 RuntimeQuery::GetRouteStatus { route_id }
475 | RuntimeQuery::InFlightCount { route_id } => {
476 RuntimeQueryResult::RouteNotFound { route_id }
477 }
478 RuntimeQuery::ListRoutes => RuntimeQueryResult::Routes {
479 route_ids: Vec::new(),
480 },
481 })
482 }
483 }
484
485 impl FunctionInvokerSync for NoopInvoker {
486 fn stage_pending(
487 &self,
488 _def: FunctionDefinition,
489 _route_id: Option<&str>,
490 _generation: u64,
491 ) {
492 }
493 fn discard_staging(&self, _generation: u64) {}
494 fn begin_reload(&self) -> u64 {
495 1
496 }
497 fn function_refs_for_route(&self, _route_id: &str) -> Vec<(FunctionId, Option<String>)> {
498 vec![]
499 }
500 fn staged_refs_for_route(
501 &self,
502 _route_id: &str,
503 _generation: u64,
504 ) -> Vec<(FunctionId, Option<String>)> {
505 vec![]
506 }
507 fn staged_defs_for_route(
508 &self,
509 _route_id: &str,
510 _generation: u64,
511 ) -> Vec<(FunctionDefinition, Option<String>)> {
512 vec![]
513 }
514 }
515
516 #[async_trait::async_trait]
517 impl FunctionInvoker for NoopInvoker {
518 async fn register(
519 &self,
520 _def: FunctionDefinition,
521 _route_id: Option<&str>,
522 ) -> Result<(), FunctionInvocationError> {
523 Ok(())
524 }
525 async fn unregister(
526 &self,
527 _id: &FunctionId,
528 _route_id: Option<&str>,
529 ) -> Result<(), FunctionInvocationError> {
530 Ok(())
531 }
532 async fn invoke(
533 &self,
534 _id: &FunctionId,
535 _exchange: &Exchange,
536 ) -> Result<ExchangePatch, FunctionInvocationError> {
537 Ok(ExchangePatch::default())
538 }
539 async fn prepare_reload(
540 &self,
541 _diff: FunctionDiff,
542 _generation: u64,
543 ) -> Result<PrepareToken, FunctionInvocationError> {
544 Ok(PrepareToken::default())
545 }
546 async fn finalize_reload(
547 &self,
548 _diff: &FunctionDiff,
549 _generation: u64,
550 ) -> Result<(), FunctionInvocationError> {
551 Ok(())
552 }
553 async fn rollback_reload(
554 &self,
555 _token: PrepareToken,
556 _generation: u64,
557 ) -> Result<(), FunctionInvocationError> {
558 Ok(())
559 }
560 async fn commit_staged(&self) -> Result<(), FunctionInvocationError> {
561 Ok(())
562 }
563 }
564
565 #[tokio::test]
566 async fn start_route_sends_command_and_returns_reply() {
567 let (tx, mut rx) = mpsc::channel(1);
568 let handle = RouteControllerHandle { tx };
569
570 let task = tokio::spawn(async move { handle.start_route("route-a").await });
571
572 let command = rx.recv().await.expect("command should be received");
573 match command {
574 RouteControllerCommand::StartRoute { route_id, reply } => {
575 assert_eq!(route_id, "route-a");
576 let _ = reply.send(Ok(()));
577 }
578 _ => panic!("unexpected command variant"),
579 }
580
581 let result = task.await.expect("join should succeed");
582 assert!(result.is_ok());
583 }
584
585 #[tokio::test]
586 async fn start_route_returns_error_when_actor_stops() {
587 let (tx, rx) = mpsc::channel(1);
588 drop(rx);
589
590 let handle = RouteControllerHandle { tx };
591 let result = handle.start_route("route-a").await;
592
593 assert!(matches!(result, Err(CamelError::ProcessorError(_))));
594 }
595
596 #[tokio::test]
597 async fn spawn_controller_actor_processes_commands_and_shutdown() {
598 let controller = DefaultRouteController::new(
599 Arc::new(std::sync::Mutex::new(Registry::new())),
600 Arc::new(camel_api::NoopPlatformService::default()),
601 );
602 let (handle, join_handle) = spawn_controller_actor(controller);
603
604 assert_eq!(handle.route_count().await.expect("route_count"), 0);
605 assert_eq!(
606 handle.route_ids().await.expect("route_ids"),
607 Vec::<String>::new()
608 );
609
610 handle.shutdown().await.expect("shutdown send");
611 join_handle.await.expect("actor join");
612 }
613
614 #[tokio::test]
615 async fn actor_handle_introspection_and_mutation_commands() {
616 let (handle, join_handle) = build_actor_with_components();
617 let definition = route_def("h-1", "timer:tick?period=100");
618
619 handle.add_route(definition).await.expect("add route");
620 assert!(handle.route_exists("h-1").await.expect("route exists h-1"));
621 assert!(
622 !handle
623 .route_exists("no-such")
624 .await
625 .expect("route exists no-such")
626 );
627
628 let from_uri = handle.route_from_uri("h-1").await.expect("route_from_uri");
629 assert_eq!(from_uri.as_deref(), Some("timer:tick?period=100"));
630 assert_eq!(handle.route_count().await.expect("route_count"), 1);
631
632 let auto_ids = handle
633 .auto_startup_route_ids()
634 .await
635 .expect("auto_startup_route_ids");
636 assert!(auto_ids.iter().any(|id| id == "h-1"));
637
638 let shutdown_ids = handle
639 .shutdown_route_ids()
640 .await
641 .expect("shutdown_route_ids");
642 assert!(shutdown_ids.iter().any(|id| id == "h-1"));
643
644 let compiled = handle
645 .compile_route_definition(route_def("h-1", "timer:tick?period=100"))
646 .await
647 .expect("compile_route_definition");
648
649 assert!(
650 handle
651 .get_pipeline("h-1")
652 .await
653 .expect("get_pipeline")
654 .is_some()
655 );
656 handle
657 .swap_pipeline("h-1", compiled)
658 .await
659 .expect("swap_pipeline");
660
661 let _ = handle
662 .in_flight_count("h-1")
663 .await
664 .expect("in_flight_count");
665 let _ = handle.route_source_hash("h-1").await;
666
667 handle
668 .set_error_handler(ErrorHandlerConfig::dead_letter_channel("log:dlq"))
669 .await
670 .expect("set_error_handler");
671 handle
672 .set_tracer_config(TracerConfig::default())
673 .await
674 .expect("set_tracer_config");
675 handle
676 .set_runtime_handle(Arc::new(NoopRuntime))
677 .await
678 .expect("set_runtime_handle");
679
680 handle.remove_route("h-1").await.expect("remove_route");
681 assert_eq!(
682 handle
683 .route_count()
684 .await
685 .expect("route_count after remove"),
686 0
687 );
688 handle
689 .stop_all_routes()
690 .await
691 .expect("stop_all_routes on empty");
692
693 handle.shutdown().await.expect("shutdown send");
694 join_handle.await.expect("actor join");
695 }
696
697 #[tokio::test]
698 async fn actor_handle_lifecycle_start_stop_restart_suspend_resume() {
699 let (handle, join_handle) = build_actor_with_components();
700 handle
701 .add_route(route_def("lc-1", "timer:tick?period=50"))
702 .await
703 .expect("add route lc-1");
704
705 handle.start_route("lc-1").await.expect("start_route");
706 sleep(Duration::from_millis(20)).await;
707
708 handle.restart_route("lc-1").await.expect("restart_route");
709 sleep(Duration::from_millis(20)).await;
710
711 handle.suspend_route("lc-1").await.expect("suspend_route");
712 handle.resume_route("lc-1").await.expect("resume_route");
713 sleep(Duration::from_millis(20)).await;
714
715 handle.stop_route("lc-1").await.expect("stop_route");
716 handle.start_all_routes().await.expect("start_all_routes");
717 sleep(Duration::from_millis(20)).await;
718 handle.stop_all_routes().await.expect("stop_all_routes");
719
720 handle
721 .start_route_reload("lc-1")
722 .await
723 .expect("start_route_reload");
724 handle
725 .stop_route_reload("lc-1")
726 .await
727 .expect("stop_route_reload");
728
729 handle.shutdown().await.expect("shutdown send");
730 join_handle.await.expect("actor join");
731 }
732
733 #[tokio::test]
734 async fn spawn_supervision_restarts_route_on_crash() {
735 let (handle, join_handle) = build_actor_with_components();
736 handle
737 .add_route(route_def("sup-1", "timer:tick?period=100"))
738 .await
739 .expect("add route sup-1");
740 handle
741 .start_route("sup-1")
742 .await
743 .expect("start_route sup-1");
744
745 let (crash_tx, crash_rx) = mpsc::channel(8);
746 let supervision = spawn_supervision_task(
747 handle.clone(),
748 SupervisionConfig {
749 initial_delay: Duration::from_millis(10),
750 max_attempts: Some(2),
751 ..SupervisionConfig::default()
752 },
753 None,
754 crash_rx,
755 );
756
757 crash_tx
758 .send(CrashNotification {
759 route_id: "sup-1".to_string(),
760 error: "simulated".to_string(),
761 })
762 .await
763 .expect("send crash notification");
764
765 sleep(Duration::from_millis(150)).await;
766 drop(crash_tx);
767 supervision.await.expect("supervision join");
768
769 handle.shutdown().await.expect("shutdown send");
770 join_handle.await.expect("actor join");
771 }
772
773 #[tokio::test]
774 async fn supervision_skips_duplicate_and_gives_up_after_max_attempts() {
775 let (handle, join_handle) = build_actor_with_components();
776 handle
777 .add_route(route_def("sup-2", "timer:tick?period=100"))
778 .await
779 .expect("add route sup-2");
780 handle
781 .start_route("sup-2")
782 .await
783 .expect("start_route sup-2");
784
785 let (crash_tx, crash_rx) = mpsc::channel(8);
786 let supervision = spawn_supervision_task(
787 handle.clone(),
788 SupervisionConfig {
789 initial_delay: Duration::from_millis(10),
790 max_attempts: Some(1),
791 ..SupervisionConfig::default()
792 },
793 None,
794 crash_rx,
795 );
796
797 crash_tx
798 .send(CrashNotification {
799 route_id: "sup-2".to_string(),
800 error: "attempt-1".to_string(),
801 })
802 .await
803 .expect("send crash attempt-1");
804 crash_tx
805 .send(CrashNotification {
806 route_id: "sup-2".to_string(),
807 error: "attempt-2".to_string(),
808 })
809 .await
810 .expect("send crash attempt-2");
811
812 sleep(Duration::from_millis(200)).await;
813 drop(crash_tx);
814 supervision.await.expect("supervision join");
815
816 handle.shutdown().await.expect("shutdown send");
817 join_handle.await.expect("actor join");
818 }
819
820 #[tokio::test]
821 async fn try_set_runtime_handle_succeeds_on_fresh_actor() {
822 let (handle, join_handle) = build_empty_actor();
823
824 handle
825 .try_set_runtime_handle(Arc::new(NoopRuntime))
826 .expect("try_set_runtime_handle should succeed");
827
828 handle.shutdown().await.expect("shutdown send");
829 join_handle.await.expect("actor join");
830 }
831
832 #[tokio::test]
833 async fn shutdown_returns_error_when_actor_stopped() {
834 let (tx, rx) = mpsc::channel(1);
835 drop(rx);
836
837 let handle = RouteControllerHandle { tx };
838 let result = handle.shutdown().await;
839
840 assert!(matches!(result, Err(CamelError::ProcessorError(_))));
841 }
842
843 #[tokio::test]
844 async fn handle_methods_send_expected_commands_and_receive_replies() {
845 let (tx, mut rx) = mpsc::channel(16);
846 let handle = RouteControllerHandle { tx };
847
848 let stop_task = tokio::spawn({
849 let h = handle.clone();
850 async move { h.stop_route("r-1").await }
851 });
852 let cmd = rx.recv().await.expect("stop command");
853 match cmd {
854 RouteControllerCommand::StopRoute { route_id, reply } => {
855 assert_eq!(route_id, "r-1");
856 let _ = reply.send(Ok(()));
857 }
858 _ => panic!("unexpected command"),
859 }
860 assert!(stop_task.await.expect("join").is_ok());
861
862 let exists_task = tokio::spawn({
863 let h = handle.clone();
864 async move { h.route_exists("r-2").await }
865 });
866 let cmd = rx.recv().await.expect("exists command");
867 match cmd {
868 RouteControllerCommand::RouteExists { route_id, reply } => {
869 assert_eq!(route_id, "r-2");
870 let _ = reply.send(true);
871 }
872 _ => panic!("unexpected command"),
873 }
874 assert!(exists_task.await.expect("join").expect("ok"));
875
876 let hash_task = tokio::spawn({
877 let h = handle.clone();
878 async move { h.route_source_hash("r-3").await }
879 });
880 let cmd = rx.recv().await.expect("hash command");
881 match cmd {
882 RouteControllerCommand::RouteSourceHash { route_id, reply } => {
883 assert_eq!(route_id, "r-3");
884 let _ = reply.send(Some(77));
885 }
886 _ => panic!("unexpected command"),
887 }
888 assert_eq!(hash_task.await.expect("join"), Some(77));
889 }
890
891 #[tokio::test]
892 async fn handle_methods_error_on_dropped_reply_channel() {
893 let (tx, mut rx) = mpsc::channel(16);
894 let handle = RouteControllerHandle { tx };
895
896 let count_task = tokio::spawn({
897 let h = handle.clone();
898 async move { h.route_count().await }
899 });
900 let cmd = rx.recv().await.expect("route_count command");
901 match cmd {
902 RouteControllerCommand::RouteCount { reply } => drop(reply),
903 _ => panic!("unexpected command"),
904 }
905 assert!(matches!(
906 count_task.await.expect("join"),
907 Err(CamelError::ProcessorError(_))
908 ));
909
910 let stop_task = tokio::spawn({
911 let h = handle.clone();
912 async move { h.stop_route("x").await }
913 });
914 let cmd = rx.recv().await.expect("stop command");
915 match cmd {
916 RouteControllerCommand::StopRoute { reply, .. } => drop(reply),
917 _ => panic!("unexpected command"),
918 }
919 assert!(matches!(
920 stop_task.await.expect("join"),
921 Err(CamelError::ProcessorError(_))
922 ));
923
924 let maybe_hash = tokio::spawn({
925 let h = handle.clone();
926 async move { h.route_source_hash("x").await }
927 });
928 let cmd = rx.recv().await.expect("hash command");
929 match cmd {
930 RouteControllerCommand::RouteSourceHash { reply, .. } => drop(reply),
931 _ => panic!("unexpected command"),
932 }
933 assert_eq!(maybe_hash.await.expect("join"), None);
934 }
935
936 #[test]
937 fn try_set_function_invoker_returns_mailbox_full() {
938 let (tx, mut rx) = mpsc::channel(1);
939 tx.try_send(RouteControllerCommand::Shutdown)
940 .expect("fill mailbox");
941 let handle = RouteControllerHandle { tx };
942
943 let result = handle.try_set_function_invoker(Arc::new(NoopInvoker));
944 assert!(matches!(result, Err(CamelError::ProcessorError(_))));
945
946 rx.try_recv().expect("mailbox still has first message");
947 }
948
949 #[tokio::test]
950 async fn test_restart_does_not_block_other_routes() {
951 let (handle, join_handle) = build_actor_with_components();
955 handle
956 .add_route(route_def("route-a", "timer:tick?period=100"))
957 .await
958 .expect("add route-a");
959 handle
960 .add_route(route_def("route-b", "timer:tick?period=100"))
961 .await
962 .expect("add route-b");
963 handle
964 .add_route(route_def("route-c", "timer:tick?period=100"))
965 .await
966 .expect("add route-c");
967
968 handle.start_route("route-a").await.expect("start route-a");
969 handle.start_route("route-b").await.expect("start route-b");
970
971 let restart_handle = handle.clone();
974 let restart_fut =
975 tokio::spawn(async move { restart_handle.restart_route("route-a").await });
976
977 tokio::task::yield_now().await;
979
980 let start_c_t0 = std::time::Instant::now();
982 let start_result = handle.start_route("route-c").await;
983 let start_c_elapsed = start_c_t0.elapsed();
984
985 assert!(start_result.is_ok(), "route-c start should succeed");
986 assert!(
987 start_c_elapsed < Duration::from_millis(80),
988 "route-c start blocked by route-a restart (took {start_c_elapsed:?}); \
989 expected to complete before restart's 100ms sleep"
990 );
991
992 let restart_result = restart_fut.await.expect("restart join");
993 assert!(restart_result.is_ok(), "route-a restart should succeed");
994
995 handle.shutdown().await.expect("shutdown send");
996 join_handle.await.expect("actor join");
997 }
998
999 #[tokio::test]
1000 async fn test_command_to_restarting_route_is_rejected() {
1001 let (handle, join_handle) = build_actor_with_components();
1004 handle
1005 .add_route(route_def("route-a", "timer:tick?period=100"))
1006 .await
1007 .expect("add route-a");
1008 handle.start_route("route-a").await.expect("start route-a");
1009
1010 let restart_handle = handle.clone();
1013 let restart_fut =
1014 tokio::spawn(async move { restart_handle.restart_route("route-a").await });
1015
1016 tokio::time::sleep(Duration::from_millis(20)).await;
1020
1021 let stop_result = handle.stop_route("route-a").await;
1023 assert!(
1024 stop_result.is_err(),
1025 "stop during restart should be rejected"
1026 );
1027 assert!(
1028 stop_result.unwrap_err().to_string().contains("restarting"),
1029 "error should mention restarting"
1030 );
1031
1032 let restart_result = restart_fut.await.expect("restart join");
1034 assert!(restart_result.is_ok(), "route-a restart should succeed");
1035
1036 handle.shutdown().await.expect("shutdown send");
1037 join_handle.await.expect("actor join");
1038 }
1039}