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    // Clone the cohort gate into the handle BEFORE the controller moves
25    // into the actor task. Reset/activate bypass the actor entirely (they
26    // act on the shared gate), so the handle needs its own Arc.
27    let cohort = Arc::clone(&controller.cohort);
28    // Hold a clone of tx so the spawned task can send StartRoute back through
29    // the same channel after the 100ms restart sleep, without moving the
30    // original tx (which we still need to return as RouteControllerHandle).
31    let tx_for_spawn = tx.clone();
32    let handle = tokio::spawn(async move {
33        let mut controller = controller;
34        // Tracks routes currently restarting via spawned off-actor tasks.
35        // Related to but separate from spawn_supervision_task's
36        // currently_restarting set (that set tracks crash-recovery restarts;
37        // this one tracks command-driven restarts).
38        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                    // allow-unwrap: Mutex cannot be poisoned in normal operation
43                    if restarting
44                        .lock()
45                        .expect("restarting mutex poisoned") // allow-unwrap
46                        .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                    // allow-unwrap: Mutex cannot be poisoned in normal operation
58                    if restarting
59                        .lock()
60                        .expect("restarting mutex poisoned") // allow-unwrap
61                        .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                    // Reject if already restarting.
73                    // allow-unwrap: Mutex cannot be poisoned in normal operation
74                    {
75                        let mut guard = restarting.lock().expect("restarting mutex poisoned"); // allow-unwrap
76                        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                    // Stop inline — actor owns the controller, so we cannot
87                    // move it into a spawned task. The 100ms sleep + start
88                    // happen off-actor by sending a StartRoute back through
89                    // the same channel.
90                    let stop_result = controller.stop_route(&route_id).await;
91                    if let Err(ref e) = stop_result {
92                        // allow-unwrap: Mutex cannot be poisoned in normal operation
93                        restarting
94                            .lock()
95                            .expect("restarting mutex poisoned") // allow-unwrap
96                            .remove(&route_id);
97                        let _ = reply.send(Err(e.clone()));
98                        continue;
99                    }
100
101                    // Spawn only the sleep + send StartRoute back through
102                    // the SAME channel. The StartRoute is processed on the
103                    // actor thread (correct ownership), and the reply moves
104                    // into the spawned task to bridge the restart caller.
105                    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                        // Remove from `restarting` BEFORE sending StartRoute: the
112                        // actor's StartRoute arm rejects if the route is in the
113                        // restarting set, so removing first lets the actor
114                        // accept its own self-sent command.
115                        // allow-unwrap: Mutex cannot be poisoned in normal operation
116                        restarting_clone
117                            .lock()
118                            .expect("restarting mutex poisoned") // allow-unwrap
119                            .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                    // Actor returns to rx.recv() immediately — HoL eliminated.
135                }
136                RouteControllerCommand::SuspendRoute { route_id, reply } => {
137                    // allow-unwrap: Mutex cannot be poisoned in normal operation
138                    if restarting
139                        .lock()
140                        .expect("restarting mutex poisoned") // allow-unwrap
141                        .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                    // allow-unwrap: Mutex cannot be poisoned in normal operation
153                    if restarting
154                        .lock()
155                        .expect("restarting mutex poisoned") // allow-unwrap
156                        .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                    // allow-unwrap: Mutex cannot be poisoned in normal operation
177                    if restarting
178                        .lock()
179                        .expect("restarting mutex poisoned") // allow-unwrap
180                        .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                    // allow-unwrap: Mutex cannot be poisoned in normal operation
248                    if restarting
249                        .lock()
250                        .expect("restarting mutex poisoned") // allow-unwrap
251                        .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                        // Detached per-call task: health probes may be slow
303                        // and must not block the sequential actor loop.
304                        // Unbounded; route via controller JoinSet if QPS grows.
305                        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                    // allow-unwrap: Mutex cannot be poisoned in normal operation
330                    if restarting
331                        .lock()
332                        .expect("restarting mutex poisoned") // allow-unwrap
333                        .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                    // allow-unwrap: Mutex cannot be poisoned in normal operation
345                    if restarting
346                        .lock()
347                        .expect("restarting mutex poisoned") // allow-unwrap
348                        .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                // log-policy: system-broken
421                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                    // log-policy: system-broken
440                    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(&registry),
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                // ListRoutes and any future variant return an empty result.
525                _ => 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        // D-L6: a route's restart (stop + 100ms sleep + start) must not block
1038        // commands for other routes. Without the fix, start_c would wait
1039        // behind restart's 100ms sleep in the actor mailbox.
1040        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        // Spawn restart on its own task so its RestartRoute command is
1058        // guaranteed to land in the mailbox before start_c is enqueued.
1059        let restart_handle = handle.clone();
1060        let restart_fut =
1061            tokio::spawn(async move { restart_handle.restart_route("route-a").await });
1062
1063        // Yield once so restart's send completes and RestartRoute is queued first.
1064        tokio::task::yield_now().await;
1065
1066        // Now issue start_c — its StartRoute command is queued behind RestartRoute.
1067        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        // D-L6: while a route is restarting, mutating commands for that route
1088        // must be rejected.
1089        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        // Spawn restart on its own task so the RestartRoute command is
1097        // guaranteed to land in the mailbox.
1098        let restart_handle = handle.clone();
1099        let restart_fut =
1100            tokio::spawn(async move { restart_handle.restart_route("route-a").await });
1101
1102        // Give the actor time to process the Restart and enter the 100ms sleep.
1103        // After the inline stop completes, the actor returns to rx.recv() and
1104        // the spawned task is sleeping — route-a is in the restarting set.
1105        tokio::time::sleep(Duration::from_millis(20)).await;
1106
1107        // While restarting, try to stop route-a — must be rejected.
1108        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        // Now the restart completes.
1119        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}