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 {
57                    route_id,
58                    stop_budget,
59                    reply,
60                } => {
61                    // allow-unwrap: Mutex cannot be poisoned in normal operation
62                    if restarting
63                        .lock()
64                        .expect("restarting mutex poisoned") // allow-unwrap
65                        .contains(&route_id)
66                    {
67                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
68                            "route {} is restarting",
69                            route_id
70                        ))));
71                        continue;
72                    }
73                    let stop_result = match stop_budget {
74                        Some(budget) => controller.stop_route_with_budget(&route_id, budget).await,
75                        None => controller.stop_route(&route_id).await,
76                    };
77                    let _ = reply.send(stop_result);
78                }
79                RouteControllerCommand::RestartRoute { route_id, reply } => {
80                    // Reject if already restarting.
81                    // allow-unwrap: Mutex cannot be poisoned in normal operation
82                    {
83                        let mut guard = restarting.lock().expect("restarting mutex poisoned"); // allow-unwrap
84                        if guard.contains(&route_id) {
85                            let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
86                                "route {} is restarting",
87                                route_id
88                            ))));
89                            continue;
90                        }
91                        guard.insert(route_id.clone());
92                    }
93
94                    // Stop inline — actor owns the controller, so we cannot
95                    // move it into a spawned task. The 100ms sleep + start
96                    // happen off-actor by sending a StartRoute back through
97                    // the same channel.
98                    let stop_result = controller.stop_route(&route_id).await;
99                    if let Err(ref e) = stop_result {
100                        // allow-unwrap: Mutex cannot be poisoned in normal operation
101                        restarting
102                            .lock()
103                            .expect("restarting mutex poisoned") // allow-unwrap
104                            .remove(&route_id);
105                        let _ = reply.send(Err(e.clone()));
106                        continue;
107                    }
108
109                    // Spawn only the sleep + send StartRoute back through
110                    // the SAME channel. The StartRoute is processed on the
111                    // actor thread (correct ownership), and the reply moves
112                    // into the spawned task to bridge the restart caller.
113                    let tx_clone = tx_for_spawn.clone();
114                    let restarting_clone = restarting.clone();
115                    let route_id_for_start = route_id.clone();
116                    let route_id_for_cleanup = route_id.clone();
117                    tokio::spawn(async move {
118                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
119                        // Remove from `restarting` BEFORE sending StartRoute: the
120                        // actor's StartRoute arm rejects if the route is in the
121                        // restarting set, so removing first lets the actor
122                        // accept its own self-sent command.
123                        // allow-unwrap: Mutex cannot be poisoned in normal operation
124                        restarting_clone
125                            .lock()
126                            .expect("restarting mutex poisoned") // allow-unwrap
127                            .remove(&route_id_for_cleanup);
128                        if tx_clone
129                            .send(RouteControllerCommand::StartRoute {
130                                route_id: route_id_for_start,
131                                reply,
132                            })
133                            .await
134                            .is_err()
135                        {
136                            warn!(
137                                "route {} restart: StartRoute send failed (actor shutting down)",
138                                route_id_for_cleanup
139                            );
140                        }
141                    });
142                    // Actor returns to rx.recv() immediately — HoL eliminated.
143                }
144                RouteControllerCommand::SuspendRoute { route_id, reply } => {
145                    // allow-unwrap: Mutex cannot be poisoned in normal operation
146                    if restarting
147                        .lock()
148                        .expect("restarting mutex poisoned") // allow-unwrap
149                        .contains(&route_id)
150                    {
151                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
152                            "route {} is restarting",
153                            route_id
154                        ))));
155                        continue;
156                    }
157                    let _ = reply.send(controller.suspend_route(&route_id).await);
158                }
159                RouteControllerCommand::ResumeRoute { route_id, reply } => {
160                    // allow-unwrap: Mutex cannot be poisoned in normal operation
161                    if restarting
162                        .lock()
163                        .expect("restarting mutex poisoned") // allow-unwrap
164                        .contains(&route_id)
165                    {
166                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
167                            "route {} is restarting",
168                            route_id
169                        ))));
170                        continue;
171                    }
172                    let _ = reply.send(controller.resume_route(&route_id).await);
173                }
174                RouteControllerCommand::StartAllRoutes { reply } => {
175                    let _ = reply.send(controller.start_all_routes().await);
176                }
177                RouteControllerCommand::StopAllRoutes { reply } => {
178                    let _ = reply.send(controller.stop_all_routes().await);
179                }
180                RouteControllerCommand::AddRoute { definition, reply } => {
181                    let _ = reply.send(controller.add_route(definition).await);
182                }
183                RouteControllerCommand::RemoveRoute { route_id, reply } => {
184                    // allow-unwrap: Mutex cannot be poisoned in normal operation
185                    if restarting
186                        .lock()
187                        .expect("restarting mutex poisoned") // allow-unwrap
188                        .contains(&route_id)
189                    {
190                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
191                            "route {} is restarting",
192                            route_id
193                        ))));
194                        continue;
195                    }
196                    let _ = reply.send(controller.remove_route(&route_id).await);
197                }
198                RouteControllerCommand::SwapPipeline {
199                    route_id,
200                    pipeline,
201                    reply,
202                } => {
203                    let _ = reply.send(controller.swap_pipeline(&route_id, pipeline));
204                }
205                RouteControllerCommand::SwapPipelineRaw {
206                    route_id,
207                    pipeline,
208                    lifecycle,
209                    reply,
210                } => {
211                    let _ =
212                        reply.send(controller.swap_pipeline_raw(&route_id, pipeline, lifecycle));
213                }
214                RouteControllerCommand::CompileRouteDefinition { definition, reply } => {
215                    let _ = reply.send(controller.compile_route_definition(definition));
216                }
217                RouteControllerCommand::CompileRouteDefinitionWithGeneration {
218                    definition,
219                    generation,
220                    reply,
221                } => {
222                    let _ = reply.send(
223                        controller.compile_route_definition_with_generation(definition, generation),
224                    );
225                }
226                RouteControllerCommand::CompileRouteDefinitionPipeline {
227                    definition,
228                    generation,
229                    reply,
230                } => {
231                    let _ = reply
232                        .send(controller.compile_route_definition_pipeline(definition, generation));
233                }
234                RouteControllerCommand::CompileRouteDefinitionDryPipeline { definition, reply } => {
235                    let _ =
236                        reply.send(controller.compile_route_definition_dry_pipeline(definition));
237                }
238                RouteControllerCommand::PrepareRouteDefinitionWithGeneration {
239                    definition,
240                    generation,
241                    reply,
242                } => {
243                    let _ = reply.send(
244                        controller.prepare_route_definition_with_generation(definition, generation),
245                    );
246                }
247                RouteControllerCommand::InsertPreparedRoute { prepared, reply } => {
248                    let _ = reply.send(controller.insert_prepared_route(prepared));
249                }
250                RouteControllerCommand::DiscardPreparedStaging { route_id, reply } => {
251                    controller.discard_prepared_staging(&route_id);
252                    let _ = reply.send(Ok(()));
253                }
254                RouteControllerCommand::RemoveRoutePreservingFunctions { route_id, reply } => {
255                    // allow-unwrap: Mutex cannot be poisoned in normal operation
256                    if restarting
257                        .lock()
258                        .expect("restarting mutex poisoned") // allow-unwrap
259                        .contains(&route_id)
260                    {
261                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
262                            "route {} is restarting",
263                            route_id
264                        ))));
265                        continue;
266                    }
267                    let _ = reply.send(
268                        controller
269                            .remove_route_preserving_functions(&route_id)
270                            .await,
271                    );
272                }
273                RouteControllerCommand::RouteFromUri { route_id, reply } => {
274                    let _ = reply.send(controller.route_from_uri(&route_id));
275                }
276                RouteControllerCommand::SetErrorHandler { config } => {
277                    controller.set_error_handler(config);
278                }
279                RouteControllerCommand::SetTracerConfig { config } => {
280                    controller.set_tracer_config(&config);
281                }
282                RouteControllerCommand::SetBindExposureAcks { acks } => {
283                    controller.set_bind_exposure_acks(acks);
284                }
285                RouteControllerCommand::RouteCount { reply } => {
286                    let _ = reply.send(controller.route_count());
287                }
288                RouteControllerCommand::InFlightCount { route_id, reply } => {
289                    let _ = reply.send(controller.in_flight_count(&route_id));
290                }
291                RouteControllerCommand::RouteExists { route_id, reply } => {
292                    let _ = reply.send(controller.route_exists(&route_id));
293                }
294                RouteControllerCommand::RouteIds { reply } => {
295                    let _ = reply.send(controller.route_ids());
296                }
297                RouteControllerCommand::ListEndpoints { reply } => {
298                    let _ = reply.send(controller.list_endpoint_uris());
299                }
300                RouteControllerCommand::RoutesForEndpoint { uri, reply } => {
301                    let _ = reply.send(controller.routes_for_endpoint(&uri));
302                }
303                RouteControllerCommand::HealthCheckEndpoint { uri, reply } => {
304                    let route_ids = controller.routes_for_endpoint(&uri);
305                    if route_ids.is_empty() {
306                        let _ =
307                            reply.send(Err(CamelError::RouteError("endpoint not found".into())));
308                    } else {
309                        let health_registry = controller.health_registry();
310                        // Detached per-call task: health probes may be slow
311                        // and must not block the sequential actor loop.
312                        // Unbounded; route via controller JoinSet if QPS grows.
313                        tokio::spawn(async move {
314                            let futures: Vec<_> = route_ids
315                                .iter()
316                                .map(|rid| health_registry.check_route(rid))
317                                .collect();
318                            let results = futures::future::join_all(futures).await;
319                            let worst = results.into_iter().fold(
320                                camel_api::HealthStatus::Healthy,
321                                crate::health_registry::combine_worst,
322                            );
323                            let _ = reply.send(Ok(worst));
324                        });
325                    }
326                }
327                RouteControllerCommand::AutoStartupRouteIds { reply } => {
328                    let _ = reply.send(controller.auto_startup_route_ids());
329                }
330                RouteControllerCommand::ShutdownRouteIds { reply } => {
331                    let _ = reply.send(controller.shutdown_route_ids());
332                }
333                RouteControllerCommand::GetPipeline { route_id, reply } => {
334                    let _ = reply.send(controller.get_pipeline(&route_id));
335                }
336                RouteControllerCommand::StartRouteReload { route_id, reply } => {
337                    // allow-unwrap: Mutex cannot be poisoned in normal operation
338                    if restarting
339                        .lock()
340                        .expect("restarting mutex poisoned") // allow-unwrap
341                        .contains(&route_id)
342                    {
343                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
344                            "route {} is restarting",
345                            route_id
346                        ))));
347                        continue;
348                    }
349                    let _ = reply.send(controller.start_route_reload(&route_id).await);
350                }
351                RouteControllerCommand::StopRouteReload { route_id, reply } => {
352                    // allow-unwrap: Mutex cannot be poisoned in normal operation
353                    if restarting
354                        .lock()
355                        .expect("restarting mutex poisoned") // allow-unwrap
356                        .contains(&route_id)
357                    {
358                        let _ = reply.send(Err(camel_api::CamelError::RouteError(format!(
359                            "route {} is restarting",
360                            route_id
361                        ))));
362                        continue;
363                    }
364                    let _ = reply.send(controller.stop_route_reload(&route_id).await);
365                }
366                RouteControllerCommand::SetRuntimeHandle { runtime } => {
367                    controller.set_runtime_handle(runtime);
368                }
369                RouteControllerCommand::SetInterceptRules { rules, reply } => {
370                    let _ = reply.send(controller.set_intercept_rules(rules));
371                }
372                RouteControllerCommand::MarkStarted { reply } => {
373                    controller.mark_started();
374                    let _ = reply.send(Ok(()));
375                }
376                RouteControllerCommand::SetFunctionInvoker { invoker } => {
377                    controller.set_function_invoker(invoker);
378                }
379                RouteControllerCommand::RouteSourceHash { route_id, reply } => {
380                    let _ = reply.send(controller.route_source_hash(&route_id));
381                }
382                RouteControllerCommand::RouteHasLifecycle { route_id, reply } => {
383                    let _ = reply.send(controller.route_has_lifecycle(&route_id));
384                }
385                RouteControllerCommand::Shutdown => {
386                    break;
387                }
388            }
389        }
390    });
391    (RouteControllerHandle { tx, cohort }, handle)
392}
393
394pub fn spawn_supervision_task(
395    controller: RouteControllerHandle,
396    config: SupervisionConfig,
397    _metrics: Option<Arc<dyn MetricsCollector>>,
398    mut crash_rx: mpsc::Receiver<CrashNotification>,
399) -> JoinHandle<()> {
400    tokio::spawn(async move {
401        let mut attempts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
402        let mut last_restart_time: std::collections::HashMap<String, Instant> =
403            std::collections::HashMap::new();
404        let mut currently_restarting: std::collections::HashSet<String> =
405            std::collections::HashSet::new();
406
407        debug!("Supervision loop started");
408
409        while let Some(notification) = crash_rx.recv().await {
410            let route_id = notification.route_id;
411            if currently_restarting.contains(&route_id) {
412                continue;
413            }
414
415            if let Some(last_time) = last_restart_time.get(&route_id)
416                && last_time.elapsed() >= config.initial_delay
417            {
418                attempts.insert(route_id.clone(), 0);
419            }
420
421            let current_attempt = attempts.entry(route_id.clone()).or_insert(0);
422            *current_attempt += 1;
423
424            if config
425                .max_attempts
426                .is_some_and(|max| *current_attempt > max)
427            {
428                // log-policy: system-broken
429                error!(
430                    route_id = %route_id,
431                    attempts = *current_attempt,
432                    "Route exceeded max restart attempts, giving up"
433                );
434                continue;
435            }
436
437            let delay = config.next_delay(*current_attempt);
438            currently_restarting.insert(route_id.clone());
439            tokio::time::sleep(delay).await;
440
441            match controller.restart_route(route_id.clone()).await {
442                Ok(()) => {
443                    info!(route_id = %route_id, "Route restarted successfully");
444                    last_restart_time.insert(route_id.clone(), Instant::now());
445                }
446                Err(err) => {
447                    // log-policy: system-broken
448                    error!(route_id = %route_id, error = %err, "Failed to restart route");
449                }
450            }
451
452            currently_restarting.remove(&route_id);
453        }
454
455        debug!("Supervision loop ended");
456    })
457}
458
459#[cfg(test)]
460mod tests {
461    use super::{
462        RouteControllerCommand, RouteControllerHandle, spawn_controller_actor,
463        spawn_supervision_task,
464    };
465    use crate::lifecycle::CohortActivationGate;
466    use crate::lifecycle::adapters::route_controller::DefaultRouteController;
467    use crate::lifecycle::adapters::route_helpers::CrashNotification;
468    use crate::lifecycle::application::route_definition::RouteDefinition;
469    use crate::shared::components::domain::Registry;
470    use crate::shared::observability::domain::TracerConfig;
471    use camel_api::function::PrepareToken;
472    use camel_api::{
473        CamelError, ErrorHandlerConfig, Exchange, ExchangePatch, FunctionDefinition, FunctionDiff,
474        FunctionId, FunctionInvocationError, FunctionInvoker, FunctionInvokerSync, RuntimeCommand,
475        RuntimeCommandBus, RuntimeCommandResult, RuntimeQuery, RuntimeQueryBus, RuntimeQueryResult,
476        SupervisionConfig,
477    };
478    use std::sync::Arc;
479    use std::time::Duration;
480    use tokio::sync::mpsc;
481    use tokio::time::sleep;
482    use tokio::time::timeout;
483
484    fn build_actor_with_components() -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
485        let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
486        {
487            let mut guard = registry.lock().expect("lock");
488            guard.register(std::sync::Arc::new(
489                camel_component_timer::TimerComponent::new(),
490            ));
491            guard.register(std::sync::Arc::new(
492                camel_component_mock::MockComponent::new(),
493            ));
494        }
495        let controller = DefaultRouteController::new(
496            Arc::clone(&registry),
497            Arc::new(camel_api::NoopPlatformService::default()),
498        );
499        spawn_controller_actor(controller)
500    }
501
502    fn build_actor_with_components_and_health(
503        health: Arc<crate::health_registry::HealthCheckRegistry>,
504    ) -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
505        let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
506        {
507            let mut guard = registry.lock().expect("lock");
508            guard.register(std::sync::Arc::new(
509                camel_component_timer::TimerComponent::new(),
510            ));
511            guard.register(std::sync::Arc::new(
512                camel_component_mock::MockComponent::new(),
513            ));
514        }
515        let mut controller = DefaultRouteController::new(
516            Arc::clone(&registry),
517            Arc::new(camel_api::NoopPlatformService::default()),
518        );
519        controller.set_health_registry(health);
520        spawn_controller_actor(controller)
521    }
522
523    fn build_empty_actor() -> (RouteControllerHandle, tokio::task::JoinHandle<()>) {
524        let controller = DefaultRouteController::new(
525            Arc::new(std::sync::Mutex::new(Registry::new())),
526            Arc::new(camel_api::NoopPlatformService::default()),
527        );
528        spawn_controller_actor(controller)
529    }
530
531    fn route_def(route_id: &str, from_uri: &str) -> RouteDefinition {
532        RouteDefinition::new(from_uri, vec![]).with_route_id(route_id)
533    }
534
535    struct NoopRuntime;
536    struct NoopInvoker;
537
538    #[async_trait::async_trait]
539    impl RuntimeCommandBus for NoopRuntime {
540        async fn execute(&self, _cmd: RuntimeCommand) -> Result<RuntimeCommandResult, CamelError> {
541            Ok(RuntimeCommandResult::Accepted)
542        }
543    }
544
545    #[async_trait::async_trait]
546    impl RuntimeQueryBus for NoopRuntime {
547        async fn ask(&self, query: RuntimeQuery) -> Result<RuntimeQueryResult, CamelError> {
548            Ok(match query {
549                RuntimeQuery::GetRouteStatus { route_id }
550                | RuntimeQuery::InFlightCount { route_id } => {
551                    RuntimeQueryResult::RouteNotFound { route_id }
552                }
553                // ListRoutes and any future variant return an empty result.
554                _ => RuntimeQueryResult::Routes {
555                    route_ids: Vec::new(),
556                },
557            })
558        }
559    }
560
561    impl FunctionInvokerSync for NoopInvoker {
562        fn stage_pending(
563            &self,
564            _def: FunctionDefinition,
565            _route_id: Option<&str>,
566            _generation: u64,
567        ) {
568        }
569        fn discard_staging(&self, _generation: u64) {}
570        fn begin_reload(&self) -> u64 {
571            1
572        }
573        fn function_refs_for_route(&self, _route_id: &str) -> Vec<(FunctionId, Option<String>)> {
574            vec![]
575        }
576        fn staged_refs_for_route(
577            &self,
578            _route_id: &str,
579            _generation: u64,
580        ) -> Vec<(FunctionId, Option<String>)> {
581            vec![]
582        }
583        fn staged_defs_for_route(
584            &self,
585            _route_id: &str,
586            _generation: u64,
587        ) -> Vec<(FunctionDefinition, Option<String>)> {
588            vec![]
589        }
590    }
591
592    #[async_trait::async_trait]
593    impl FunctionInvoker for NoopInvoker {
594        async fn register(
595            &self,
596            _def: FunctionDefinition,
597            _route_id: Option<&str>,
598        ) -> Result<(), FunctionInvocationError> {
599            Ok(())
600        }
601        async fn unregister(
602            &self,
603            _id: &FunctionId,
604            _route_id: Option<&str>,
605        ) -> Result<(), FunctionInvocationError> {
606            Ok(())
607        }
608        async fn invoke(
609            &self,
610            _id: &FunctionId,
611            _exchange: &Exchange,
612        ) -> Result<ExchangePatch, FunctionInvocationError> {
613            Ok(ExchangePatch::default())
614        }
615        async fn prepare_reload(
616            &self,
617            _diff: FunctionDiff,
618            _generation: u64,
619        ) -> Result<PrepareToken, FunctionInvocationError> {
620            Ok(PrepareToken::default())
621        }
622        async fn finalize_reload(
623            &self,
624            _diff: &FunctionDiff,
625            _generation: u64,
626        ) -> Result<(), FunctionInvocationError> {
627            Ok(())
628        }
629        async fn rollback_reload(
630            &self,
631            _token: PrepareToken,
632            _generation: u64,
633        ) -> Result<(), FunctionInvocationError> {
634            Ok(())
635        }
636        async fn commit_staged(&self) -> Result<(), FunctionInvocationError> {
637            Ok(())
638        }
639    }
640
641    #[tokio::test]
642    async fn start_route_sends_command_and_returns_reply() {
643        let (tx, mut rx) = mpsc::channel(1);
644        let handle = RouteControllerHandle {
645            tx,
646            cohort: Arc::new(CohortActivationGate::new_closed()),
647        };
648
649        let task = tokio::spawn(async move { handle.start_route("route-a").await });
650
651        let command = timeout(Duration::from_secs(2), rx.recv())
652            .await
653            .expect("command should be received within 2s")
654            .expect("command channel alive");
655        match command {
656            RouteControllerCommand::StartRoute { route_id, reply } => {
657                assert_eq!(route_id, "route-a");
658                let _ = reply.send(Ok(()));
659            }
660            _ => panic!("unexpected command variant"),
661        }
662
663        let result = task.await.expect("join should succeed"); // allow-test-wait: reply sent before join; start_route resolves
664        assert!(result.is_ok());
665    }
666
667    #[tokio::test]
668    async fn start_route_returns_error_when_actor_stops() {
669        let (tx, rx) = mpsc::channel(1);
670        drop(rx);
671
672        let handle = RouteControllerHandle {
673            tx,
674            cohort: Arc::new(CohortActivationGate::new_closed()),
675        };
676        let result = handle.start_route("route-a").await;
677
678        assert!(matches!(result, Err(CamelError::ProcessorError(_))));
679    }
680
681    #[tokio::test]
682    async fn spawn_controller_actor_processes_commands_and_shutdown() {
683        let controller = DefaultRouteController::new(
684            Arc::new(std::sync::Mutex::new(Registry::new())),
685            Arc::new(camel_api::NoopPlatformService::default()),
686        );
687        let (handle, join_handle) = spawn_controller_actor(controller);
688
689        assert_eq!(handle.route_count().await.expect("route_count"), 0);
690        assert_eq!(
691            handle.route_ids().await.expect("route_ids"),
692            Vec::<String>::new()
693        );
694
695        handle.shutdown().await.expect("shutdown send");
696        join_handle.await.expect("actor join");
697    }
698
699    #[tokio::test]
700    async fn actor_handle_introspection_and_mutation_commands() {
701        let (handle, join_handle) = build_actor_with_components();
702        let definition = route_def("h-1", "timer:tick?period=100");
703
704        handle.add_route(definition).await.expect("add route");
705        assert!(handle.route_exists("h-1").await.expect("route exists h-1"));
706        assert!(
707            !handle
708                .route_exists("no-such")
709                .await
710                .expect("route exists no-such")
711        );
712
713        let from_uri = handle.route_from_uri("h-1").await.expect("route_from_uri");
714        assert_eq!(from_uri.as_deref(), Some("timer:tick?period=100"));
715        assert_eq!(handle.route_count().await.expect("route_count"), 1);
716
717        let auto_ids = handle
718            .auto_startup_route_ids()
719            .await
720            .expect("auto_startup_route_ids");
721        assert!(auto_ids.iter().any(|id| id == "h-1"));
722
723        let shutdown_ids = handle
724            .shutdown_route_ids()
725            .await
726            .expect("shutdown_route_ids");
727        assert!(shutdown_ids.iter().any(|id| id == "h-1"));
728
729        let compiled = handle
730            .compile_route_definition(route_def("h-1", "timer:tick?period=100"))
731            .await
732            .expect("compile_route_definition");
733
734        assert!(
735            handle
736                .get_pipeline("h-1")
737                .await
738                .expect("get_pipeline")
739                .is_some()
740        );
741        handle
742            .swap_pipeline("h-1", compiled)
743            .await
744            .expect("swap_pipeline");
745
746        let _ = handle
747            .in_flight_count("h-1")
748            .await
749            .expect("in_flight_count");
750        let _ = handle.route_source_hash("h-1").await;
751
752        handle
753            .set_error_handler(ErrorHandlerConfig::dead_letter_channel("log:dlq"))
754            .await
755            .expect("set_error_handler");
756        handle
757            .set_tracer_config(TracerConfig::default())
758            .await
759            .expect("set_tracer_config");
760        handle
761            .set_runtime_handle(Arc::new(NoopRuntime))
762            .await
763            .expect("set_runtime_handle");
764
765        handle.remove_route("h-1").await.expect("remove_route");
766        assert_eq!(
767            handle
768                .route_count()
769                .await
770                .expect("route_count after remove"),
771            0
772        );
773        handle
774            .stop_all_routes()
775            .await
776            .expect("stop_all_routes on empty");
777
778        handle.shutdown().await.expect("shutdown send");
779        join_handle.await.expect("actor join");
780    }
781
782    #[tokio::test]
783    async fn actor_handle_lifecycle_start_stop_restart_suspend_resume() {
784        let (handle, join_handle) = build_actor_with_components();
785        handle
786            .add_route(route_def("lc-1", "timer:tick?period=50"))
787            .await
788            .expect("add route lc-1");
789
790        handle.start_route("lc-1").await.expect("start_route");
791        sleep(Duration::from_millis(20)).await;
792
793        handle.restart_route("lc-1").await.expect("restart_route");
794        sleep(Duration::from_millis(20)).await;
795
796        handle.suspend_route("lc-1").await.expect("suspend_route");
797        handle.resume_route("lc-1").await.expect("resume_route");
798        sleep(Duration::from_millis(20)).await;
799
800        handle.stop_route("lc-1").await.expect("stop_route");
801        handle.start_all_routes().await.expect("start_all_routes");
802        sleep(Duration::from_millis(20)).await;
803        handle.stop_all_routes().await.expect("stop_all_routes");
804
805        handle
806            .start_route_reload("lc-1")
807            .await
808            .expect("start_route_reload");
809        handle
810            .stop_route_reload("lc-1")
811            .await
812            .expect("stop_route_reload");
813
814        handle.shutdown().await.expect("shutdown send");
815        join_handle.await.expect("actor join");
816    }
817
818    /// Regression for the actor dispatch path: the actor holds a concrete
819    /// `DefaultRouteController`, so a bare `controller.stop_route_with_budget(..)`
820    /// resolves to an INHERENT method when one exists and silently skips the
821    /// trait override's health cleanup. This drives the budgeted stop through
822    /// the handle (actor command) — the production path — and asserts the
823    /// route's probes are deactivated.
824    #[tokio::test]
825    async fn actor_budgeted_stop_deactivates_route_health() {
826        use crate::health_registry::HealthCheckRegistry;
827        use camel_api::AsyncHealthCheck;
828
829        struct AlwaysUnhealthy;
830
831        #[async_trait::async_trait]
832        impl AsyncHealthCheck for AlwaysUnhealthy {
833            fn name(&self) -> &str {
834                "probe"
835            }
836
837            async fn check(&self) -> camel_api::health::CheckResult {
838                camel_api::health::CheckResult::unhealthy("probe", "boom")
839            }
840        }
841
842        let health = Arc::new(HealthCheckRegistry::new(Duration::from_secs(1)));
843        health.register_for_route("health-1", Arc::new(AlwaysUnhealthy));
844        let (handle, join_handle) = build_actor_with_components_and_health(Arc::clone(&health));
845
846        handle
847            .add_route(route_def("health-1", "timer:tick?period=10"))
848            .await
849            .expect("add route health-1");
850        handle.start_route("health-1").await.expect("start_route");
851        assert_eq!(
852            health.check_all().await.status,
853            camel_api::HealthStatus::Unhealthy,
854            "a started route must report its registered probe"
855        );
856
857        handle
858            .stop_route_with_budget("health-1", Some(Duration::from_millis(50)))
859            .await
860            .expect("budgeted stop through actor");
861
862        let report = health.check_all().await;
863        assert_eq!(
864            report.status,
865            camel_api::HealthStatus::Healthy,
866            "actor budgeted stop must mark the route stopped"
867        );
868        assert!(
869            report.services.is_empty(),
870            "actor budgeted stop must deactivate the route's probes: {report:?}"
871        );
872
873        handle.shutdown().await.expect("shutdown send");
874        join_handle.await.expect("actor join");
875    }
876
877    #[tokio::test]
878    async fn spawn_supervision_restarts_route_on_crash() {
879        let (handle, join_handle) = build_actor_with_components();
880        handle
881            .add_route(route_def("sup-1", "timer:tick?period=100"))
882            .await
883            .expect("add route sup-1");
884        handle
885            .start_route("sup-1")
886            .await
887            .expect("start_route sup-1");
888
889        let (crash_tx, crash_rx) = mpsc::channel(8);
890        let supervision = spawn_supervision_task(
891            handle.clone(),
892            SupervisionConfig {
893                initial_delay: Duration::from_millis(10),
894                max_attempts: Some(2),
895                ..SupervisionConfig::default()
896            },
897            None,
898            crash_rx,
899        );
900
901        crash_tx
902            .send(CrashNotification {
903                route_id: "sup-1".to_string(),
904                error: "simulated".to_string(),
905            })
906            .await
907            .expect("send crash notification");
908
909        sleep(Duration::from_millis(150)).await;
910        drop(crash_tx);
911        supervision.await.expect("supervision join");
912
913        handle.shutdown().await.expect("shutdown send");
914        join_handle.await.expect("actor join");
915    }
916
917    #[tokio::test]
918    async fn supervision_skips_duplicate_and_gives_up_after_max_attempts() {
919        let (handle, join_handle) = build_actor_with_components();
920        handle
921            .add_route(route_def("sup-2", "timer:tick?period=100"))
922            .await
923            .expect("add route sup-2");
924        handle
925            .start_route("sup-2")
926            .await
927            .expect("start_route sup-2");
928
929        let (crash_tx, crash_rx) = mpsc::channel(8);
930        let supervision = spawn_supervision_task(
931            handle.clone(),
932            SupervisionConfig {
933                initial_delay: Duration::from_millis(10),
934                max_attempts: Some(1),
935                ..SupervisionConfig::default()
936            },
937            None,
938            crash_rx,
939        );
940
941        crash_tx
942            .send(CrashNotification {
943                route_id: "sup-2".to_string(),
944                error: "attempt-1".to_string(),
945            })
946            .await
947            .expect("send crash attempt-1");
948        crash_tx
949            .send(CrashNotification {
950                route_id: "sup-2".to_string(),
951                error: "attempt-2".to_string(),
952            })
953            .await
954            .expect("send crash attempt-2");
955
956        sleep(Duration::from_millis(200)).await;
957        drop(crash_tx);
958        supervision.await.expect("supervision join");
959
960        handle.shutdown().await.expect("shutdown send");
961        join_handle.await.expect("actor join");
962    }
963
964    #[tokio::test]
965    async fn try_set_runtime_handle_succeeds_on_fresh_actor() {
966        let (handle, join_handle) = build_empty_actor();
967
968        handle
969            .try_set_runtime_handle(Arc::new(NoopRuntime))
970            .expect("try_set_runtime_handle should succeed");
971
972        handle.shutdown().await.expect("shutdown send");
973        join_handle.await.expect("actor join");
974    }
975
976    #[tokio::test]
977    async fn shutdown_returns_error_when_actor_stopped() {
978        let (tx, rx) = mpsc::channel(1);
979        drop(rx);
980
981        let handle = RouteControllerHandle {
982            tx,
983            cohort: Arc::new(CohortActivationGate::new_closed()),
984        };
985        let result = handle.shutdown().await;
986
987        assert!(matches!(result, Err(CamelError::ProcessorError(_))));
988    }
989
990    #[tokio::test]
991    async fn handle_methods_send_expected_commands_and_receive_replies() {
992        let (tx, mut rx) = mpsc::channel(16);
993        let handle = RouteControllerHandle {
994            tx,
995            cohort: Arc::new(CohortActivationGate::new_closed()),
996        };
997
998        let stop_task = tokio::spawn({
999            let h = handle.clone();
1000            async move { h.stop_route("r-1").await }
1001        });
1002        let cmd = timeout(Duration::from_secs(2), rx.recv())
1003            .await
1004            .expect("stop command within 2s")
1005            .expect("command channel alive");
1006        match cmd {
1007            RouteControllerCommand::StopRoute {
1008                route_id, reply, ..
1009            } => {
1010                assert_eq!(route_id, "r-1");
1011                let _ = reply.send(Ok(()));
1012            }
1013            _ => panic!("unexpected command"),
1014        }
1015        assert!(stop_task.await.expect("join").is_ok());
1016
1017        let exists_task = tokio::spawn({
1018            let h = handle.clone();
1019            async move { h.route_exists("r-2").await }
1020        });
1021        let cmd = timeout(Duration::from_secs(2), rx.recv())
1022            .await
1023            .expect("exists command within 2s")
1024            .expect("command channel alive");
1025        match cmd {
1026            RouteControllerCommand::RouteExists { route_id, reply } => {
1027                assert_eq!(route_id, "r-2");
1028                let _ = reply.send(true);
1029            }
1030            _ => panic!("unexpected command"),
1031        }
1032        assert!(exists_task.await.expect("join").expect("ok"));
1033
1034        let hash_task = tokio::spawn({
1035            let h = handle.clone();
1036            async move { h.route_source_hash("r-3").await }
1037        });
1038        let cmd = timeout(Duration::from_secs(2), rx.recv())
1039            .await
1040            .expect("hash command within 2s")
1041            .expect("command channel alive");
1042        match cmd {
1043            RouteControllerCommand::RouteSourceHash { route_id, reply } => {
1044                assert_eq!(route_id, "r-3");
1045                let _ = reply.send(Some(77));
1046            }
1047            _ => panic!("unexpected command"),
1048        }
1049        assert_eq!(hash_task.await.expect("join"), Some(77));
1050    }
1051
1052    #[tokio::test]
1053    async fn handle_methods_error_on_dropped_reply_channel() {
1054        let (tx, mut rx) = mpsc::channel(16);
1055        let handle = RouteControllerHandle {
1056            tx,
1057            cohort: Arc::new(CohortActivationGate::new_closed()),
1058        };
1059
1060        let count_task = tokio::spawn({
1061            let h = handle.clone();
1062            async move { h.route_count().await }
1063        });
1064        let cmd = timeout(Duration::from_secs(2), rx.recv())
1065            .await
1066            .expect("route_count command within 2s")
1067            .expect("command channel alive");
1068        match cmd {
1069            RouteControllerCommand::RouteCount { reply } => drop(reply),
1070            _ => panic!("unexpected command"),
1071        }
1072        assert!(matches!(
1073            count_task.await.expect("join"),
1074            Err(CamelError::ProcessorError(_))
1075        ));
1076
1077        let stop_task = tokio::spawn({
1078            let h = handle.clone();
1079            async move { h.stop_route("x").await }
1080        });
1081        let cmd = timeout(Duration::from_secs(2), rx.recv())
1082            .await
1083            .expect("stop command within 2s")
1084            .expect("command channel alive");
1085        match cmd {
1086            RouteControllerCommand::StopRoute { reply, .. } => drop(reply),
1087            _ => panic!("unexpected command"),
1088        }
1089        assert!(matches!(
1090            stop_task.await.expect("join"),
1091            Err(CamelError::ProcessorError(_))
1092        ));
1093
1094        let maybe_hash = tokio::spawn({
1095            let h = handle.clone();
1096            async move { h.route_source_hash("x").await }
1097        });
1098        let cmd = timeout(Duration::from_secs(2), rx.recv())
1099            .await
1100            .expect("hash command within 2s")
1101            .expect("command channel alive");
1102        match cmd {
1103            RouteControllerCommand::RouteSourceHash { reply, .. } => drop(reply),
1104            _ => panic!("unexpected command"),
1105        }
1106        assert_eq!(maybe_hash.await.expect("join"), None);
1107    }
1108
1109    #[test]
1110    fn try_set_function_invoker_returns_mailbox_full() {
1111        let (tx, mut rx) = mpsc::channel(1);
1112        tx.try_send(RouteControllerCommand::Shutdown)
1113            .expect("fill mailbox");
1114        let handle = RouteControllerHandle {
1115            tx,
1116            cohort: Arc::new(CohortActivationGate::new_closed()),
1117        };
1118
1119        let result = handle.try_set_function_invoker(Arc::new(NoopInvoker));
1120        assert!(matches!(result, Err(CamelError::ProcessorError(_))));
1121
1122        rx.try_recv().expect("mailbox still has first message");
1123    }
1124
1125    #[tokio::test]
1126    async fn test_restart_does_not_block_other_routes() {
1127        // D-L6: a route's restart (stop + 100ms sleep + start) must not block
1128        // commands for other routes. Without the fix, start_c would wait
1129        // behind restart's 100ms sleep in the actor mailbox.
1130        let (handle, join_handle) = build_actor_with_components();
1131        handle
1132            .add_route(route_def("route-a", "timer:tick?period=100"))
1133            .await
1134            .expect("add route-a");
1135        handle
1136            .add_route(route_def("route-b", "timer:tick?period=100"))
1137            .await
1138            .expect("add route-b");
1139        handle
1140            .add_route(route_def("route-c", "timer:tick?period=100"))
1141            .await
1142            .expect("add route-c");
1143
1144        handle.start_route("route-a").await.expect("start route-a");
1145        handle.start_route("route-b").await.expect("start route-b");
1146
1147        // Spawn restart on its own task so its RestartRoute command is
1148        // guaranteed to land in the mailbox before start_c is enqueued.
1149        let restart_handle = handle.clone();
1150        let restart_fut =
1151            tokio::spawn(async move { restart_handle.restart_route("route-a").await });
1152
1153        // Yield once so restart's send completes and RestartRoute is queued first.
1154        tokio::task::yield_now().await;
1155
1156        // Now issue start_c — its StartRoute command is queued behind RestartRoute.
1157        let start_c_t0 = std::time::Instant::now();
1158        let start_result = handle.start_route("route-c").await;
1159        let start_c_elapsed = start_c_t0.elapsed();
1160
1161        assert!(start_result.is_ok(), "route-c start should succeed");
1162        assert!(
1163            start_c_elapsed < Duration::from_millis(80),
1164            "route-c start blocked by route-a restart (took {start_c_elapsed:?}); \
1165             expected to complete before restart's 100ms sleep"
1166        );
1167
1168        let restart_result = restart_fut.await.expect("restart join"); // allow-test-wait: restart task finite; join asserted
1169        assert!(restart_result.is_ok(), "route-a restart should succeed");
1170
1171        handle.shutdown().await.expect("shutdown send");
1172        join_handle.await.expect("actor join");
1173    }
1174
1175    #[tokio::test]
1176    async fn test_command_to_restarting_route_is_rejected() {
1177        // D-L6: while a route is restarting, mutating commands for that route
1178        // must be rejected.
1179        let (handle, join_handle) = build_actor_with_components();
1180        handle
1181            .add_route(route_def("route-a", "timer:tick?period=100"))
1182            .await
1183            .expect("add route-a");
1184        handle.start_route("route-a").await.expect("start route-a");
1185
1186        // Spawn restart on its own task so the RestartRoute command is
1187        // guaranteed to land in the mailbox.
1188        let restart_handle = handle.clone();
1189        let restart_fut =
1190            tokio::spawn(async move { restart_handle.restart_route("route-a").await });
1191
1192        // Give the actor time to process the Restart and enter the 100ms sleep.
1193        // After the inline stop completes, the actor returns to rx.recv() and
1194        // the spawned task is sleeping — route-a is in the restarting set.
1195        tokio::time::sleep(Duration::from_millis(20)).await;
1196
1197        // While restarting, try to stop route-a — must be rejected.
1198        let stop_result = handle.stop_route("route-a").await;
1199        assert!(
1200            stop_result.is_err(),
1201            "stop during restart should be rejected"
1202        );
1203        assert!(
1204            stop_result.unwrap_err().to_string().contains("restarting"),
1205            "error should mention restarting"
1206        );
1207
1208        // Now the restart completes.
1209        let restart_result = restart_fut.await.expect("restart join"); // allow-test-wait: restart task finite; join asserted
1210        assert!(restart_result.is_ok(), "route-a restart should succeed");
1211
1212        handle.shutdown().await.expect("shutdown send");
1213        join_handle.await.expect("actor join");
1214    }
1215}