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