Skip to main content

camel_core/lifecycle/adapters/
route_compiler.rs

1// adapters/route_compiler.rs
2// Pipeline compilation functions: compose BuilderSteps into a Tower BoxProcessor.
3// Tower types live here as this is the adapter layer responsible for
4// translating declarative route definitions into executable pipelines.
5
6use std::future::Future;
7use std::pin::Pin;
8use std::sync::Arc;
9use std::task::{Context, Poll};
10
11use tokio_util::sync::CancellationToken;
12use tower::Service;
13
14use camel_api::metrics::MetricsCollector;
15use camel_api::{
16    BoxProcessor, CamelError, Exchange, IdentityProcessor, Message, NoOpMetrics,
17    ORIGINAL_MESSAGE_EXTENSION, PipelineOutcome,
18};
19
20use camel_api::error_handler::{BoundaryKind, RetryOutcome, StepDisposition};
21use camel_processor::{
22    CircuitBreakerDecision, CircuitBreakerGate, RouteErrorHandler, invoke_processor,
23};
24use opentelemetry::trace::{SpanKind, Status, TraceContextExt, Tracer};
25use opentelemetry::{Context as OtelContext, InstrumentationScope, KeyValue, global};
26use tracing::Instrument;
27
28use crate::lifecycle::adapters::body_coercing::wrap_if_needed;
29use crate::lifecycle::adapters::step_compilers::CompiledStep;
30use crate::shared::observability::adapters::TracingProcessor;
31use crate::shared::observability::adapters::tracer::{
32    SpanEndGuard, capped_correlation_id, record_exception, step_id_for, step_span_attributes,
33};
34use crate::shared::observability::domain::{DetailLevel, MetricsLeversConfig};
35
36// Re-export outcome composition types so existing step_compiler import paths
37// (`route_compiler::BoxProcessorSegment`, etc.) continue to work.
38pub(crate) use super::outcome_composition::{
39    BodyCoercingSegment, BoxProcessorSegment, StopSegment, compose_outcome_segment,
40};
41
42// Task-local cancel token — set by the pipeline task per-start, checked by
43// `run_steps` between steps. Absent in direct tests (skip check).
44//
45// Design: per-start task-local, NOT compiled into the pipeline struct, to
46// avoid the lifecycle bug where a compiled-in child token stays cancelled
47// after stop→restart (the new start would inherit the cancelled state).
48// ADR-0043.
49tokio::task_local! {
50    pub(crate) static CANCEL_TOKEN: CancellationToken;
51}
52
53/// Runtime context for metrics + route_id (B3). Cancel is via task-local (B1).
54#[derive(Clone)]
55pub struct PipelineRuntimeCtx {
56    pub metrics: Arc<dyn MetricsCollector>,
57    pub route_id: Arc<str>,
58}
59
60impl PipelineRuntimeCtx {
61    /// Constructor for compile-time contexts where no MetricsCollector is available.
62    /// The resulting pipeline will emit disposition counters to NoOpMetrics (no-op).
63    /// Prefer constructing PipelineRuntimeCtx with real metrics at route startup.
64    pub fn compile_time() -> Self {
65        Self {
66            metrics: Arc::new(NoOpMetrics),
67            route_id: Arc::from(""),
68        }
69    }
70}
71
72/// Newtype around `Arc<[CompiledStep]>`.
73///
74/// `CompiledStep` contains `BoxProcessor` (`tower::util::BoxCloneSyncService`),
75/// whose erased inner trait object is bounded `Send + Sync`. `CompiledStep` is
76/// therefore `Send + Sync` by construction, and `SharedSnapshot` derives both
77/// auto traits from `Arc<[CompiledStep]>` — the snapshot is shareable across
78/// threads with auto-derived traits alone.
79#[derive(Clone)]
80struct SharedSnapshot(Arc<[CompiledStep]>);
81
82// Compile-time guard: CompiledStep must remain Send + Sync so the snapshot
83// stays shareable via auto-derivation. `Send` keeps the future returned by
84// `run_steps` Send; `Sync` covers concurrent `&self` reads on
85// `SequentialPipeline`/`TracedPipeline` clones (e.g. `poll_ready` on one
86// thread, `call` on another).
87#[allow(dead_code)]
88const _: () = {
89    fn assert_send<T: Send>() {}
90    fn assert_sync<T: Sync>() {}
91    fn _check() {
92        assert_send::<CompiledStep>();
93        assert_sync::<CompiledStep>();
94    }
95};
96
97/// Compose a list of CompiledSteps into a sub-pipeline (EIP internal).
98///
99/// Uses `into_tower_result()` so `PipelineOutcome::Stopped` maps to `Ok(ex)`.
100/// Use [`compose_pipeline_with_handler`] for the top-level consumer-facing pipeline.
101pub fn compose_pipeline(processors: Vec<CompiledStep>, ctx: PipelineRuntimeCtx) -> BoxProcessor {
102    if processors.is_empty() {
103        return BoxProcessor::new(IdentityProcessor);
104    }
105    BoxProcessor::new(SequentialPipeline {
106        steps: SharedSnapshot(processors.into()),
107        handler: None,
108        ctx,
109    })
110}
111
112/// Compose a list of CompiledSteps with an optional route error handler.
113///
114/// When a handler is present, step readiness errors are swallowed (poll_ready
115/// returns Ready) and the handler's retry/recovery logic is invoked on step
116/// failures. Otherwise, step readiness errors propagate immediately.
117pub fn compose_pipeline_with_handler(
118    processors: Vec<CompiledStep>,
119    handler: Option<Arc<dyn RouteErrorHandler>>,
120    ctx: PipelineRuntimeCtx,
121) -> BoxProcessor {
122    if processors.is_empty() {
123        return BoxProcessor::new(IdentityProcessor);
124    }
125    BoxProcessor::new(SequentialPipeline {
126        steps: SharedSnapshot(processors.into()),
127        handler,
128        ctx,
129    })
130}
131
132/// Effective span/metric gating for the traced pipeline, derived once from
133/// the effective tracer config (dashboard-observability D3).
134///
135/// `pipeline_enabled` decides whether routes are wrapped with the
136/// observability adapters at all; `spans_enabled` gates SPAN creation only
137/// (explicit `tracer.enabled = false` with an exporter on yields
138/// `pipeline_enabled && !spans_enabled`, so metric families — errors
139/// unconditionally — keep flowing); `levers` gate individual non-error
140/// families.
141#[derive(Clone, Debug)]
142pub struct TracerPipelineGating {
143    pub pipeline_enabled: bool,
144    pub spans_enabled: bool,
145    pub levers: MetricsLeversConfig,
146}
147
148impl TracerPipelineGating {
149    /// Fully traced pipeline with default levers (legacy `trace_enabled = true`).
150    pub fn traced() -> Self {
151        Self {
152            pipeline_enabled: true,
153            spans_enabled: true,
154            levers: MetricsLeversConfig::default(),
155        }
156    }
157
158    /// No observability wrapping (legacy `trace_enabled = false`).
159    pub fn off() -> Self {
160        Self {
161            pipeline_enabled: false,
162            spans_enabled: false,
163            levers: MetricsLeversConfig::default(),
164        }
165    }
166}
167
168/// Legacy bool call sites keep their meaning: `true` = fully traced,
169/// `false` = no wrapping.
170impl From<bool> for TracerPipelineGating {
171    fn from(trace_enabled: bool) -> Self {
172        if trace_enabled {
173            Self::traced()
174        } else {
175            Self::off()
176        }
177    }
178}
179
180/// Compose a list of CompiledSteps into a traced pipeline with Stop→Ok translation.
181///
182/// Each processor is wrapped with TracingProcessor to emit spans for observability,
183/// and the pipeline opens one Internal route root span per invocation (named after
184/// `route_id`) that parents every step span. Step spans are named
185/// `{route_id}:{label}` when the compiled step carries a DSL label (e.g.
186/// `to:direct`, `split`); unlabeled steps fall back to the positional
187/// `{route_id}:step-{index}` name. Empty traced routes still return a
188/// `TracedPipeline` so the root span records the route invocation with zero steps.
189/// When the pipeline is disabled, falls back to [`compose_pipeline_with_handler`]
190/// with zero overhead; when only spans are disabled, steps are still wrapped for
191/// metric families but no route root span is opened.
192pub fn compose_traced_pipeline(
193    processors: Vec<CompiledStep>,
194    route_id: &str,
195    gating: impl Into<TracerPipelineGating>,
196    detail_level: DetailLevel,
197    metrics: Option<Arc<dyn MetricsCollector>>,
198    handler: Option<Arc<dyn RouteErrorHandler>>,
199    ctx: PipelineRuntimeCtx,
200) -> BoxProcessor {
201    let gating = gating.into();
202    if !gating.pipeline_enabled {
203        return compose_pipeline_with_handler(processors, handler, ctx);
204    }
205
206    let wrapped: Vec<CompiledStep> = processors
207        .into_iter()
208        .enumerate()
209        .map(|(idx, step)| {
210            let (p, c, lc, lbl, kh, uri) = match step {
211                CompiledStep::Process {
212                    processor,
213                    body_contract,
214                    lifecycle,
215                    label,
216                    kind_hint,
217                    to_uri,
218                } => (
219                    processor,
220                    body_contract,
221                    lifecycle,
222                    label,
223                    kind_hint,
224                    to_uri,
225                ),
226                CompiledStep::Stop => return CompiledStep::Stop,
227                CompiledStep::Segment { .. } => return step,
228            };
229            let traced = BoxProcessor::new(
230                TracingProcessor::new(
231                    p,
232                    route_id.to_string(),
233                    idx,
234                    detail_level.clone(),
235                    metrics.clone(),
236                    lbl.clone(),
237                    // Thread the registry-stamped declared To URI
238                    // (steplatency 2.1) so call-time step attempts emit the
239                    // `step_duration_secs` histogram labeled with the
240                    // operator-authored URI; non-To steps keep `None`.
241                    uri.clone(),
242                    // Thread the registry-stamped kind hint (span-kind-hint
243                    // 1.3) so `to:http` steps export Client spans and broker
244                    // sends export Producer spans; unlabeled/non-To steps keep
245                    // the Internal default.
246                    kh,
247                )
248                .with_spans_enabled(gating.spans_enabled)
249                .with_metric_levers(gating.levers.clone()),
250            );
251            CompiledStep::Process {
252                processor: traced,
253                body_contract: c,
254                lifecycle: lc,
255                label: lbl,
256                kind_hint: kh,
257                to_uri: uri,
258            }
259        })
260        .collect();
261
262    // Spans off: no route root span — a plain sequential pipeline over the
263    // metrics-emitting step wrappers.
264    if !gating.spans_enabled {
265        return BoxProcessor::new(SequentialPipeline {
266            steps: SharedSnapshot(wrapped.into()),
267            handler,
268            ctx,
269        });
270    }
271
272    BoxProcessor::new(TracedPipeline {
273        steps: SharedSnapshot(wrapped.into()),
274        route_id: route_id.to_string(),
275        handler,
276        ctx,
277    })
278}
279
280/// Compose a list of `CompiledStep` items into a single pipeline with body coercion.
281///
282/// Each processor is optionally wrapped with `BodyCoercingProcessor` based on its
283/// contract. Processors with `None` contract are passed through with zero overhead.
284/// `CompiledStep::Stop` passes through without coercion.
285pub fn compose_pipeline_with_contracts(
286    processors: Vec<CompiledStep>,
287    handler: Option<Arc<dyn RouteErrorHandler>>,
288    ctx: PipelineRuntimeCtx,
289) -> BoxProcessor {
290    let wrapped: Vec<CompiledStep> = processors
291        .into_iter()
292        .map(|step| match step {
293            CompiledStep::Process {
294                processor,
295                body_contract,
296                lifecycle,
297                label,
298                kind_hint,
299                to_uri,
300            } => {
301                let coerced = wrap_if_needed(processor, body_contract);
302                CompiledStep::Process {
303                    processor: coerced,
304                    body_contract: None,
305                    lifecycle,
306                    label,
307                    kind_hint,
308                    to_uri,
309                }
310            }
311            CompiledStep::Stop => CompiledStep::Stop,
312            CompiledStep::Segment { .. } => step,
313        })
314        .collect();
315    compose_pipeline_with_handler(wrapped, handler, ctx)
316}
317
318/// Compose a list of `CompiledStep` items into a single pipeline with body coercion.
319///
320/// Applies body coercion contracts first, then wraps with `TracingProcessor`.
321/// The pipeline opens one Internal route root span per invocation (named after
322/// `route_id`); empty traced routes still return a `TracedPipeline` so the
323/// root span records the route invocation with zero steps.
324/// When the pipeline is disabled, falls back to [`compose_pipeline_with_contracts`].
325pub(crate) fn compose_traced_pipeline_with_contracts(
326    processors: Vec<CompiledStep>,
327    route_id: &str,
328    gating: impl Into<TracerPipelineGating>,
329    detail_level: DetailLevel,
330    metrics: Option<Arc<dyn MetricsCollector>>,
331    handler: Option<Arc<dyn RouteErrorHandler>>,
332    ctx: PipelineRuntimeCtx,
333) -> BoxProcessor {
334    let gating = gating.into();
335    if !gating.pipeline_enabled {
336        return compose_pipeline_with_contracts(processors, handler, ctx);
337    }
338
339    let coerced: Vec<CompiledStep> = processors
340        .into_iter()
341        .map(|step| match step {
342            CompiledStep::Process {
343                processor,
344                body_contract,
345                lifecycle,
346                label,
347                kind_hint,
348                to_uri,
349            } => {
350                let processor = wrap_if_needed(processor, body_contract);
351                CompiledStep::Process {
352                    processor,
353                    body_contract: None,
354                    lifecycle,
355                    label,
356                    kind_hint,
357                    to_uri,
358                }
359            }
360            CompiledStep::Stop => CompiledStep::Stop,
361            CompiledStep::Segment { .. } => step,
362        })
363        .collect();
364
365    compose_traced_pipeline(
366        coerced,
367        route_id,
368        gating,
369        detail_level,
370        metrics,
371        handler,
372        ctx,
373    )
374}
375
376/// A service that executes a sequence of CompiledSteps in order.
377///
378/// Uses `into_tower_result()` so `PipelineOutcome::Stopped(ex)` maps to
379/// `Ok(ex)` — the Bug B fix that makes Stop indistinguishable from Completed
380/// at the consumer boundary.
381#[derive(Clone)]
382struct SequentialPipeline {
383    steps: SharedSnapshot,
384    handler: Option<Arc<dyn RouteErrorHandler>>,
385    ctx: PipelineRuntimeCtx,
386}
387
388impl Service<Exchange> for SequentialPipeline {
389    type Response = Exchange;
390    type Error = CamelError;
391    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
392
393    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
394        // rc-mn8n review: with a handler, readiness errors are swallowed
395        // here anyway and every invoke re-polls the first step
396        // (`RetryableStep::invoke` calls `ready()` before the step's
397        // `call`), so a pre-invoke first-step poll only duplicates the
398        // tracer adapter's `poll_ready` Err-arm recording. Skip it —
399        // Pending backpressure is preserved at the invoke re-poll.
400        // Non-handler routes keep this poll: its Err is their only
401        // readiness signal (the call never runs on failure).
402        if self.handler.is_some() {
403            return Poll::Ready(Ok(()));
404        }
405        match self.steps.0.first() {
406            Some(CompiledStep::Process { processor, .. }) => {
407                let mut proc = processor.clone();
408                match proc.poll_ready(cx) {
409                    Poll::Pending => Poll::Pending,
410                    Poll::Ready(Err(_)) if self.handler.is_some() => Poll::Ready(Ok(())),
411                    Poll::Ready(other) => Poll::Ready(other),
412                }
413            }
414            Some(CompiledStep::Stop) => Poll::Ready(Ok(())),
415            Some(CompiledStep::Segment { .. }) => Poll::Ready(Ok(())),
416            None => Poll::Ready(Ok(())),
417        }
418    }
419
420    // ADR-0024 reply-channel adapter: PipelineOutcome → Result<Exchange, CamelError>.
421    // Completed(ex) and Stopped(ex) both map to Ok(ex); Failed(err) maps to Err.
422    // Downstream consumers (RouteChannelService, ExchangeUoWLayer, HTTP/Kafka reply
423    // finalisers) see Result<Exchange, CamelError> and treat Stop as success.
424    fn call(&mut self, exchange: Exchange) -> Self::Future {
425        // Cheap Arc::clone (refcount bump) on the SharedSnapshot newtype.
426        // `SharedSnapshot: Send` so the future returned by `run_steps`
427        // captures it directly without needing a Send-asserting wrapper.
428        let steps = self.steps.clone();
429        let handler = self.handler.clone();
430        let ctx = self.ctx.clone();
431        Box::pin(async move {
432            run_steps(steps, exchange, handler, false, &ctx.route_id, &ctx)
433                .await
434                .into_tower_result()
435        })
436    }
437}
438
439/// A traced service pipeline for wrapped CompiledSteps.
440///
441/// Each invocation opens one Internal route root span named after the route;
442/// step spans (from `TracingProcessor`) nest under it. The root span handle
443/// lives inside the `call` async body — never on `self` — so hot-reload
444/// pipeline swaps are unaffected.
445#[derive(Clone)]
446struct TracedPipeline {
447    steps: SharedSnapshot,
448    route_id: String,
449    handler: Option<Arc<dyn RouteErrorHandler>>,
450    ctx: PipelineRuntimeCtx,
451}
452
453impl Service<Exchange> for TracedPipeline {
454    type Response = Exchange;
455    type Error = CamelError;
456    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
457
458    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
459        // rc-mn8n review: with a handler, readiness errors are swallowed
460        // here anyway and every invoke re-polls the first step
461        // (`RetryableStep::invoke` calls `ready()` before the step's
462        // `call`), so a pre-invoke first-step poll only duplicates the
463        // tracer adapter's `poll_ready` Err-arm recording. Skip it —
464        // Pending backpressure is preserved at the invoke re-poll.
465        // Non-handler routes keep this poll: its Err is their only
466        // readiness signal (the call never runs on failure).
467        if self.handler.is_some() {
468            return Poll::Ready(Ok(()));
469        }
470        match self.steps.0.first() {
471            Some(CompiledStep::Process { processor, .. }) => {
472                let mut proc = processor.clone();
473                match proc.poll_ready(cx) {
474                    Poll::Pending => Poll::Pending,
475                    Poll::Ready(Err(_)) if self.handler.is_some() => Poll::Ready(Ok(())),
476                    Poll::Ready(other) => Poll::Ready(other),
477                }
478            }
479            Some(CompiledStep::Stop) => Poll::Ready(Ok(())),
480            Some(CompiledStep::Segment { .. }) => Poll::Ready(Ok(())),
481            None => Poll::Ready(Ok(())),
482        }
483    }
484
485    // ADR-0024 reply-channel adapter (same as SequentialPipeline::call):
486    // Completed(ex) and Stopped(ex) both map to Ok(ex). Bug B fix.
487    //
488    // Route root span (trace-model-tree T1.3): one Internal span per route
489    // invocation, named after the route, parenting every step span. Derived
490    // from the entry context (not the ambient current context) so parent
491    // entries such as baggage stay attached; the entry context is restored
492    // on the result exchange when one comes back.
493    fn call(&mut self, exchange: Exchange) -> Self::Future {
494        let steps = self.steps.clone();
495        let route_id = self.route_id.clone();
496        let handler = self.handler.clone();
497        let ctx = self.ctx.clone();
498        Box::pin(async move {
499            let tracer = global::tracer_with_scope(
500                InstrumentationScope::builder("camel-core")
501                    .with_version(env!("CARGO_PKG_VERSION"))
502                    .build(),
503            );
504            let entry_cx = exchange.otel_context.clone();
505            let root_span = tracer
506                .span_builder(route_id.clone())
507                .with_kind(SpanKind::Internal)
508                .with_attributes([
509                    KeyValue::new("messaging.system", "camel"),
510                    KeyValue::new("route_id", route_id.clone()),
511                    KeyValue::new(
512                        "correlation_id",
513                        capped_correlation_id(exchange.correlation_id()).to_string(),
514                    ),
515                ])
516                .start_with_context(&tracer, &entry_cx);
517            let root_cx = entry_cx.with_span(root_span);
518            // Guard ends the root span even if a step panics.
519            let _root_guard = SpanEndGuard(root_cx.clone());
520            let mut exchange = exchange;
521            exchange.otel_context = root_cx.clone();
522
523            let outcome = run_steps(steps, exchange, handler, true, &route_id, &ctx).await;
524            finish_span_outcome(outcome, &root_cx, entry_cx).into_tower_result()
525        })
526    }
527}
528
529/// Run a sequence of CompiledSteps with optional error recovery.
530///
531/// Each step is unified under [`OwnedRetryable`] — Process and
532/// Segment variants are treated uniformly via a stack-allocated enum
533/// that dispatches to the existing `RetryableStep` impls on
534/// `BoxProcessor` and `OutcomeSegment`. This eliminates the per-step
535/// `Box::new(...) as Box<dyn RetryableStep>` heap allocation that the
536/// pre-A2 implementation paid for every step of every Exchange (A2).
537///
538/// On the traced path (`trace == true`, `route_id` from the traced
539/// pipeline), Segment steps dispatch through [`TracedSegmentStep`]
540/// instead, so the initial invocation AND every retry attempt opened by
541/// the error handler runs through the same span wrapper (T1.4).
542///
543/// On failure:
544/// 1. If a handler is present, `match_policy` selects a retry policy.
545/// 2. `retry_step` attempts recovery; if exhausted, `handle_step` determines
546///    the disposition:
547///    - `Propagate` — return the error
548///    - `Handled` — return the exchange early (success)
549///    - `Continued` — clear the error and continue to the next step
550/// 3. If no handler is present, the error is propagated directly.
551///
552/// CompiledStep::Stop short-circuits to `PipelineOutcome::Stopped(ex)` — the
553/// handler is bypassed and no Tower service is invoked (ADR-0024 §3.5).
554async fn run_steps(
555    steps: SharedSnapshot,
556    exchange: Exchange,
557    handler: Option<Arc<dyn RouteErrorHandler>>,
558    trace: bool,
559    route_id: &str,
560    ctx: &PipelineRuntimeCtx,
561) -> PipelineOutcome {
562    use camel_api::error_handler::RetryableStep;
563    let mut ex = exchange;
564    // Index-based loop (not `for (i, step) in steps.0.iter().enumerate()`):
565    // retained to avoid holding a `&[CompiledStep]` borrow across the
566    // `.await` below — `&steps.0[i]` is consumed by the `match` scrutinee
567    // and drops before the await, so no borrow is live across the await
568    // point. The original `CompiledStep: !Sync` rationale is gone
569    // (`BoxProcessor` is now `Send + Sync` via `BoxCloneSyncService`);
570    // the loop shape is kept purely for borrow hygiene — no behavior change.
571    let len = steps.0.len();
572    for i in 0..len {
573        // B1: cooperative cancellation between steps via task-local.
574        // If the task-local is not set (direct test calls), skip the check.
575        let cancelled = CANCEL_TOKEN.try_with(|t| t.is_cancelled()).unwrap_or(false);
576        if cancelled {
577            return PipelineOutcome::Failed(CamelError::ConsumerStopping);
578        }
579        // A2: dispatch to existing `RetryableStep` impls through a stack
580        // enum instead of paying `Box::new(...) as Box<dyn RetryableStep>`
581        // per step. `OwnedRetryable` is `enum { Processor, Segment }` with
582        // discriminant-by-value layout — no extra heap alloc. On the traced
583        // path, Segment steps take the `TracedSegment` variant so every
584        // attempt (initial + retries) gets its own step span (T1.4).
585        let mut retryable: OwnedRetryable = match &steps.0[i] {
586            CompiledStep::Stop => return PipelineOutcome::Stopped(ex),
587            CompiledStep::Process { processor, .. } => OwnedRetryable::Processor(processor.clone()),
588            CompiledStep::Segment { segment, label, .. } => {
589                if trace {
590                    OwnedRetryable::TracedSegment(TracedSegmentStep {
591                        segment: segment.clone(),
592                        route_id: route_id.to_string(),
593                        index: i,
594                        label: label.clone(),
595                    })
596                } else {
597                    OwnedRetryable::Segment(segment.clone())
598                }
599            }
600        };
601
602        let original = handler.as_ref().map(|_| ex.clone());
603        let outcome = if trace {
604            invoke_with_span(&mut retryable, ex, i).await
605        } else {
606            retryable.invoke(ex).await
607        };
608
609        match outcome {
610            PipelineOutcome::Completed(next) => {
611                if camel_api::is_camel_stop(&next) {
612                    return PipelineOutcome::Stopped(next);
613                }
614                ex = next;
615            }
616            PipelineOutcome::Stopped(stopped_ex) => {
617                return PipelineOutcome::Stopped(stopped_ex);
618            }
619            PipelineOutcome::Failed(err) => {
620                let (Some(handler), Some(original)) = (handler.as_ref(), original) else {
621                    return PipelineOutcome::Failed(err);
622                };
623                let policy = handler.match_policy(&err);
624                // `&mut retryable` auto-coerces from `&mut OwnedRetryable` to
625                // `&mut dyn RetryableStep` via the trait impl on the enum.
626                match handler
627                    .retry_step(policy, &mut retryable, original, err)
628                    .await
629                {
630                    RetryOutcome::Recovered(exchange) => {
631                        // allow-open-label rc-xl5k (route label: user-defined route id, bounded by route count)
632                        ctx.metrics.record_counter(
633                            "pipeline_disposition",
634                            1.0,
635                            &[("disposition", "recovered"), ("route_id", &ctx.route_id)],
636                        );
637                        ex = exchange;
638                    }
639                    RetryOutcome::Stopped(stopped_ex) => {
640                        // allow-open-label rc-xl5k (route label: user-defined route id, bounded by route count)
641                        ctx.metrics.record_counter(
642                            "pipeline_disposition",
643                            1.0,
644                            &[("disposition", "stopped"), ("route_id", &ctx.route_id)],
645                        );
646                        return PipelineOutcome::Stopped(stopped_ex);
647                    }
648                    RetryOutcome::Exhausted {
649                        exchange,
650                        error,
651                        policy,
652                    } => {
653                        let disposition = if trace {
654                            handler
655                                .handle_step(policy, exchange, error)
656                                .instrument(tracing::debug_span!("error_handler", step_index = i))
657                                .await
658                        } else {
659                            handler.handle_step(policy, exchange, error).await
660                        };
661                        match disposition {
662                            Ok(StepDisposition::Propagate(e)) => {
663                                // allow-open-label rc-xl5k (route label: user-defined route id, bounded by route count)
664                                ctx.metrics.record_counter(
665                                    "pipeline_disposition",
666                                    1.0,
667                                    &[("disposition", "propagated"), ("route_id", &ctx.route_id)],
668                                );
669                                return PipelineOutcome::Failed(e);
670                            }
671                            Ok(StepDisposition::Handled(done)) => {
672                                // allow-open-label rc-xl5k (route label: user-defined route id, bounded by route count)
673                                ctx.metrics.record_counter(
674                                    "pipeline_disposition",
675                                    1.0,
676                                    &[("disposition", "handled"), ("route_id", &ctx.route_id)],
677                                );
678                                return PipelineOutcome::Completed(done);
679                            }
680                            Ok(StepDisposition::Continued(next)) => {
681                                // allow-open-label rc-xl5k (route label: user-defined route id, bounded by route count)
682                                ctx.metrics.record_counter(
683                                    "pipeline_disposition",
684                                    1.0,
685                                    &[("disposition", "continued"), ("route_id", &ctx.route_id)],
686                                );
687                                ex = next;
688                            }
689                            Err(e) => {
690                                // allow-open-label rc-xl5k (route label: user-defined route id, bounded by route count)
691                                ctx.metrics.record_counter(
692                                    "pipeline_disposition",
693                                    1.0,
694                                    &[
695                                        ("disposition", "handler_error"),
696                                        ("route_id", &ctx.route_id),
697                                    ],
698                                );
699                                return PipelineOutcome::Failed(e);
700                            }
701                            // Future StepDisposition variants fail the pipeline.
702                            _ => {
703                                return PipelineOutcome::Failed(CamelError::ProcessorError(
704                                    "unknown step disposition".to_string(),
705                                ));
706                            }
707                        }
708                    }
709                    // Future RetryOutcome variants fail the pipeline.
710                    _ => {
711                        return PipelineOutcome::Failed(CamelError::ProcessorError(
712                            "unknown retry outcome".to_string(),
713                        ));
714                    }
715                }
716            }
717        }
718    }
719    PipelineOutcome::Completed(ex)
720}
721
722/// Stack-allocated dispatcher that unifies `BoxProcessor` and
723/// `OutcomeSegment` for the retry path without the heap allocation a
724/// `Box<dyn RetryableStep>` would require. Sized by-value, dispatched
725/// through a single trait method that fans out to the existing
726/// `RetryableStep` impls on each variant.
727///
728/// A2: replaces `Box::new(processor.clone()) as Box<dyn RetryableStep>`
729/// (and the equivalent for segments) with this enum, saving one heap
730/// allocation per pipeline step per Exchange invocation.
731enum OwnedRetryable {
732    Processor(camel_api::BoxProcessor),
733    Segment(camel_api::OutcomeSegment),
734    /// Traced segment dispatch (T1.4): every attempt — the initial
735    /// invocation and each retry opened by the error handler — goes
736    /// through `TracedSegmentStep` so each gets its own step span.
737    TracedSegment(TracedSegmentStep),
738}
739
740impl camel_api::error_handler::RetryableStep for OwnedRetryable {
741    fn invoke<'a>(
742        &'a mut self,
743        exchange: Exchange,
744    ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
745        match self {
746            OwnedRetryable::Processor(p) => p.invoke(exchange),
747            OwnedRetryable::Segment(s) => s.invoke(exchange),
748            OwnedRetryable::TracedSegment(s) => s.invoke(exchange),
749        }
750    }
751}
752
753/// Start an Internal span for one segment step attempt, parented by
754/// `entry_cx` (the traced pipeline's root context) with the Minimal-level
755/// attribute set from `step_span_attributes` (trace-model-tree T1.4).
756///
757/// Named `{route_id}:{label}` when the segment step carries a DSL label
758/// (e.g. `split`); unlabeled segments fall back to the positional
759/// `{route_id}:step-{index}` name — same contract as process step spans.
760fn segment_span(
761    tracer: &global::BoxedTracer,
762    route_id: &str,
763    index: usize,
764    label: Option<Arc<str>>,
765    entry_cx: &OtelContext,
766    correlation_id: &str,
767) -> global::BoxedSpan {
768    tracer
769        .span_builder(format!(
770            "{route_id}:{}",
771            label.as_deref().unwrap_or(&step_id_for(index))
772        ))
773        .with_kind(SpanKind::Internal)
774        .with_attributes(step_span_attributes(route_id, index, correlation_id))
775        .start_with_context(tracer, entry_cx)
776}
777
778/// Per-attempt span adapter for `CompiledStep::Segment` on traced
779/// pipelines (trace-model-tree T1.4).
780///
781/// Implements `RetryableStep` so BOTH the initial invocation and every
782/// retry attempt dispatched by `RouteErrorHandler::retry_step` run
783/// through the same wrapper: each `invoke` opens one fresh Internal span
784/// parented by the incoming context (the route root), named
785/// `{route_id}:{label}` when the segment step carries a DSL label (e.g.
786/// `split`) and `{route_id}:step-{index}` otherwise. It runs the inner
787/// segment with that span active, restores the incoming context on
788/// outcomes that carry the exchange, and ends the span with the future —
789/// spans never outlive the attempt.
790///
791/// Retry inputs are the error handler's preserved pre-attempt exchange,
792/// which still carries the route root context (restored by a previous
793/// attempt's Ok path, or never left on the first attempt), so every
794/// attempt span nests under the route root, not under each other.
795struct TracedSegmentStep {
796    segment: camel_api::OutcomeSegment,
797    route_id: String,
798    index: usize,
799    label: Option<Arc<str>>,
800}
801
802fn finish_span_outcome(
803    outcome: PipelineOutcome,
804    span_cx: &OtelContext,
805    entry_cx: OtelContext,
806) -> PipelineOutcome {
807    match outcome {
808        PipelineOutcome::Completed(mut ex) => {
809            span_cx.span().set_status(Status::Ok);
810            ex.otel_context = entry_cx;
811            PipelineOutcome::Completed(ex)
812        }
813        PipelineOutcome::Stopped(mut ex) => {
814            span_cx.span().set_status(Status::Ok);
815            ex.otel_context = entry_cx;
816            PipelineOutcome::Stopped(ex)
817        }
818        PipelineOutcome::Failed(e) => {
819            record_exception(&span_cx.span(), &e);
820            PipelineOutcome::Failed(e)
821        }
822    }
823}
824
825impl camel_api::error_handler::RetryableStep for TracedSegmentStep {
826    fn invoke<'a>(
827        &'a mut self,
828        mut exchange: Exchange,
829    ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
830        Box::pin(async move {
831            let tracer = global::tracer_with_scope(
832                InstrumentationScope::builder("camel-core")
833                    .with_version(env!("CARGO_PKG_VERSION"))
834                    .build(),
835            );
836            let entry_cx = exchange.otel_context.clone();
837            let span = segment_span(
838                &tracer,
839                &self.route_id,
840                self.index,
841                self.label.clone(),
842                &entry_cx,
843                exchange.correlation_id(),
844            );
845            let cx = entry_cx.with_span(span);
846            // Guard ends the attempt span even if the segment panics.
847            let _guard = SpanEndGuard(cx.clone());
848            exchange.otel_context = cx.clone();
849            finish_span_outcome(self.segment.run(exchange).await, &cx, entry_cx)
850        })
851    }
852}
853
854async fn invoke_with_span(
855    retryable: &mut dyn camel_api::error_handler::RetryableStep,
856    exchange: Exchange,
857    idx: usize,
858) -> PipelineOutcome {
859    retryable
860        .invoke(exchange)
861        .instrument(tracing::debug_span!("pipeline_step", index = idx))
862        .await
863}
864
865/// Route channel with explicit security and circuit-breaker gates.
866///
867/// Gate order: Security → CB(before_call) → Pipeline → CB(after_result).
868/// Errors from Security/CB gates go to `handler.handle_boundary`.
869/// Errors from Pipeline go through the injected handler's retry/handle_step.
870/// Pipeline Propagate returns Err — passed through to upstream.
871#[derive(Clone)]
872pub struct RouteChannelService {
873    handler: Arc<dyn RouteErrorHandler>,
874    security: Option<BoxProcessor>,
875    cb_gate: Option<CircuitBreakerGate>,
876    pipeline: BoxProcessor,
877    /// When true, stash the original Message as `ORIGINAL_MESSAGE_EXTENSION`
878    /// before any gate runs, so the error handler can restore it on failure.
879    use_original_message: bool,
880}
881
882impl RouteChannelService {
883    pub fn new(
884        handler: Arc<dyn RouteErrorHandler>,
885        security: Option<BoxProcessor>,
886        cb_gate: Option<CircuitBreakerGate>,
887        pipeline: BoxProcessor,
888        use_original_message: bool,
889    ) -> Self {
890        Self {
891            handler,
892            security,
893            cb_gate,
894            pipeline,
895            use_original_message,
896        }
897    }
898}
899
900impl Service<Exchange> for RouteChannelService {
901    type Response = Exchange;
902    type Error = CamelError;
903    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
904
905    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
906        // Swallow readiness errors from security gate — deferred to call()
907        if let Some(ref mut sec) = self.security {
908            match sec.clone().poll_ready(cx) {
909                Poll::Pending => return Poll::Pending,
910                Poll::Ready(Err(_)) | Poll::Ready(Ok(())) => {}
911            }
912        }
913        // rc-mn8n review: do NOT poll the pipeline here. Every handler
914        // route re-polls at invoke time (`RetryableStep::invoke` calls
915        // `ready()` before the step's `call`), so this pre-call poll only
916        // duplicated the tracer adapter's `poll_ready` Err-arm recording —
917        // one readiness failure counted more than once. Pending
918        // backpressure is preserved at the invoke re-poll.
919        Poll::Ready(Ok(()))
920    }
921
922    fn call(&mut self, exchange: Exchange) -> Self::Future {
923        let handler = self.handler.clone();
924        let security = self.security.clone();
925        let cb_gate = self.cb_gate.clone();
926        let mut pipeline = self.pipeline.clone();
927        let use_original_message = self.use_original_message;
928
929        Box::pin(async move {
930            let mut ex = exchange;
931
932            // Stash original message for use_original_message support.
933            // Done BEFORE any gate so the DLC can restore the pre-route message.
934            // Only stashes when the flag is true to avoid perf regression on every Exchange.
935            if use_original_message {
936                let original: Arc<Message> = Arc::new(ex.input.clone());
937                ex.set_extension(ORIGINAL_MESSAGE_EXTENSION, original);
938            }
939
940            // Gate 1: Security
941            if let Some(mut sec) = security {
942                let original = ex.clone();
943                match invoke_processor(&mut sec, ex).await {
944                    Ok(next) => ex = next,
945                    Err(err) => {
946                        return handler
947                            .handle_boundary(BoundaryKind::Security, original, err)
948                            .await;
949                    }
950                }
951            }
952
953            // Gate 2: CircuitBreaker — before_call
954            if let Some(ref cb) = cb_gate {
955                match cb.before_call() {
956                    CircuitBreakerDecision::Allow => { /* proceed to pipeline */ }
957                    CircuitBreakerDecision::Fallback(mut fb) => {
958                        // Circuit open with fallback — call fallback.
959                        // Fallback errors go through handle_boundary, not raw to upstream.
960                        let original = ex.clone();
961                        match invoke_processor(&mut fb, ex).await {
962                            Ok(result) => return Ok(result),
963                            Err(err) => {
964                                return handler
965                                    .handle_boundary(BoundaryKind::CircuitBreaker, original, err)
966                                    .await;
967                            }
968                        }
969                    }
970                    CircuitBreakerDecision::Reject(err) => {
971                        let original = ex.clone();
972                        return handler
973                            .handle_boundary(BoundaryKind::CircuitBreaker, original, err)
974                            .await;
975                    }
976                }
977            }
978
979            // Pipeline (handler already injected for step errors)
980            let result = invoke_processor(&mut pipeline, ex).await;
981
982            // Gate 2: CircuitBreaker — after_result
983            if let Some(ref cb) = cb_gate {
984                cb.after_result(&result);
985            }
986
987            // Propagate from inner handler — pass through to upstream
988            result
989        })
990    }
991}
992
993#[cfg(test)]
994#[path = "route_compiler_tests.rs"]
995mod tests;