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