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