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