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