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