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