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                "duplicate route ID '{route_id}'"
339            )));
340        }
341
342        debug!(route_id = %route_id, "Adding route to controller");
343
344        let managed = match self.build_managed_route(
345            definition,
346            &super::step_resolution::FunctionStagingMode::DirectAdd,
347        ) {
348            Ok(managed) => managed,
349            Err(err) => {
350                self.discard_function_staging();
351                return Err(err);
352            }
353        };
354
355        if let Some(invoker) = &self.function_invoker
356            && let Err(err) = invoker.commit_staged().await
357        {
358            invoker.discard_staging(0);
359            return Err(CamelError::Config(err.to_string()));
360        }
361
362        self.routes
363            .insert(managed.definition.route_id().to_string(), managed);
364
365        self.endpoint_index.insert(&from_uri, &route_id);
366        Ok(())
367    }
368
369    pub(super) fn build_managed_route(
370        &self,
371        definition: RouteDefinition,
372        staging_mode: &super::step_resolution::FunctionStagingMode,
373    ) -> Result<ManagedRoute, CamelError> {
374        let route_id = definition.route_id().to_string();
375
376        let definition_info = definition.to_info();
377        let RouteDefinition {
378            from_uri,
379            steps,
380            error_handler,
381            circuit_breaker,
382            security_policy,
383            security_authenticator,
384            unit_of_work,
385            concurrency,
386            ..
387        } = definition;
388
389        let producer_ctx = self.build_producer_context(&route_id)?;
390
391        // N2: reject mixed Aggregate + Resequence top-level splits
392        assert_no_mixed_top_level_splits(&steps)?;
393
394        let (aggregate_split, processors_with_contracts) = self
395            .route_compiler_ext()
396            .detect_and_validate_route_split(steps, &producer_ctx, &route_id, staging_mode)?;
397        let lifecycle = super::route_helpers::collect_lifecycle(&processors_with_contracts);
398        let route_id_for_tracing = route_id.clone();
399        let eh_config = error_handler.or_else(|| self.global_error_handler.clone());
400
401        let mut pipeline = build_eh_config_pipeline(
402            eh_config.as_ref(),
403            Arc::clone(&self.registry),
404            Arc::clone(&self.languages),
405            self.tracer_metrics.clone(),
406            Arc::clone(&self.platform_service),
407            self.health_registry(),
408            &route_id_for_tracing,
409            &producer_ctx,
410            processors_with_contracts,
411            self.tracing_enabled,
412            self.tracer_detail_level.clone(),
413            security_policy.clone(),
414            circuit_breaker,
415        )?;
416
417        let uow_counter = if let Some(uow_config) = &unit_of_work {
418            let component_ctx = Arc::new(ControllerComponentContext::new(
419                Arc::clone(&self.registry),
420                Arc::clone(&self.languages),
421                self.tracer_metrics
422                    .clone()
423                    .unwrap_or_else(|| Arc::new(NoOpMetrics)),
424                Arc::clone(&self.platform_service),
425                self.health_registry(),
426                Some(route_id.clone()),
427            ));
428            let rt: Arc<dyn camel_component_api::RuntimeObservability> =
429                Arc::clone(&component_ctx) as Arc<_>;
430            let (uow_layer, counter) = super::route_compiler_ext::resolve_uow_layer(
431                uow_config,
432                &producer_ctx,
433                rt,
434                component_ctx.as_ref(),
435                None,
436            )?;
437            pipeline = BoxProcessor::new(uow_layer.layer(pipeline));
438            Some(counter)
439        } else {
440            None
441        };
442
443        Ok(ManagedRoute {
444            definition: definition_info,
445            from_uri,
446            pipeline: super::pipeline_runtime::new_shared_pipeline_with_lifecycle(
447                pipeline, lifecycle,
448            ),
449            concurrency,
450            consumer_handle: None,
451            pipeline_handle: None,
452            consumer_cancel_token: CancellationToken::new(),
453            pipeline_cancel_token: CancellationToken::new(),
454            channel_sender: None,
455            in_flight: uow_counter,
456            drain_in_flight: Arc::new(std::sync::atomic::AtomicU64::new(0)),
457            aggregate_split,
458            agg_service: None,
459            compiled: route_runtime_state::CompiledRoute {
460                security_policy,
461                security_authenticator,
462            },
463        })
464    }
465
466    pub async fn add_route_with_generation(
467        &mut self,
468        definition: RouteDefinition,
469        generation: u64,
470    ) -> Result<(), CamelError> {
471        let route_id = definition.route_id().to_string();
472        let from_uri = definition.from_uri().to_string();
473
474        if self.routes.contains_key(&route_id) {
475            return Err(CamelError::RouteError(format!(
476                "duplicate route ID '{route_id}'"
477            )));
478        }
479
480        debug!(route_id = %route_id, generation, "Adding route to controller with generation");
481
482        let managed = self.build_managed_route(
483            definition,
484            &super::step_resolution::FunctionStagingMode::HotReload { generation },
485        )?;
486
487        self.routes.insert(route_id.clone(), managed);
488
489        self.endpoint_index.insert(&from_uri, &route_id);
490        Ok(())
491    }
492
493    pub async fn remove_route_preserving_functions(
494        &mut self,
495        route_id: &str,
496    ) -> Result<(), CamelError> {
497        let managed = self.routes.get(route_id).ok_or_else(|| {
498            CamelError::RouteError(format!("Route '{}' not found for removal", route_id))
499        })?;
500        if handle_is_running(&managed.consumer_handle)
501            || handle_is_running(&managed.pipeline_handle)
502        {
503            return Err(CamelError::RouteError(format!(
504                "Route '{}' must be stopped before removal (current execution lifecycle: {})",
505                route_id,
506                inferred_lifecycle_label(managed)
507            )));
508        }
509        self.routes.remove(route_id);
510        if let Some(reg) = &self.health_registry {
511            reg.unregister_for_route(route_id);
512        }
513        self.endpoint_index.remove(route_id);
514        debug!(route_id = %route_id, "Route removed from controller (functions preserved for reload finalize)");
515        Ok(())
516    }
517
518    /// Compile a route definition into a processor pipeline, without adding it
519    /// to the controller. Used for validation and testing.
520    pub fn compile_route_definition(
521        &self,
522        def: RouteDefinition,
523    ) -> Result<BoxProcessor, CamelError> {
524        self.route_compiler_ext().compile_route_definition(def)
525    }
526
527    /// Compile a route definition with a specific generation (for hot-reload).
528    pub fn compile_route_definition_with_generation(
529        &self,
530        def: RouteDefinition,
531        generation: u64,
532    ) -> Result<BoxProcessor, CamelError> {
533        self.route_compiler_ext()
534            .compile_route_definition_with_generation(def, generation)
535    }
536
537    /// Compile a route definition into a [`CompiledPipeline`] (processor +
538    /// lifecycle handles). Used by the hot-reload Restart path so that
539    /// lifecycle handles are threaded through
540    /// [`swap_pipeline_raw`](Self::swap_pipeline_raw).
541    pub(crate) fn compile_route_definition_pipeline(
542        &self,
543        def: RouteDefinition,
544        generation: u64,
545    ) -> Result<CompiledPipeline, CamelError> {
546        self.route_compiler_ext()
547            .compile_route_definition_pipeline(def, generation)
548    }
549
550    /// Compile without function generation, returning full [`CompiledPipeline`].
551    ///
552    /// Oracle Fix 1: used by the stateless hot-reload path so that
553    /// lifecycle-bearing routes have their handles preserved.
554    pub(crate) fn compile_route_definition_dry_pipeline(
555        &self,
556        def: RouteDefinition,
557    ) -> Result<CompiledPipeline, CamelError> {
558        self.route_compiler_ext()
559            .compile_route_definition_dry_pipeline(def)
560    }
561
562    /// Remove a route from the controller map.
563    ///
564    /// The route **must** be stopped before removal (status `Stopped` or `Failed`).
565    /// Returns an error if the route is still running or does not exist.
566    /// Does not cancel any running tasks — call `stop_route` first.
567    pub async fn remove_route(&mut self, route_id: &str) -> Result<(), CamelError> {
568        let managed = self.routes.get(route_id).ok_or_else(|| {
569            CamelError::RouteError(format!("Route '{}' not found for removal", route_id))
570        })?;
571        if handle_is_running(&managed.consumer_handle)
572            || handle_is_running(&managed.pipeline_handle)
573        {
574            return Err(CamelError::RouteError(format!(
575                "Route '{}' must be stopped before removal (current execution lifecycle: {})",
576                route_id,
577                inferred_lifecycle_label(managed)
578            )));
579        }
580        if let Some(invoker) = &self.function_invoker {
581            for (id, rid) in self.collect_function_refs(route_id) {
582                if let Err(e) = invoker.unregister(&id, rid.as_deref()).await {
583                    warn!(route_id = %route_id, error = %e, "Failed to unregister function during route removal");
584                }
585            }
586        }
587        self.routes.remove(route_id);
588        if let Some(reg) = &self.health_registry {
589            reg.unregister_for_route(route_id);
590        }
591        self.endpoint_index.remove(route_id);
592        info!(route_id = %route_id, "Route removed from controller");
593        Ok(())
594    }
595
596    fn collect_function_refs(
597        &self,
598        route_id: &str,
599    ) -> Vec<(camel_api::FunctionId, Option<String>)> {
600        self.function_invoker
601            .as_ref()
602            .map(|invoker| invoker.function_refs_for_route(route_id))
603            .unwrap_or_default()
604    }
605
606    fn discard_function_staging(&self) {
607        if let Some(invoker) = &self.function_invoker {
608            invoker.discard_staging(0);
609        }
610    }
611
612    /// Returns the number of routes in the controller.
613    pub fn route_count(&self) -> usize {
614        self.routes.route_count()
615    }
616
617    pub fn in_flight_count(&self, route_id: &str) -> Option<u64> {
618        self.routes.in_flight_count(route_id)
619    }
620
621    /// Returns `true` if a route with the given ID exists.
622    pub fn route_exists(&self, route_id: &str) -> bool {
623        self.routes.route_exists(route_id)
624    }
625
626    /// Returns all route IDs.
627    pub fn route_ids(&self) -> Vec<String> {
628        self.routes.route_ids()
629    }
630
631    pub fn route_source_hash(&self, route_id: &str) -> Option<u64> {
632        self.routes.route_source_hash(route_id)
633    }
634
635    /// Returns route IDs that should auto-start, sorted by startup order (ascending).
636    pub fn auto_startup_route_ids(&self) -> Vec<String> {
637        self.routes.auto_startup_route_ids()
638    }
639
640    /// Returns route IDs sorted by shutdown order (startup order descending).
641    pub fn shutdown_route_ids(&self) -> Vec<String> {
642        self.routes.shutdown_route_ids()
643    }
644
645    /// Atomically swap the pipeline of a route (zero-downtime).
646    ///
647    /// In-flight requests finish with the old pipeline (kept alive by Arc).
648    /// New requests immediately use the new pipeline.
649    ///
650    /// ## Rejection policy
651    ///
652    /// Returns an error if the route has lifecycle-bearing steps or an active
653    /// aggregate — these require the **Restart path** (stop → swap → start).
654    ///
655    /// The caller (e.g. `reload_actions::apply_swap`) MUST catch this rejection
656    /// and fall back to:
657    /// 1. `stop_route_reload` — drain lifecycle, stop consumer
658    /// 2. `swap_pipeline_raw` — bypass the lifecycle check (route is stopped)
659    /// 3. `start_route_reload` — re-create consumer with the new pipeline
660    ///
661    /// This is the "reject, don't defer" policy (oracle Fix 3): the swap is
662    /// refused upfront rather than silently deferring or partially swapping.
663    pub fn swap_pipeline(
664        &self,
665        route_id: &str,
666        new_pipeline: BoxProcessor,
667    ) -> Result<(), CamelError> {
668        let managed = self
669            .routes
670            .get(route_id)
671            .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
672
673        let assembly = managed.pipeline.load();
674        let has_lifecycle = !assembly.lifecycle.is_empty();
675
676        if has_lifecycle || managed.agg_service.is_some() {
677            warn!(
678                route_id = %route_id,
679                "Hot-swap rejected — route has lifecycle/agg steps; use Restart path"
680            );
681            return Err(CamelError::RouteError(format!(
682                "Route '{}' contains stateful steps (lifecycle-bearing). Hot-swap not supported — use restart.",
683                route_id
684            )));
685        }
686
687        drop(assembly);
688
689        if managed.aggregate_split.is_some() {
690            warn!(
691                route_id = %route_id,
692                "swap_pipeline: aggregate routes with timeout do not support hot-reload of pre/post segments"
693            );
694        }
695
696        super::pipeline_runtime::swap_pipeline_raw(&managed.pipeline, new_pipeline, vec![]);
697        debug!(route_id = %route_id, "Pipeline swapped atomically");
698        Ok(())
699    }
700
701    /// Non-checking raw pipeline swap — bypasses lifecycle/aggregate rejection.
702    ///
703    /// Only for use after the route has been stopped (Restart path).
704    /// Does NOT check for lifecycle handles or aggregate service — the caller
705    /// is responsible for ensuring the route is safe to swap.
706    ///
707    /// Accepts `lifecycle` so that the new pipeline assembly records the
708    /// lifecycle handles from the compiled steps.  When the route is
709    /// subsequently stopped, these handles are drained.
710    pub(crate) fn swap_pipeline_raw(
711        &self,
712        route_id: &str,
713        new_pipeline: BoxProcessor,
714        lifecycle: Vec<Arc<dyn StepLifecycle>>,
715    ) -> Result<(), CamelError> {
716        let managed = self
717            .routes
718            .get(route_id)
719            .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
720        super::pipeline_runtime::swap_pipeline_raw(&managed.pipeline, new_pipeline, lifecycle);
721        debug!(route_id = %route_id, "Pipeline swapped (raw — lifecycle bypass)");
722        Ok(())
723    }
724
725    /// Returns the from_uri of a route, if it exists.
726    pub fn route_from_uri(&self, route_id: &str) -> Option<String> {
727        self.routes.route_from_uri(route_id)
728    }
729
730    /// Return all route_ids that consume from the given source endpoint URI.
731    pub fn routes_for_endpoint(&self, uri: &str) -> Vec<String> {
732        self.endpoint_index.routes_for(uri)
733    }
734
735    /// Return all registered source endpoint URIs.
736    pub fn list_endpoint_uris(&self) -> Vec<String> {
737        self.endpoint_index.list_uris()
738    }
739
740    /// Get a clone of the current pipeline for a route.
741    ///
742    /// This is useful for testing and introspection.
743    /// Returns `None` if the route doesn't exist.
744    pub fn get_pipeline(&self, route_id: &str) -> Option<BoxProcessor> {
745        self.routes.get_pipeline(route_id)
746    }
747
748    /// Check whether the running route has lifecycle-bearing steps.
749    ///
750    /// Returns `false` when the route is missing.
751    pub(crate) fn route_has_lifecycle(&self, route_id: &str) -> bool {
752        self.routes
753            .get(route_id)
754            .map(|managed| !managed.pipeline.load().lifecycle.is_empty())
755            .unwrap_or(false)
756    }
757
758    /// Internal stop implementation that can set custom status.
759    pub(super) async fn stop_route_internal(&mut self, route_id: &str) -> Result<(), CamelError> {
760        self.routes.stop_route(route_id).await
761    }
762
763    pub async fn start_route_reload(&mut self, route_id: &str) -> Result<(), CamelError> {
764        self.start_route(route_id).await
765    }
766
767    pub async fn stop_route_reload(&mut self, route_id: &str) -> Result<(), CamelError> {
768        self.stop_route(route_id).await
769    }
770}
771
772// ── Aggregator route helpers ──
773
774impl DefaultRouteController {
775    /// Start a route with an aggregate split (pre-pipeline → aggregator → post-pipeline).
776    ///
777    /// Spawns a biased-select forward loop that routes exchanges through the
778    /// pre-pipeline, aggregator, and post-pipeline in sequence, with late-exchange
779    /// handling and force-completion on stop.
780    #[allow(clippy::too_many_arguments)]
781    pub(super) async fn start_aggregate_route(
782        &mut self,
783        route_id: &str,
784        split: AggregateSplitInfo,
785        consumer: Box<dyn Consumer>,
786        consumer_ctx: ConsumerContext,
787        mut rx: mpsc::Receiver<ExchangeEnvelope>,
788        crash_notifier: Option<mpsc::Sender<CrashNotification>>,
789        runtime_for_consumer: Option<Weak<dyn RuntimeHandle>>,
790        tx_for_storage: mpsc::Sender<ExchangeEnvelope>,
791        // Pipeline cancellation — a child of the managed route's pipeline_cancel_token.
792        pipeline_cancel: CancellationToken,
793        drain_in_flight: Arc<std::sync::atomic::AtomicU64>,
794    ) -> Result<(), CamelError> {
795        let (late_tx, late_rx) = mpsc::channel::<Exchange>(256);
796
797        let route_cancel_clone = pipeline_cancel.clone();
798        let svc = AggregatorService::new(
799            split.agg_config.clone(),
800            late_tx,
801            Arc::clone(&self.languages),
802            route_cancel_clone,
803        );
804        let agg = Arc::new(svc);
805
806        let pipeline_cancel_for_monitor = pipeline_cancel.clone();
807        let agg_for_monitor = Arc::clone(&agg);
808
809        {
810            let managed = self
811                .routes
812                .get_mut(route_id)
813                .expect("invariant: route must exist"); // allow-unwrap
814            managed.agg_service = Some(Arc::clone(&agg));
815        }
816
817        let late_rx = Arc::new(tokio::sync::Mutex::new(late_rx));
818        let pre_pipeline = split.pre_pipeline;
819        let post_pipeline = split.post_pipeline;
820
821        // Spawn biased select forward loop
822        let pipeline_handle = tokio::spawn(async move {
823            loop {
824                tokio::select! {
825                    biased;
826
827                    late_ex = async {
828                        let mut rx = late_rx.lock().await;
829                        rx.recv().await
830                    } => {
831                        match late_ex {
832                            Some(ex) => {
833                                let pipe = post_pipeline.load();
834                                if let Err(e) = pipe.processor.clone_inner().oneshot(ex).await {
835                                    tracing::warn!(error = %e, "late exchange post-pipeline failed");
836                                }
837                            }
838                            None => return,
839                        }
840                    }
841
842                    envelope_opt = rx.recv() => {
843                        match envelope_opt {
844                            Some(envelope) => {
845                                let ExchangeEnvelope { exchange, reply_tx } = envelope;
846                                let _drain_guard = super::route_helpers::DrainGuard::new(Arc::clone(&drain_in_flight));
847                                let pre_pipe = pre_pipeline.load();
848                                let ex = match pre_pipe.processor.clone_inner().oneshot(exchange).await {
849                                    Ok(ex) => ex,
850                                    Err(e) => {
851                                        if let Some(tx) = reply_tx { let _ = tx.send(Err(e)); }
852                                        continue;
853                                    }
854                                };
855
856                                let ex = {
857                                    let cloned_svc = agg.as_ref().clone();
858                                    cloned_svc.oneshot(ex).await
859                                };
860
861                                match ex {
862                                    Ok(ex) => {
863                                        if !is_pending(&ex) {
864                                            let post_pipe = post_pipeline.load();
865                                            let out = post_pipe.processor.clone_inner().oneshot(ex).await;
866                                            if let Some(tx) = reply_tx { let _ = tx.send(out); }
867                                        } else if let Some(tx) = reply_tx {
868                                            let _ = tx.send(Ok(ex));
869                                        }
870                                    }
871                                    Err(e) => {
872                                        if let Some(tx) = reply_tx { let _ = tx.send(Err(e)); }
873                                    }
874                                }
875                            }
876                            None => return,
877                        }
878                    }
879
880                    _ = pipeline_cancel.cancelled() => {
881                        agg.force_complete_all();
882                        let mut rx_guard = late_rx.lock().await;
883                        while let Ok(late_ex) = rx_guard.try_recv() {
884                            let pipe = post_pipeline.load();
885                            let _ = pipe.processor.clone_inner().oneshot(late_ex).await;
886                        }
887                        break;
888                    }
889                }
890            }
891        });
892        #[cfg(test)]
893        emit_start_route_event("pipeline_spawned");
894
895        // Start consumer after pipeline loop is spawned to avoid startup races
896        // where consumers emit exchanges before the route pipeline begins polling.
897        let (consumer_handle, startup_rx) = super::consumer_management::spawn_consumer_task(
898            route_id.to_string(),
899            consumer,
900            consumer_ctx,
901            crash_notifier,
902            runtime_for_consumer,
903            false,
904        );
905
906        // rc-w1u9: await consumer startup handshake for aggregate routes too
907        // so bind failures surface as route-start errors.
908        // rc-kh7c: on failure, abort the orphaned consumer task and cancel the
909        // pipeline so neither runs detached. The aggregate pipeline loop would
910        // eventually self-clean via rx-drop + late_tx-drop, but cancelling
911        // pipeline_cancel also triggers force_complete_all (aggregate cleanup).
912        if let Err(e) =
913            super::consumer_management::await_consumer_startup(startup_rx, "startup").await
914        {
915            consumer_handle.abort();
916            pipeline_cancel_for_monitor.cancel();
917            return Err(e);
918        }
919
920        // Extend the stored consumer handle through aggregate force-completion.
921        // While this monitor drains pending buckets, handle_is_running still reports
922        // the Route as running because forced exchanges may still be in post-pipeline.
923        let force_on_stop = agg_for_monitor.config().force_completion_on_stop;
924        let consumer_handle = tokio::spawn(async move {
925            let _ = consumer_handle.await;
926            if !pipeline_cancel_for_monitor.is_cancelled() {
927                agg_for_monitor.force_complete_all();
928                if force_on_stop {
929                    pipeline_cancel_for_monitor.cancel();
930                }
931            }
932        });
933        #[cfg(test)]
934        emit_start_route_event("consumer_spawned");
935
936        {
937            let managed = self
938                .routes
939                .get_mut(route_id)
940                .expect("invariant: route must exist"); // allow-unwrap
941            managed.consumer_handle = Some(consumer_handle);
942            managed.pipeline_handle = Some(pipeline_handle);
943            managed.channel_sender = Some(tx_for_storage);
944        }
945
946        info!(route_id = %route_id, "Route started (aggregate with timeout)");
947        Ok(())
948    }
949
950    /// Test-only: inject lifecycle handles into an existing route's pipeline
951    /// assembly.  This makes the route lifecycle-bearing so that swap_pipeline
952    /// rejects it, forcing callers (like reload_actions::apply_swap) to take
953    /// the Restart path instead.
954    #[cfg(test)]
955    pub(crate) fn set_route_lifecycle_for_test(
956        &mut self,
957        route_id: &str,
958        lifecycle: Vec<Arc<dyn StepLifecycle>>,
959    ) -> Result<(), CamelError> {
960        use super::pipeline_runtime::PipelineAssembly;
961        use camel_api::SyncBoxProcessor;
962        use std::sync::Arc;
963
964        let managed = self
965            .routes
966            .get_mut(route_id)
967            .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
968        let old_processor = managed.pipeline.load().processor.clone_inner();
969        managed.pipeline.store(Arc::new(PipelineAssembly::new(
970            SyncBoxProcessor::new(old_processor),
971            lifecycle,
972        )));
973        Ok(())
974    }
975}
976
977#[cfg(test)]
978impl crate::hot_reload::ports::ReloadIntrospectionPort for DefaultRouteController {
979    fn route_ids(&self) -> Vec<String> {
980        self.route_ids() // inherent pub fn
981    }
982    fn route_from_uri(&self, route_id: &str) -> Option<String> {
983        self.route_from_uri(route_id) // inherent pub fn
984    }
985    fn route_source_hash(&self, route_id: &str) -> Option<u64> {
986        self.route_source_hash(route_id) // inherent pub fn
987    }
988}
989
990#[cfg(test)]
991#[path = "route_controller_tests.rs"]
992mod tests;