Skip to main content

camel_core/lifecycle/adapters/
route_controller.rs

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