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 use tokio::time::timeout;
475
476 fn build_actor_with_components() -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
477 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
478 {
479 let mut guard = registry.lock().expect("lock");
480 guard.register(std::sync::Arc::new(
481 camel_component_timer::TimerComponent::new(),
482 ));
483 guard.register(std::sync::Arc::new(
484 camel_component_mock::MockComponent::new(),
485 ));
486 }
487 let controller = DefaultRouteController::new(
488 Arc::clone(®istry),
489 Arc::new(camel_api::NoopPlatformService::default()),
490 );
491 spawn_controller_actor(controller)
492 }
493
494 fn build_empty_actor() -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
495 let controller = DefaultRouteController::new(
496 Arc::new(std::sync::Mutex::new(Registry::new())),
497 Arc::new(camel_api::NoopPlatformService::default()),
498 );
499 spawn_controller_actor(controller)
500 }
501
502 fn route_def(route_id: &str, from_uri: &str) -> RouteDefinition {
503 RouteDefinition::new(from_uri, vec![]).with_route_id(route_id)
504 }
505
506 struct NoopRuntime;
507 struct NoopInvoker;
508
509 #[async_trait::async_trait]
510 impl RuntimeCommandBus for NoopRuntime {
511 async fn execute(&self, _cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError> {
512 Ok(RuntimeCommandResult::Accepted)
513 }
514 }
515
516 #[async_trait::async_trait]
517 impl RuntimeQueryBus for NoopRuntime {
518 async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
519 Ok(match query {
520 RuntimeQuery::GetRouteStatus { route_id }
521 | RuntimeQuery::InFlightCount { route_id } => {
522 RuntimeQueryResult::RouteNotFound { route_id }
523 }
524 _ => RuntimeQueryResult::Routes {
526 route_ids: Vec::new(),
527 },
528 })
529 }
530 }
531
532 impl FunctionInvokerSync for NoopInvoker {
533 fn stage_pending(
534 &self,
535 _def: FunctionDefinition,
536 _route_id: Option<&str>,
537 _generation: u64,
538 ) {
539 }
540 fn discard_staging(&self, _generation: u64) {}
541 fn begin_reload(&self) -> u64 {
542 1
543 }
544 fn function_refs_for_route(&self, _route_id: &str) -> Vec<(FunctionId, Option<String>)> {
545 vec![]
546 }
547 fn staged_refs_for_route(
548 &self,
549 _route_id: &str,
550 _generation: u64,
551 ) -> Vec<(FunctionId, Option<String>)> {
552 vec![]
553 }
554 fn staged_defs_for_route(
555 &self,
556 _route_id: &str,
557 _generation: u64,
558 ) -> Vec<(FunctionDefinition, Option<String>)> {
559 vec![]
560 }
561 }
562
563 #[async_trait::async_trait]
564 impl FunctionInvoker for NoopInvoker {
565 async fn register(
566 &self,
567 _def: FunctionDefinition,
568 _route_id: Option<&str>,
569 ) -> Result<(), FunctionInvocationError> {
570 Ok(())
571 }
572 async fn unregister(
573 &self,
574 _id: &FunctionId,
575 _route_id: Option<&str>,
576 ) -> Result<(), FunctionInvocationError> {
577 Ok(())
578 }
579 async fn invoke(
580 &self,
581 _id: &FunctionId,
582 _exchange: &Exchange,
583 ) -> Result<ExchangePatch, FunctionInvocationError> {
584 Ok(ExchangePatch::default())
585 }
586 async fn prepare_reload(
587 &self,
588 _diff: FunctionDiff,
589 _generation: u64,
590 ) -> Result<PrepareToken, FunctionInvocationError> {
591 Ok(PrepareToken::default())
592 }
593 async fn finalize_reload(
594 &self,
595 _diff: &FunctionDiff,
596 _generation: u64,
597 ) -> Result<(), FunctionInvocationError> {
598 Ok(())
599 }
600 async fn rollback_reload(
601 &self,
602 _token: PrepareToken,
603 _generation: u64,
604 ) -> Result<(), FunctionInvocationError> {
605 Ok(())
606 }
607 async fn commit_staged(&self) -> Result<(), FunctionInvocationError> {
608 Ok(())
609 }
610 }
611
612 #[tokio::test]
613 async fn start_route_sends_command_and_returns_reply() {
614 let (tx, mut rx) = mpsc::channel(1);
615 let handle = RouteControllerHandle {
616 tx,
617 cohort: Arc::new(CohortActivationGate::new_closed()),
618 };
619
620 let task = tokio::spawn(async move { handle.start_route("route-a").await });
621
622 let command = timeout(Duration::from_secs(2), rx.recv())
623 .await
624 .expect("command should be received within 2s")
625 .expect("command channel alive");
626 match command {
627 RouteControllerCommand::StartRoute { route_id, reply } => {
628 assert_eq!(route_id, "route-a");
629 let _ = reply.send(Ok(()));
630 }
631 _ => panic!("unexpected command variant"),
632 }
633
634 let result = task.await.expect("join should succeed");
635 assert!(result.is_ok());
636 }
637
638 #[tokio::test]
639 async fn start_route_returns_error_when_actor_stops() {
640 let (tx, rx) = mpsc::channel(1);
641 drop(rx);
642
643 let handle = RouteControllerHandle {
644 tx,
645 cohort: Arc::new(CohortActivationGate::new_closed()),
646 };
647 let result = handle.start_route("route-a").await;
648
649 assert!(matches!(result, Err(CamelError::ProcessorError(_))));
650 }
651
652 #[tokio::test]
653 async fn spawn_controller_actor_processes_commands_and_shutdown() {
654 let controller = DefaultRouteController::new(
655 Arc::new(std::sync::Mutex::new(Registry::new())),
656 Arc::new(camel_api::NoopPlatformService::default()),
657 );
658 let (handle, join_handle) = spawn_controller_actor(controller);
659
660 assert_eq!(handle.route_count().await.expect("route_count"), 0);
661 assert_eq!(
662 handle.route_ids().await.expect("route_ids"),
663 Vec::<String>::new()
664 );
665
666 handle.shutdown().await.expect("shutdown send");
667 join_handle.await.expect("actor join");
668 }
669
670 #[tokio::test]
671 async fn actor_handle_introspection_and_mutation_commands() {
672 let (handle, join_handle) = build_actor_with_components();
673 let definition = route_def("h-1", "timer:tick?period=100");
674
675 handle.add_route(definition).await.expect("add route");
676 assert!(handle.route_exists("h-1").await.expect("route exists h-1"));
677 assert!(
678 !handle
679 .route_exists("no-such")
680 .await
681 .expect("route exists no-such")
682 );
683
684 let from_uri = handle.route_from_uri("h-1").await.expect("route_from_uri");
685 assert_eq!(from_uri.as_deref(), Some("timer:tick?period=100"));
686 assert_eq!(handle.route_count().await.expect("route_count"), 1);
687
688 let auto_ids = handle
689 .auto_startup_route_ids()
690 .await
691 .expect("auto_startup_route_ids");
692 assert!(auto_ids.iter().any(|id| id == "h-1"));
693
694 let shutdown_ids = handle
695 .shutdown_route_ids()
696 .await
697 .expect("shutdown_route_ids");
698 assert!(shutdown_ids.iter().any(|id| id == "h-1"));
699
700 let compiled = handle
701 .compile_route_definition(route_def("h-1", "timer:tick?period=100"))
702 .await
703 .expect("compile_route_definition");
704
705 assert!(
706 handle
707 .get_pipeline("h-1")
708 .await
709 .expect("get_pipeline")
710 .is_some()
711 );
712 handle
713 .swap_pipeline("h-1", compiled)
714 .await
715 .expect("swap_pipeline");
716
717 let _ = handle
718 .in_flight_count("h-1")
719 .await
720 .expect("in_flight_count");
721 let _ = handle.route_source_hash("h-1").await;
722
723 handle
724 .set_error_handler(ErrorHandlerConfig::dead_letter_channel("log:dlq"))
725 .await
726 .expect("set_error_handler");
727 handle
728 .set_tracer_config(TracerConfig::default())
729 .await
730 .expect("set_tracer_config");
731 handle
732 .set_runtime_handle(Arc::new(NoopRuntime))
733 .await
734 .expect("set_runtime_handle");
735
736 handle.remove_route("h-1").await.expect("remove_route");
737 assert_eq!(
738 handle
739 .route_count()
740 .await
741 .expect("route_count after remove"),
742 0
743 );
744 handle
745 .stop_all_routes()
746 .await
747 .expect("stop_all_routes on empty");
748
749 handle.shutdown().await.expect("shutdown send");
750 join_handle.await.expect("actor join");
751 }
752
753 #[tokio::test]
754 async fn actor_handle_lifecycle_start_stop_restart_suspend_resume() {
755 let (handle, join_handle) = build_actor_with_components();
756 handle
757 .add_route(route_def("lc-1", "timer:tick?period=50"))
758 .await
759 .expect("add route lc-1");
760
761 handle.start_route("lc-1").await.expect("start_route");
762 sleep(Duration::from_millis(20)).await;
763
764 handle.restart_route("lc-1").await.expect("restart_route");
765 sleep(Duration::from_millis(20)).await;
766
767 handle.suspend_route("lc-1").await.expect("suspend_route");
768 handle.resume_route("lc-1").await.expect("resume_route");
769 sleep(Duration::from_millis(20)).await;
770
771 handle.stop_route("lc-1").await.expect("stop_route");
772 handle.start_all_routes().await.expect("start_all_routes");
773 sleep(Duration::from_millis(20)).await;
774 handle.stop_all_routes().await.expect("stop_all_routes");
775
776 handle
777 .start_route_reload("lc-1")
778 .await
779 .expect("start_route_reload");
780 handle
781 .stop_route_reload("lc-1")
782 .await
783 .expect("stop_route_reload");
784
785 handle.shutdown().await.expect("shutdown send");
786 join_handle.await.expect("actor join");
787 }
788
789 #[tokio::test]
790 async fn spawn_supervision_restarts_route_on_crash() {
791 let (handle, join_handle) = build_actor_with_components();
792 handle
793 .add_route(route_def("sup-1", "timer:tick?period=100"))
794 .await
795 .expect("add route sup-1");
796 handle
797 .start_route("sup-1")
798 .await
799 .expect("start_route sup-1");
800
801 let (crash_tx, crash_rx) = mpsc::channel(8);
802 let supervision = spawn_supervision_task(
803 handle.clone(),
804 SupervisionConfig {
805 initial_delay: Duration::from_millis(10),
806 max_attempts: Some(2),
807 ..SupervisionConfig::default()
808 },
809 None,
810 crash_rx,
811 );
812
813 crash_tx
814 .send(CrashNotification {
815 route_id: "sup-1".to_string(),
816 error: "simulated".to_string(),
817 })
818 .await
819 .expect("send crash notification");
820
821 sleep(Duration::from_millis(150)).await;
822 drop(crash_tx);
823 supervision.await.expect("supervision join");
824
825 handle.shutdown().await.expect("shutdown send");
826 join_handle.await.expect("actor join");
827 }
828
829 #[tokio::test]
830 async fn supervision_skips_duplicate_and_gives_up_after_max_attempts() {
831 let (handle, join_handle) = build_actor_with_components();
832 handle
833 .add_route(route_def("sup-2", "timer:tick?period=100"))
834 .await
835 .expect("add route sup-2");
836 handle
837 .start_route("sup-2")
838 .await
839 .expect("start_route sup-2");
840
841 let (crash_tx, crash_rx) = mpsc::channel(8);
842 let supervision = spawn_supervision_task(
843 handle.clone(),
844 SupervisionConfig {
845 initial_delay: Duration::from_millis(10),
846 max_attempts: Some(1),
847 ..SupervisionConfig::default()
848 },
849 None,
850 crash_rx,
851 );
852
853 crash_tx
854 .send(CrashNotification {
855 route_id: "sup-2".to_string(),
856 error: "attempt-1".to_string(),
857 })
858 .await
859 .expect("send crash attempt-1");
860 crash_tx
861 .send(CrashNotification {
862 route_id: "sup-2".to_string(),
863 error: "attempt-2".to_string(),
864 })
865 .await
866 .expect("send crash attempt-2");
867
868 sleep(Duration::from_millis(200)).await;
869 drop(crash_tx);
870 supervision.await.expect("supervision join");
871
872 handle.shutdown().await.expect("shutdown send");
873 join_handle.await.expect("actor join");
874 }
875
876 #[tokio::test]
877 async fn try_set_runtime_handle_succeeds_on_fresh_actor() {
878 let (handle, join_handle) = build_empty_actor();
879
880 handle
881 .try_set_runtime_handle(Arc::new(NoopRuntime))
882 .expect("try_set_runtime_handle should succeed");
883
884 handle.shutdown().await.expect("shutdown send");
885 join_handle.await.expect("actor join");
886 }
887
888 #[tokio::test]
889 async fn shutdown_returns_error_when_actor_stopped() {
890 let (tx, rx) = mpsc::channel(1);
891 drop(rx);
892
893 let handle = RouteControllerHandle {
894 tx,
895 cohort: Arc::new(CohortActivationGate::new_closed()),
896 };
897 let result = handle.shutdown().await;
898
899 assert!(matches!(result, Err(CamelError::ProcessorError(_))));
900 }
901
902 #[tokio::test]
903 async fn handle_methods_send_expected_commands_and_receive_replies() {
904 let (tx, mut rx) = mpsc::channel(16);
905 let handle = RouteControllerHandle {
906 tx,
907 cohort: Arc::new(CohortActivationGate::new_closed()),
908 };
909
910 let stop_task = tokio::spawn({
911 let h = handle.clone();
912 async move { h.stop_route("r-1").await }
913 });
914 let cmd = timeout(Duration::from_secs(2), rx.recv())
915 .await
916 .expect("stop command within 2s")
917 .expect("command channel alive");
918 match cmd {
919 RouteControllerCommand::StopRoute { route_id, reply } => {
920 assert_eq!(route_id, "r-1");
921 let _ = reply.send(Ok(()));
922 }
923 _ => panic!("unexpected command"),
924 }
925 assert!(stop_task.await.expect("join").is_ok());
926
927 let exists_task = tokio::spawn({
928 let h = handle.clone();
929 async move { h.route_exists("r-2").await }
930 });
931 let cmd = timeout(Duration::from_secs(2), rx.recv())
932 .await
933 .expect("exists command within 2s")
934 .expect("command channel alive");
935 match cmd {
936 RouteControllerCommand::RouteExists { route_id, reply } => {
937 assert_eq!(route_id, "r-2");
938 let _ = reply.send(true);
939 }
940 _ => panic!("unexpected command"),
941 }
942 assert!(exists_task.await.expect("join").expect("ok"));
943
944 let hash_task = tokio::spawn({
945 let h = handle.clone();
946 async move { h.route_source_hash("r-3").await }
947 });
948 let cmd = timeout(Duration::from_secs(2), rx.recv())
949 .await
950 .expect("hash command within 2s")
951 .expect("command channel alive");
952 match cmd {
953 RouteControllerCommand::RouteSourceHash { route_id, reply } => {
954 assert_eq!(route_id, "r-3");
955 let _ = reply.send(Some(77));
956 }
957 _ => panic!("unexpected command"),
958 }
959 assert_eq!(hash_task.await.expect("join"), Some(77));
960 }
961
962 #[tokio::test]
963 async fn handle_methods_error_on_dropped_reply_channel() {
964 let (tx, mut rx) = mpsc::channel(16);
965 let handle = RouteControllerHandle {
966 tx,
967 cohort: Arc::new(CohortActivationGate::new_closed()),
968 };
969
970 let count_task = tokio::spawn({
971 let h = handle.clone();
972 async move { h.route_count().await }
973 });
974 let cmd = timeout(Duration::from_secs(2), rx.recv())
975 .await
976 .expect("route_count command within 2s")
977 .expect("command channel alive");
978 match cmd {
979 RouteControllerCommand::RouteCount { reply } => drop(reply),
980 _ => panic!("unexpected command"),
981 }
982 assert!(matches!(
983 count_task.await.expect("join"),
984 Err(CamelError::ProcessorError(_))
985 ));
986
987 let stop_task = tokio::spawn({
988 let h = handle.clone();
989 async move { h.stop_route("x").await }
990 });
991 let cmd = timeout(Duration::from_secs(2), rx.recv())
992 .await
993 .expect("stop command within 2s")
994 .expect("command channel alive");
995 match cmd {
996 RouteControllerCommand::StopRoute { reply, .. } => drop(reply),
997 _ => panic!("unexpected command"),
998 }
999 assert!(matches!(
1000 stop_task.await.expect("join"),
1001 Err(CamelError::ProcessorError(_))
1002 ));
1003
1004 let maybe_hash = tokio::spawn({
1005 let h = handle.clone();
1006 async move { h.route_source_hash("x").await }
1007 });
1008 let cmd = timeout(Duration::from_secs(2), rx.recv())
1009 .await
1010 .expect("hash command within 2s")
1011 .expect("command channel alive");
1012 match cmd {
1013 RouteControllerCommand::RouteSourceHash { reply, .. } => drop(reply),
1014 _ => panic!("unexpected command"),
1015 }
1016 assert_eq!(maybe_hash.await.expect("join"), None);
1017 }
1018
1019 #[test]
1020 fn try_set_function_invoker_returns_mailbox_full() {
1021 let (tx, mut rx) = mpsc::channel(1);
1022 tx.try_send(RouteControllerCommand::Shutdown)
1023 .expect("fill mailbox");
1024 let handle = RouteControllerHandle {
1025 tx,
1026 cohort: Arc::new(CohortActivationGate::new_closed()),
1027 };
1028
1029 let result = handle.try_set_function_invoker(Arc::new(NoopInvoker));
1030 assert!(matches!(result, Err(CamelError::ProcessorError(_))));
1031
1032 rx.try_recv().expect("mailbox still has first message");
1033 }
1034
1035 #[tokio::test]
1036 async fn test_restart_does_not_block_other_routes() {
1037 let (handle, join_handle) = build_actor_with_components();
1041 handle
1042 .add_route(route_def("route-a", "timer:tick?period=100"))
1043 .await
1044 .expect("add route-a");
1045 handle
1046 .add_route(route_def("route-b", "timer:tick?period=100"))
1047 .await
1048 .expect("add route-b");
1049 handle
1050 .add_route(route_def("route-c", "timer:tick?period=100"))
1051 .await
1052 .expect("add route-c");
1053
1054 handle.start_route("route-a").await.expect("start route-a");
1055 handle.start_route("route-b").await.expect("start route-b");
1056
1057 let restart_handle = handle.clone();
1060 let restart_fut =
1061 tokio::spawn(async move { restart_handle.restart_route("route-a").await });
1062
1063 tokio::task::yield_now().await;
1065
1066 let start_c_t0 = std::time::Instant::now();
1068 let start_result = handle.start_route("route-c").await;
1069 let start_c_elapsed = start_c_t0.elapsed();
1070
1071 assert!(start_result.is_ok(), "route-c start should succeed");
1072 assert!(
1073 start_c_elapsed < Duration::from_millis(80),
1074 "route-c start blocked by route-a restart (took {start_c_elapsed:?}); \
1075 expected to complete before restart's 100ms sleep"
1076 );
1077
1078 let restart_result = restart_fut.await.expect("restart join");
1079 assert!(restart_result.is_ok(), "route-a restart should succeed");
1080
1081 handle.shutdown().await.expect("shutdown send");
1082 join_handle.await.expect("actor join");
1083 }
1084
1085 #[tokio::test]
1086 async fn test_command_to_restarting_route_is_rejected() {
1087 let (handle, join_handle) = build_actor_with_components();
1090 handle
1091 .add_route(route_def("route-a", "timer:tick?period=100"))
1092 .await
1093 .expect("add route-a");
1094 handle.start_route("route-a").await.expect("start route-a");
1095
1096 let restart_handle = handle.clone();
1099 let restart_fut =
1100 tokio::spawn(async move { restart_handle.restart_route("route-a").await });
1101
1102 tokio::time::sleep(Duration::from_millis(20)).await;
1106
1107 let stop_result = handle.stop_route("route-a").await;
1109 assert!(
1110 stop_result.is_err(),
1111 "stop during restart should be rejected"
1112 );
1113 assert!(
1114 stop_result.unwrap_err().to_string().contains("restarting"),
1115 "error should mention restarting"
1116 );
1117
1118 let restart_result = restart_fut.await.expect("restart join");
1120 assert!(restart_result.is_ok(), "route-a restart should succeed");
1121
1122 handle.shutdown().await.expect("shutdown send");
1123 join_handle.await.expect("actor join");
1124 }
1125}