Skip to main content

camel_core/lifecycle/adapters/
controller_actor.rs

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