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