Skip to main content

camel_core/lifecycle/adapters/
route_controller.rs

1//! Default implementation of RouteController.
2//!
3//! This module provides [`DefaultRouteController`], which manages route lifecycle
4//! including starting, stopping, suspending, and resuming routes.
5
6use std::collections::HashMap;
7use std::sync::{Arc, Weak};
8use std::time::Duration;
9
10use tokio::sync::mpsc;
11use tokio_util::sync::CancellationToken;
12use tower::{Layer, ServiceExt};
13use tracing::{debug, info, warn};
14
15use camel_api::error_handler::ErrorHandlerConfig;
16use camel_api::metrics::MetricsCollector;
17#[allow(unused_imports)]
18use camel_api::{
19    BoxProcessor, CamelError, Exchange, FunctionInvoker, IdentityProcessor, NoOpMetrics,
20    NoopPlatformService, PlatformService, ProducerContext, RouteController, RuntimeHandle,
21    StepLifecycle,
22};
23use camel_component_api::{Consumer, ConsumerContext, consumer::ExchangeEnvelope};
24use camel_processor::aggregator::AggregatorService;
25pub use camel_processor::aggregator::SharedLanguageRegistry;
26
27use crate::health_registry::HealthCheckRegistry;
28use crate::lifecycle::adapters::controller_component_context::ControllerComponentContext;
29use crate::lifecycle::adapters::route_compiler_ext::{RouteCompilerExt, build_eh_config_pipeline};
30use crate::lifecycle::adapters::route_helpers::{
31    AggregateSplitInfo, CrashNotification, ManagedRoute, assert_no_mixed_top_level_splits,
32    handle_is_running, inferred_lifecycle_label, is_pending,
33};
34#[cfg(test)]
35pub(super) use crate::lifecycle::adapters::route_helpers::{
36    emit_start_route_event, set_start_route_event_hook,
37};
38use crate::lifecycle::adapters::route_registry::RouteRegistry;
39use crate::lifecycle::adapters::route_runtime_state;
40use crate::lifecycle::adapters::step_compilers::CompiledStep;
41use crate::lifecycle::application::route_definition::{BuilderStep, RouteDefinition};
42pub(crate) use crate::lifecycle::domain::CompiledPipeline;
43use crate::shared::components::domain::Registry;
44use crate::shared::observability::domain::{DetailLevel, TracerConfig};
45use camel_bean::BeanRegistry;
46
47/// Default implementation of [`RouteController`].
48///
49/// Manages route lifecycle with support for:
50/// - Starting/stopping individual routes
51/// - Suspending and resuming routes
52/// - Auto-startup with startup ordering
53/// - Graceful shutdown
54pub struct DefaultRouteController {
55    /// Routes indexed by route ID.
56    pub(super) routes: RouteRegistry,
57    /// Reference to the component registry for resolving endpoints.
58    pub(super) registry: Arc<std::sync::Mutex<Registry>>,
59    /// Shared language registry for resolving declarative language expressions.
60    pub(super) languages: SharedLanguageRegistry,
61    /// Bean registry for bean method invocation.
62    pub(super) beans: Arc<std::sync::Mutex<BeanRegistry>>,
63    /// Runtime handle injected into ProducerContext for command/query operations.
64    pub(super) runtime: Option<Weak<dyn RuntimeHandle>>,
65    /// Optional global error handler applied to all routes without a per-route handler.
66    pub(super) global_error_handler: Option<ErrorHandlerConfig>,
67    /// Optional crash notifier for supervision.
68    pub(super) crash_notifier: Option<mpsc::Sender<CrashNotification>>,
69    /// Whether tracing is enabled for route pipelines.
70    pub(super) tracing_enabled: bool,
71    /// Detail level for tracing when enabled.
72    pub(super) tracer_detail_level: DetailLevel,
73    /// Metrics collector for tracing processor.
74    pub(super) tracer_metrics: Option<Arc<dyn MetricsCollector>>,
75    pub(super) platform_service: Arc<dyn PlatformService>,
76    pub(super) function_invoker: Option<Arc<dyn FunctionInvoker>>,
77    pub(super) health_registry: Option<Arc<HealthCheckRegistry>>,
78    /// Shared idempotent repository registry. Defaults to an empty registry;
79    /// the CamelContext builder installs a populated handle that includes the
80    /// built-in `"memory"` repository.
81    pub(super) idempotent_repositories: crate::SharedIdempotentRegistry,
82    pub(super) claim_check_repositories: crate::SharedClaimCheckRegistry,
83    pub(super) cache_repositories: crate::SharedCacheRegistry,
84    /// F2 staging: prepared-but-not-inserted ManagedRoutes keyed by route_id.
85    /// `prepare_*` writes here; `insert_prepared_route` drains via `remove()`.
86    /// On insert-failure error paths, the caller (`reload_actions.rs`) must
87    /// explicitly drain to avoid orphan CancellationToken/SharedPipeline leaks.
88    pub(super) prepared_staging: HashMap<String, ManagedRoute>,
89    /// Source endpoint URI to route_id index (one-to-many).
90    pub(super) endpoint_index: super::endpoint_index::EndpointIndex,
91}
92
93impl DefaultRouteController {
94    pub(super) fn health_registry(&self) -> Arc<HealthCheckRegistry> {
95        self.health_registry.clone().unwrap_or_else(|| {
96            debug!("health_registry not configured — creating isolated fallback");
97            Arc::new(HealthCheckRegistry::new(Duration::from_secs(5)))
98        })
99    }
100
101    /// Create a new `DefaultRouteController` with the given registry.
102    pub fn new(
103        registry: Arc<std::sync::Mutex<Registry>>,
104        platform_service: Arc<dyn PlatformService>,
105    ) -> Self {
106        Self::with_beans_and_platform_service(
107            registry,
108            Arc::new(std::sync::Mutex::new(BeanRegistry::new())),
109            platform_service,
110        )
111    }
112
113    /// Create a new `DefaultRouteController` with shared bean registry.
114    pub fn with_beans(
115        registry: Arc<std::sync::Mutex<Registry>>,
116        beans: Arc<std::sync::Mutex<BeanRegistry>>,
117    ) -> Self {
118        Self::with_beans_and_platform_service(
119            registry,
120            beans,
121            Arc::new(NoopPlatformService::default()),
122        )
123    }
124
125    fn with_beans_and_platform_service(
126        registry: Arc<std::sync::Mutex<Registry>>,
127        beans: Arc<std::sync::Mutex<BeanRegistry>>,
128        platform_service: Arc<dyn PlatformService>,
129    ) -> Self {
130        Self {
131            routes: RouteRegistry::new(),
132            registry,
133            languages: Arc::new(std::sync::Mutex::new(HashMap::new())),
134            beans,
135            runtime: None,
136            global_error_handler: None,
137            crash_notifier: None,
138            tracing_enabled: false,
139            tracer_detail_level: DetailLevel::Minimal,
140            tracer_metrics: None,
141            platform_service,
142            function_invoker: None,
143            health_registry: None,
144            idempotent_repositories: Arc::new(crate::IdempotentRegistry::new()),
145            claim_check_repositories: Arc::new(crate::ClaimCheckRegistry::new()),
146            cache_repositories: Arc::new(crate::CacheRegistry::new()),
147            prepared_staging: HashMap::new(),
148            endpoint_index: super::endpoint_index::EndpointIndex::new(),
149        }
150    }
151
152    /// Create a new `DefaultRouteController` with shared language registry.
153    pub fn with_languages(
154        registry: Arc<std::sync::Mutex<Registry>>,
155        languages: SharedLanguageRegistry,
156        platform_service: Arc<dyn PlatformService>,
157    ) -> Self {
158        Self {
159            routes: RouteRegistry::new(),
160            registry,
161            languages,
162            beans: Arc::new(std::sync::Mutex::new(BeanRegistry::new())),
163            runtime: None,
164            global_error_handler: None,
165            crash_notifier: None,
166            tracing_enabled: false,
167            tracer_detail_level: DetailLevel::Minimal,
168            tracer_metrics: None,
169            platform_service,
170            function_invoker: None,
171            health_registry: None,
172            idempotent_repositories: Arc::new(crate::IdempotentRegistry::new()),
173            claim_check_repositories: Arc::new(crate::ClaimCheckRegistry::new()),
174            cache_repositories: Arc::new(crate::CacheRegistry::new()),
175            prepared_staging: HashMap::new(),
176            endpoint_index: super::endpoint_index::EndpointIndex::new(),
177        }
178    }
179
180    pub fn with_languages_and_beans(
181        registry: Arc<std::sync::Mutex<Registry>>,
182        languages: SharedLanguageRegistry,
183        platform_service: Arc<dyn PlatformService>,
184        beans: Arc<std::sync::Mutex<BeanRegistry>>,
185    ) -> Self {
186        Self {
187            routes: RouteRegistry::new(),
188            registry,
189            languages,
190            beans,
191            runtime: None,
192            global_error_handler: None,
193            crash_notifier: None,
194            tracing_enabled: false,
195            tracer_detail_level: DetailLevel::Minimal,
196            tracer_metrics: None,
197            platform_service,
198            function_invoker: None,
199            health_registry: None,
200            idempotent_repositories: Arc::new(crate::IdempotentRegistry::new()),
201            claim_check_repositories: Arc::new(crate::ClaimCheckRegistry::new()),
202            cache_repositories: Arc::new(crate::CacheRegistry::new()),
203            prepared_staging: HashMap::new(),
204            endpoint_index: super::endpoint_index::EndpointIndex::new(),
205        }
206    }
207
208    pub fn with_function_invoker(mut self, function_invoker: Arc<dyn FunctionInvoker>) -> Self {
209        self.function_invoker = Some(function_invoker);
210        self
211    }
212
213    pub(crate) fn set_idempotent_repositories(
214        &mut self,
215        repositories: crate::SharedIdempotentRegistry,
216    ) {
217        self.idempotent_repositories = repositories;
218    }
219
220    pub(crate) fn set_claim_check_repositories(
221        &mut self,
222        repositories: crate::SharedClaimCheckRegistry,
223    ) {
224        self.claim_check_repositories = repositories;
225    }
226
227    pub(crate) fn set_cache_repositories(&mut self, repositories: crate::SharedCacheRegistry) {
228        self.cache_repositories = repositories;
229    }
230
231    pub fn set_health_registry(&mut self, registry: Arc<HealthCheckRegistry>) {
232        self.health_registry = Some(registry);
233    }
234
235    pub fn set_function_invoker(&mut self, invoker: Arc<dyn FunctionInvoker>) {
236        self.function_invoker = Some(invoker);
237    }
238
239    /// Set runtime handle for ProducerContext creation.
240    pub fn set_runtime_handle(&mut self, runtime: Arc<dyn RuntimeHandle>) {
241        self.runtime = Some(Arc::downgrade(&runtime));
242    }
243
244    /// Set the crash notifier for supervision.
245    ///
246    /// When set, the controller will send a `CrashNotification` whenever
247    /// a consumer crashes.
248    pub fn set_crash_notifier(&mut self, tx: mpsc::Sender<CrashNotification>) {
249        self.crash_notifier = Some(tx);
250    }
251
252    /// Set a global error handler applied to all routes without a per-route handler.
253    pub fn set_error_handler(&mut self, config: ErrorHandlerConfig) {
254        self.global_error_handler = Some(config);
255    }
256
257    /// Configure tracing for this route controller.
258    pub fn set_tracer_config(&mut self, config: &TracerConfig) {
259        self.tracing_enabled = config.enabled;
260        self.tracer_detail_level = config.detail_level.clone();
261        self.tracer_metrics = config.metrics_collector.clone();
262    }
263
264    fn build_producer_context(&self, route_id: &str) -> Result<ProducerContext, CamelError> {
265        let mut producer_ctx = ProducerContext::new().with_route_id(route_id);
266        if let Some(runtime) = self.runtime.as_ref().and_then(Weak::upgrade) {
267            producer_ctx = producer_ctx.with_runtime(runtime);
268        }
269        Ok(producer_ctx)
270    }
271
272    /// Create a transient [`RouteCompilerExt`] from this controller's fields.
273    fn route_compiler_ext(&self) -> RouteCompilerExt<'_> {
274        RouteCompilerExt {
275            registry: &self.registry,
276            languages: &self.languages,
277            beans: &self.beans,
278            function_invoker: &self.function_invoker,
279            tracing_enabled: self.tracing_enabled,
280            tracer_detail_level: &self.tracer_detail_level,
281            tracer_metrics: &self.tracer_metrics,
282            platform_service: &self.platform_service,
283            runtime: &self.runtime,
284            global_error_handler: &self.global_error_handler,
285            health_registry: &self.health_registry,
286            route_registry: &self.routes,
287            idempotent_repositories: Arc::clone(&self.idempotent_repositories),
288            claim_check_repositories: Arc::clone(&self.claim_check_repositories),
289            cache_repositories: Arc::clone(&self.cache_repositories),
290        }
291    }
292
293    /// Resolve BuilderSteps into BoxProcessors.
294    #[allow(dead_code)] // used by tests and may be needed for future split paths
295    pub(crate) fn resolve_steps(
296        &self,
297        steps: Vec<BuilderStep>,
298        producer_ctx: &ProducerContext,
299        registry: &Arc<std::sync::Mutex<Registry>>,
300        route_id: Option<&str>,
301        staging_mode: &super::step_resolution::FunctionStagingMode,
302    ) -> Result<Vec<CompiledStep>, CamelError> {
303        let component_ctx = Arc::new(ControllerComponentContext::new(
304            Arc::clone(registry),
305            Arc::clone(&self.languages),
306            self.tracer_metrics
307                .clone()
308                .unwrap_or_else(|| Arc::new(NoOpMetrics)),
309            Arc::clone(&self.platform_service),
310            self.health_registry(),
311            route_id.map(|s| s.to_string()),
312        ));
313        let rt: Arc<dyn camel_component_api::RuntimeObservability> =
314            Arc::clone(&component_ctx) as Arc<_>;
315
316        super::step_resolution::resolve_steps(
317            steps,
318            producer_ctx,
319            rt,
320            registry,
321            &self.languages,
322            &self.beans,
323            self.function_invoker.clone(),
324            component_ctx,
325            route_id,
326            staging_mode,
327            &self.idempotent_repositories,
328            &self.claim_check_repositories,
329            &self.cache_repositories,
330        )
331    }
332
333    /// Add a route definition to the controller.
334    ///
335    /// Steps are resolved immediately using the registry.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if:
340    /// - A route with the same ID already exists
341    /// - Step resolution fails
342    pub async fn add_route(&mut self, definition: RouteDefinition) -> Result<(), CamelError> {
343        let route_id = definition.route_id().to_string();
344        let from_uri = definition.from_uri().to_string();
345
346        if self.routes.contains_key(&route_id) {
347            return Err(CamelError::RouteError(format!(
348                "duplicate route ID '{route_id}'"
349            )));
350        }
351
352        debug!(route_id = %route_id, "Adding route to controller");
353
354        let managed = match self.build_managed_route(
355            definition,
356            &super::step_resolution::FunctionStagingMode::DirectAdd,
357        ) {
358            Ok(managed) => managed,
359            Err(err) => {
360                self.discard_function_staging();
361                return Err(err);
362            }
363        };
364
365        if let Some(invoker) = &self.function_invoker
366            && let Err(err) = invoker.commit_staged().await
367        {
368            invoker.discard_staging(0);
369            return Err(CamelError::Config(err.to_string()));
370        }
371
372        self.routes
373            .insert(managed.definition.route_id().to_string(), managed);
374
375        self.endpoint_index.insert(&from_uri, &route_id);
376        Ok(())
377    }
378
379    pub(super) fn build_managed_route(
380        &self,
381        definition: RouteDefinition,
382        staging_mode: &super::step_resolution::FunctionStagingMode,
383    ) -> Result<ManagedRoute, CamelError> {
384        let route_id = definition.route_id().to_string();
385
386        let definition_info = definition.to_info();
387        let RouteDefinition {
388            from_uri,
389            steps,
390            error_handler,
391            circuit_breaker,
392            security_policy,
393            security_authenticator,
394            unit_of_work,
395            concurrency,
396            ..
397        } = definition;
398
399        let producer_ctx = self.build_producer_context(&route_id)?;
400
401        // N2: reject mixed Aggregate + Resequence top-level splits
402        assert_no_mixed_top_level_splits(&steps)?;
403
404        let (aggregate_split, processors_with_contracts) = self
405            .route_compiler_ext()
406            .detect_and_validate_route_split(steps, &producer_ctx, &route_id, staging_mode)?;
407        let lifecycle = super::route_helpers::collect_lifecycle(&processors_with_contracts);
408        let route_id_for_tracing = route_id.clone();
409        let eh_config = error_handler.or_else(|| self.global_error_handler.clone());
410
411        let mut pipeline = build_eh_config_pipeline(
412            eh_config.as_ref(),
413            Arc::clone(&self.registry),
414            Arc::clone(&self.languages),
415            self.tracer_metrics.clone(),
416            Arc::clone(&self.platform_service),
417            self.health_registry(),
418            &route_id_for_tracing,
419            &producer_ctx,
420            processors_with_contracts,
421            self.tracing_enabled,
422            self.tracer_detail_level.clone(),
423            security_policy.clone(),
424            circuit_breaker,
425        )?;
426
427        let uow_counter = if let Some(uow_config) = &unit_of_work {
428            let component_ctx = Arc::new(ControllerComponentContext::new(
429                Arc::clone(&self.registry),
430                Arc::clone(&self.languages),
431                self.tracer_metrics
432                    .clone()
433                    .unwrap_or_else(|| Arc::new(NoOpMetrics)),
434                Arc::clone(&self.platform_service),
435                self.health_registry(),
436                Some(route_id.clone()),
437            ));
438            let rt: Arc<dyn camel_component_api::RuntimeObservability> =
439                Arc::clone(&component_ctx) as Arc<_>;
440            let (uow_layer, counter) = super::route_compiler_ext::resolve_uow_layer(
441                uow_config,
442                &producer_ctx,
443                rt,
444                component_ctx.as_ref(),
445                None,
446            )?;
447            pipeline = BoxProcessor::new(uow_layer.layer(pipeline));
448            Some(counter)
449        } else {
450            None
451        };
452
453        Ok(ManagedRoute {
454            definition: definition_info,
455            from_uri,
456            pipeline: super::pipeline_runtime::new_shared_pipeline_with_lifecycle(
457                pipeline, lifecycle,
458            ),
459            concurrency,
460            consumer_handle: None,
461            pipeline_handle: None,
462            consumer_cancel_token: CancellationToken::new(),
463            pipeline_cancel_token: CancellationToken::new(),
464            channel_sender: None,
465            in_flight: uow_counter,
466            drain_in_flight: Arc::new(std::sync::atomic::AtomicU64::new(0)),
467            aggregate_split,
468            agg_service: None,
469            compiled: route_runtime_state::CompiledRoute {
470                security_policy,
471                security_authenticator,
472            },
473        })
474    }
475
476    pub async fn add_route_with_generation(
477        &mut self,
478        definition: RouteDefinition,
479        generation: u64,
480    ) -> Result<(), CamelError> {
481        let route_id = definition.route_id().to_string();
482        let from_uri = definition.from_uri().to_string();
483
484        if self.routes.contains_key(&route_id) {
485            return Err(CamelError::RouteError(format!(
486                "duplicate route ID '{route_id}'"
487            )));
488        }
489
490        debug!(route_id = %route_id, generation, "Adding route to controller with generation");
491
492        let managed = self.build_managed_route(
493            definition,
494            &super::step_resolution::FunctionStagingMode::HotReload { generation },
495        )?;
496
497        self.routes.insert(route_id.clone(), managed);
498
499        self.endpoint_index.insert(&from_uri, &route_id);
500        Ok(())
501    }
502
503    pub async fn remove_route_preserving_functions(
504        &mut self,
505        route_id: &str,
506    ) -> Result<(), CamelError> {
507        let managed = self.routes.get(route_id).ok_or_else(|| {
508            CamelError::RouteError(format!("Route '{}' not found for removal", route_id))
509        })?;
510        if handle_is_running(&managed.consumer_handle)
511            || handle_is_running(&managed.pipeline_handle)
512        {
513            return Err(CamelError::RouteError(format!(
514                "Route '{}' must be stopped before removal (current execution lifecycle: {})",
515                route_id,
516                inferred_lifecycle_label(managed)
517            )));
518        }
519        self.routes.remove(route_id);
520        if let Some(reg) = &self.health_registry {
521            reg.unregister_for_route(route_id);
522        }
523        self.endpoint_index.remove(route_id);
524        debug!(route_id = %route_id, "Route removed from controller (functions preserved for reload finalize)");
525        Ok(())
526    }
527
528    /// Compile a route definition into a processor pipeline, without adding it
529    /// to the controller. Used for validation and testing.
530    pub fn compile_route_definition(
531        &self,
532        def: RouteDefinition,
533    ) -> Result<BoxProcessor, CamelError> {
534        self.route_compiler_ext().compile_route_definition(def)
535    }
536
537    /// Compile a route definition with a specific generation (for hot-reload).
538    pub fn compile_route_definition_with_generation(
539        &self,
540        def: RouteDefinition,
541        generation: u64,
542    ) -> Result<BoxProcessor, CamelError> {
543        self.route_compiler_ext()
544            .compile_route_definition_with_generation(def, generation)
545    }
546
547    /// Compile a route definition into a [`CompiledPipeline`] (processor +
548    /// lifecycle handles). Used by the hot-reload Restart path so that
549    /// lifecycle handles are threaded through
550    /// [`swap_pipeline_raw`](Self::swap_pipeline_raw).
551    pub(crate) fn compile_route_definition_pipeline(
552        &self,
553        def: RouteDefinition,
554        generation: u64,
555    ) -> Result<CompiledPipeline, CamelError> {
556        self.route_compiler_ext()
557            .compile_route_definition_pipeline(def, generation)
558    }
559
560    /// Compile without function generation, returning full [`CompiledPipeline`].
561    ///
562    /// Oracle Fix 1: used by the stateless hot-reload path so that
563    /// lifecycle-bearing routes have their handles preserved.
564    pub(crate) fn compile_route_definition_dry_pipeline(
565        &self,
566        def: RouteDefinition,
567    ) -> Result<CompiledPipeline, CamelError> {
568        self.route_compiler_ext()
569            .compile_route_definition_dry_pipeline(def)
570    }
571
572    /// Remove a route from the controller map.
573    ///
574    /// The route **must** be stopped before removal (status `Stopped` or `Failed`).
575    /// Returns an error if the route is still running or does not exist.
576    /// Does not cancel any running tasks — call `stop_route` first.
577    pub async fn remove_route(&mut self, route_id: &str) -> Result<(), CamelError> {
578        let managed = self.routes.get(route_id).ok_or_else(|| {
579            CamelError::RouteError(format!("Route '{}' not found for removal", route_id))
580        })?;
581        if handle_is_running(&managed.consumer_handle)
582            || handle_is_running(&managed.pipeline_handle)
583        {
584            return Err(CamelError::RouteError(format!(
585                "Route '{}' must be stopped before removal (current execution lifecycle: {})",
586                route_id,
587                inferred_lifecycle_label(managed)
588            )));
589        }
590        if let Some(invoker) = &self.function_invoker {
591            for (id, rid) in self.collect_function_refs(route_id) {
592                if let Err(e) = invoker.unregister(&id, rid.as_deref()).await {
593                    warn!(route_id = %route_id, error = %e, "Failed to unregister function during route removal");
594                }
595            }
596        }
597        self.routes.remove(route_id);
598        if let Some(reg) = &self.health_registry {
599            reg.unregister_for_route(route_id);
600        }
601        self.endpoint_index.remove(route_id);
602        info!(route_id = %route_id, "Route removed from controller");
603        Ok(())
604    }
605
606    fn collect_function_refs(
607        &self,
608        route_id: &str,
609    ) -> Vec<(camel_api::FunctionId, Option<String>)> {
610        self.function_invoker
611            .as_ref()
612            .map(|invoker| invoker.function_refs_for_route(route_id))
613            .unwrap_or_default()
614    }
615
616    fn discard_function_staging(&self) {
617        if let Some(invoker) = &self.function_invoker {
618            invoker.discard_staging(0);
619        }
620    }
621
622    /// Returns the number of routes in the controller.
623    pub fn route_count(&self) -> usize {
624        self.routes.route_count()
625    }
626
627    pub fn in_flight_count(&self, route_id: &str) -> Option<u64> {
628        self.routes.in_flight_count(route_id)
629    }
630
631    /// Returns `true` if a route with the given ID exists.
632    pub fn route_exists(&self, route_id: &str) -> bool {
633        self.routes.route_exists(route_id)
634    }
635
636    /// Returns all route IDs.
637    pub fn route_ids(&self) -> Vec<String> {
638        self.routes.route_ids()
639    }
640
641    pub fn route_source_hash(&self, route_id: &str) -> Option<u64> {
642        self.routes.route_source_hash(route_id)
643    }
644
645    /// Returns route IDs that should auto-start, sorted by startup order (ascending).
646    pub fn auto_startup_route_ids(&self) -> Vec<String> {
647        self.routes.auto_startup_route_ids()
648    }
649
650    /// Returns route IDs sorted by shutdown order (startup order descending).
651    pub fn shutdown_route_ids(&self) -> Vec<String> {
652        self.routes.shutdown_route_ids()
653    }
654
655    /// Atomically swap the pipeline of a route (zero-downtime).
656    ///
657    /// In-flight requests finish with the old pipeline (kept alive by Arc).
658    /// New requests immediately use the new pipeline.
659    ///
660    /// ## Rejection policy
661    ///
662    /// Returns an error if the route has lifecycle-bearing steps or an active
663    /// aggregate — these require the **Restart path** (stop → swap → start).
664    ///
665    /// The caller (e.g. `reload_actions::apply_swap`) MUST catch this rejection
666    /// and fall back to:
667    /// 1. `stop_route_reload` — drain lifecycle, stop consumer
668    /// 2. `swap_pipeline_raw` — bypass the lifecycle check (route is stopped)
669    /// 3. `start_route_reload` — re-create consumer with the new pipeline
670    ///
671    /// This is the "reject, don't defer" policy (oracle Fix 3): the swap is
672    /// refused upfront rather than silently deferring or partially swapping.
673    pub fn swap_pipeline(
674        &self,
675        route_id: &str,
676        new_pipeline: BoxProcessor,
677    ) -> Result<(), CamelError> {
678        let managed = self
679            .routes
680            .get(route_id)
681            .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
682
683        let assembly = managed.pipeline.load();
684        let has_lifecycle = !assembly.lifecycle.is_empty();
685
686        if has_lifecycle || managed.agg_service.is_some() {
687            warn!(
688                route_id = %route_id,
689                "Hot-swap rejected — route has lifecycle/agg steps; use Restart path"
690            );
691            return Err(CamelError::RouteError(format!(
692                "Route '{}' contains stateful steps (lifecycle-bearing). Hot-swap not supported — use restart.",
693                route_id
694            )));
695        }
696
697        drop(assembly);
698
699        if managed.aggregate_split.is_some() {
700            warn!(
701                route_id = %route_id,
702                "swap_pipeline: aggregate routes with timeout do not support hot-reload of pre/post segments"
703            );
704        }
705
706        super::pipeline_runtime::swap_pipeline_raw(&managed.pipeline, new_pipeline, vec![]);
707        debug!(route_id = %route_id, "Pipeline swapped atomically");
708        Ok(())
709    }
710
711    /// Non-checking raw pipeline swap — bypasses lifecycle/aggregate rejection.
712    ///
713    /// Only for use after the route has been stopped (Restart path).
714    /// Does NOT check for lifecycle handles or aggregate service — the caller
715    /// is responsible for ensuring the route is safe to swap.
716    ///
717    /// Accepts `lifecycle` so that the new pipeline assembly records the
718    /// lifecycle handles from the compiled steps.  When the route is
719    /// subsequently stopped, these handles are drained.
720    pub(crate) fn swap_pipeline_raw(
721        &self,
722        route_id: &str,
723        new_pipeline: BoxProcessor,
724        lifecycle: Vec<Arc<dyn StepLifecycle>>,
725    ) -> Result<(), CamelError> {
726        let managed = self
727            .routes
728            .get(route_id)
729            .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
730        super::pipeline_runtime::swap_pipeline_raw(&managed.pipeline, new_pipeline, lifecycle);
731        debug!(route_id = %route_id, "Pipeline swapped (raw — lifecycle bypass)");
732        Ok(())
733    }
734
735    /// Returns the from_uri of a route, if it exists.
736    pub fn route_from_uri(&self, route_id: &str) -> Option<String> {
737        self.routes.route_from_uri(route_id)
738    }
739
740    /// Return all route_ids that consume from the given source endpoint URI.
741    pub fn routes_for_endpoint(&self, uri: &str) -> Vec<String> {
742        self.endpoint_index.routes_for(uri)
743    }
744
745    /// Return all registered source endpoint URIs.
746    pub fn list_endpoint_uris(&self) -> Vec<String> {
747        self.endpoint_index.list_uris()
748    }
749
750    /// Get a clone of the current pipeline for a route.
751    ///
752    /// This is useful for testing and introspection.
753    /// Returns `None` if the route doesn't exist.
754    pub fn get_pipeline(&self, route_id: &str) -> Option<BoxProcessor> {
755        self.routes.get_pipeline(route_id)
756    }
757
758    /// Check whether the running route has lifecycle-bearing steps.
759    ///
760    /// Returns `false` when the route is missing.
761    pub(crate) fn route_has_lifecycle(&self, route_id: &str) -> bool {
762        self.routes
763            .get(route_id)
764            .map(|managed| !managed.pipeline.load().lifecycle.is_empty())
765            .unwrap_or(false)
766    }
767
768    /// Internal stop implementation that can set custom status.
769    pub(super) async fn stop_route_internal(&mut self, route_id: &str) -> Result<(), CamelError> {
770        self.routes.stop_route(route_id).await
771    }
772
773    pub async fn start_route_reload(&mut self, route_id: &str) -> Result<(), CamelError> {
774        self.start_route(route_id).await
775    }
776
777    pub async fn stop_route_reload(&mut self, route_id: &str) -> Result<(), CamelError> {
778        self.stop_route(route_id).await
779    }
780}
781
782// ── Aggregator route helpers ──
783
784impl DefaultRouteController {
785    /// Start a route with an aggregate split (pre-pipeline → aggregator → post-pipeline).
786    ///
787    /// Spawns a biased-select forward loop that routes exchanges through the
788    /// pre-pipeline, aggregator, and post-pipeline in sequence, with late-exchange
789    /// handling and force-completion on stop.
790    #[allow(clippy::too_many_arguments)]
791    pub(super) async fn start_aggregate_route(
792        &mut self,
793        route_id: &str,
794        split: AggregateSplitInfo,
795        consumer: Box<dyn Consumer>,
796        consumer_ctx: ConsumerContext,
797        mut rx: mpsc::Receiver<ExchangeEnvelope>,
798        crash_notifier: Option<mpsc::Sender<CrashNotification>>,
799        runtime_for_consumer: Option<Weak<dyn RuntimeHandle>>,
800        tx_for_storage: mpsc::Sender<ExchangeEnvelope>,
801        // Pipeline cancellation — a child of the managed route's pipeline_cancel_token.
802        pipeline_cancel: CancellationToken,
803        drain_in_flight: Arc<std::sync::atomic::AtomicU64>,
804    ) -> Result<(), CamelError> {
805        let (late_tx, late_rx) = mpsc::channel::<Exchange>(256);
806
807        let route_cancel_clone = pipeline_cancel.clone();
808        let svc = AggregatorService::new(
809            split.agg_config.clone(),
810            late_tx,
811            Arc::clone(&self.languages),
812            route_cancel_clone,
813        );
814        let agg = Arc::new(svc);
815
816        let pipeline_cancel_for_monitor = pipeline_cancel.clone();
817        let agg_for_monitor = Arc::clone(&agg);
818
819        {
820            let managed = self
821                .routes
822                .get_mut(route_id)
823                .expect("invariant: route must exist"); // allow-unwrap
824            managed.agg_service = Some(Arc::clone(&agg));
825        }
826
827        let late_rx = Arc::new(tokio::sync::Mutex::new(late_rx));
828        let pre_pipeline = split.pre_pipeline;
829        let post_pipeline = split.post_pipeline;
830
831        // Spawn biased select forward loop
832        let pipeline_handle = tokio::spawn(async move {
833            loop {
834                tokio::select! {
835                    biased;
836
837                    late_ex = async {
838                        let mut rx = late_rx.lock().await;
839                        rx.recv().await
840                    } => {
841                        match late_ex {
842                            Some(ex) => {
843                                let pipe = post_pipeline.load();
844                                if let Err(e) = pipe.processor.clone_inner().oneshot(ex).await {
845                                    tracing::warn!(error = %e, "late exchange post-pipeline failed");
846                                }
847                            }
848                            None => return,
849                        }
850                    }
851
852                    envelope_opt = rx.recv() => {
853                        match envelope_opt {
854                            Some(envelope) => {
855                                let ExchangeEnvelope { exchange, reply_tx } = envelope;
856                                let _drain_guard = super::route_helpers::DrainGuard::new(Arc::clone(&drain_in_flight));
857                                let pre_pipe = pre_pipeline.load();
858                                let ex = match pre_pipe.processor.clone_inner().oneshot(exchange).await {
859                                    Ok(ex) => ex,
860                                    Err(e) => {
861                                        if let Some(tx) = reply_tx { let _ = tx.send(Err(e)); }
862                                        continue;
863                                    }
864                                };
865
866                                let ex = {
867                                    let cloned_svc = agg.as_ref().clone();
868                                    cloned_svc.oneshot(ex).await
869                                };
870
871                                match ex {
872                                    Ok(ex) => {
873                                        if !is_pending(&ex) {
874                                            let post_pipe = post_pipeline.load();
875                                            let out = post_pipe.processor.clone_inner().oneshot(ex).await;
876                                            if let Some(tx) = reply_tx { let _ = tx.send(out); }
877                                        } else if let Some(tx) = reply_tx {
878                                            let _ = tx.send(Ok(ex));
879                                        }
880                                    }
881                                    Err(e) => {
882                                        if let Some(tx) = reply_tx { let _ = tx.send(Err(e)); }
883                                    }
884                                }
885                            }
886                            None => return,
887                        }
888                    }
889
890                    _ = pipeline_cancel.cancelled() => {
891                        agg.force_complete_all();
892                        let mut rx_guard = late_rx.lock().await;
893                        while let Ok(late_ex) = rx_guard.try_recv() {
894                            let pipe = post_pipeline.load();
895                            let _ = pipe.processor.clone_inner().oneshot(late_ex).await;
896                        }
897                        break;
898                    }
899                }
900            }
901        });
902        #[cfg(test)]
903        emit_start_route_event("pipeline_spawned");
904
905        // Start consumer after pipeline loop is spawned to avoid startup races
906        // where consumers emit exchanges before the route pipeline begins polling.
907        let (consumer_handle, startup_rx) = super::consumer_management::spawn_consumer_task(
908            route_id.to_string(),
909            consumer,
910            consumer_ctx,
911            crash_notifier,
912            runtime_for_consumer,
913            false,
914        );
915
916        // rc-w1u9: await consumer startup handshake for aggregate routes too
917        // so bind failures surface as route-start errors.
918        // rc-kh7c: on failure, abort the orphaned consumer task and cancel the
919        // pipeline so neither runs detached. The aggregate pipeline loop would
920        // eventually self-clean via rx-drop + late_tx-drop, but cancelling
921        // pipeline_cancel also triggers force_complete_all (aggregate cleanup).
922        if let Err(e) =
923            super::consumer_management::await_consumer_startup(startup_rx, "startup").await
924        {
925            consumer_handle.abort();
926            pipeline_cancel_for_monitor.cancel();
927            return Err(e);
928        }
929
930        // Extend the stored consumer handle through aggregate force-completion.
931        // While this monitor drains pending buckets, handle_is_running still reports
932        // the Route as running because forced exchanges may still be in post-pipeline.
933        let force_on_stop = agg_for_monitor.config().force_completion_on_stop;
934        let consumer_handle = tokio::spawn(async move {
935            let _ = consumer_handle.await;
936            if !pipeline_cancel_for_monitor.is_cancelled() {
937                agg_for_monitor.force_complete_all();
938                if force_on_stop {
939                    pipeline_cancel_for_monitor.cancel();
940                }
941            }
942        });
943        #[cfg(test)]
944        emit_start_route_event("consumer_spawned");
945
946        {
947            let managed = self
948                .routes
949                .get_mut(route_id)
950                .expect("invariant: route must exist"); // allow-unwrap
951            managed.consumer_handle = Some(consumer_handle);
952            managed.pipeline_handle = Some(pipeline_handle);
953            managed.channel_sender = Some(tx_for_storage);
954        }
955
956        info!(route_id = %route_id, "Route started (aggregate with timeout)");
957        Ok(())
958    }
959
960    /// Test-only: inject lifecycle handles into an existing route's pipeline
961    /// assembly.  This makes the route lifecycle-bearing so that swap_pipeline
962    /// rejects it, forcing callers (like reload_actions::apply_swap) to take
963    /// the Restart path instead.
964    #[cfg(test)]
965    pub(crate) fn set_route_lifecycle_for_test(
966        &mut self,
967        route_id: &str,
968        lifecycle: Vec<Arc<dyn StepLifecycle>>,
969    ) -> Result<(), CamelError> {
970        use super::pipeline_runtime::PipelineAssembly;
971        use camel_api::SyncBoxProcessor;
972        use std::sync::Arc;
973
974        let managed = self
975            .routes
976            .get_mut(route_id)
977            .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
978        let old_processor = managed.pipeline.load().processor.clone_inner();
979        managed.pipeline.store(Arc::new(PipelineAssembly::new(
980            SyncBoxProcessor::new(old_processor),
981            lifecycle,
982        )));
983        Ok(())
984    }
985}
986
987#[cfg(test)]
988impl crate::hot_reload::ports::ReloadIntrospectionPort for DefaultRouteController {
989    fn route_ids(&self) -> Vec<String> {
990        self.route_ids() // inherent pub fn
991    }
992    fn route_from_uri(&self, route_id: &str) -> Option<String> {
993        self.route_from_uri(route_id) // inherent pub fn
994    }
995    fn route_source_hash(&self, route_id: &str) -> Option<u64> {
996        self.route_source_hash(route_id) // inherent pub fn
997    }
998}
999
1000#[cfg(test)]
1001#[path = "route_controller_tests.rs"]
1002mod tests;