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