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) = match step {
211 CompiledStep::Process {
212 processor,
213 body_contract,
214 lifecycle,
215 label,
216 kind_hint,
217 } => (processor, body_contract, lifecycle, label, kind_hint),
218 CompiledStep::Stop => return CompiledStep::Stop,
219 CompiledStep::Segment { .. } => return step,
220 };
221 let traced = BoxProcessor::new(
222 TracingProcessor::new(
223 p,
224 route_id.to_string(),
225 idx,
226 detail_level.clone(),
227 metrics.clone(),
228 lbl.clone(),
229 // Thread the registry-stamped kind hint (span-kind-hint
230 // 1.3) so `to:http` steps export Client spans and broker
231 // sends export Producer spans; unlabeled/non-To steps keep
232 // the Internal default.
233 kh,
234 )
235 .with_spans_enabled(gating.spans_enabled)
236 .with_metric_levers(gating.levers.clone()),
237 );
238 CompiledStep::Process {
239 processor: traced,
240 body_contract: c,
241 lifecycle: lc,
242 label: lbl,
243 kind_hint: kh,
244 }
245 })
246 .collect();
247
248 // Spans off: no route root span — a plain sequential pipeline over the
249 // metrics-emitting step wrappers.
250 if !gating.spans_enabled {
251 return BoxProcessor::new(SequentialPipeline {
252 steps: SharedSnapshot(wrapped.into()),
253 handler,
254 ctx,
255 });
256 }
257
258 BoxProcessor::new(TracedPipeline {
259 steps: SharedSnapshot(wrapped.into()),
260 route_id: route_id.to_string(),
261 handler,
262 ctx,
263 })
264}
265
266/// Compose a list of `CompiledStep` items into a single pipeline with body coercion.
267///
268/// Each processor is optionally wrapped with `BodyCoercingProcessor` based on its
269/// contract. Processors with `None` contract are passed through with zero overhead.
270/// `CompiledStep::Stop` passes through without coercion.
271pub fn compose_pipeline_with_contracts(
272 processors: Vec<CompiledStep>,
273 handler: Option<Arc<dyn RouteErrorHandler>>,
274 ctx: PipelineRuntimeCtx,
275) -> BoxProcessor {
276 let wrapped: Vec<CompiledStep> = processors
277 .into_iter()
278 .map(|step| match step {
279 CompiledStep::Process {
280 processor,
281 body_contract,
282 lifecycle,
283 label,
284 kind_hint,
285 } => {
286 let coerced = wrap_if_needed(processor, body_contract);
287 CompiledStep::Process {
288 processor: coerced,
289 body_contract: None,
290 lifecycle,
291 label,
292 kind_hint,
293 }
294 }
295 CompiledStep::Stop => CompiledStep::Stop,
296 CompiledStep::Segment { .. } => step,
297 })
298 .collect();
299 compose_pipeline_with_handler(wrapped, handler, ctx)
300}
301
302/// Compose a list of `CompiledStep` items into a single pipeline with body coercion.
303///
304/// Applies body coercion contracts first, then wraps with `TracingProcessor`.
305/// The pipeline opens one Internal route root span per invocation (named after
306/// `route_id`); empty traced routes still return a `TracedPipeline` so the
307/// root span records the route invocation with zero steps.
308/// When the pipeline is disabled, falls back to [`compose_pipeline_with_contracts`].
309pub(crate) fn compose_traced_pipeline_with_contracts(
310 processors: Vec<CompiledStep>,
311 route_id: &str,
312 gating: impl Into<TracerPipelineGating>,
313 detail_level: DetailLevel,
314 metrics: Option<Arc<dyn MetricsCollector>>,
315 handler: Option<Arc<dyn RouteErrorHandler>>,
316 ctx: PipelineRuntimeCtx,
317) -> BoxProcessor {
318 let gating = gating.into();
319 if !gating.pipeline_enabled {
320 return compose_pipeline_with_contracts(processors, handler, ctx);
321 }
322
323 let coerced: Vec<CompiledStep> = processors
324 .into_iter()
325 .map(|step| match step {
326 CompiledStep::Process {
327 processor,
328 body_contract,
329 lifecycle,
330 label,
331 kind_hint,
332 } => {
333 let processor = wrap_if_needed(processor, body_contract);
334 CompiledStep::Process {
335 processor,
336 body_contract: None,
337 lifecycle,
338 label,
339 kind_hint,
340 }
341 }
342 CompiledStep::Stop => CompiledStep::Stop,
343 CompiledStep::Segment { .. } => step,
344 })
345 .collect();
346
347 compose_traced_pipeline(
348 coerced,
349 route_id,
350 gating,
351 detail_level,
352 metrics,
353 handler,
354 ctx,
355 )
356}
357
358/// A service that executes a sequence of CompiledSteps in order.
359///
360/// Uses `into_tower_result()` so `PipelineOutcome::Stopped(ex)` maps to
361/// `Ok(ex)` — the Bug B fix that makes Stop indistinguishable from Completed
362/// at the consumer boundary.
363#[derive(Clone)]
364struct SequentialPipeline {
365 steps: SharedSnapshot,
366 handler: Option<Arc<dyn RouteErrorHandler>>,
367 ctx: PipelineRuntimeCtx,
368}
369
370impl Service<Exchange> for SequentialPipeline {
371 type Response = Exchange;
372 type Error = CamelError;
373 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
374
375 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
376 // rc-mn8n review: with a handler, readiness errors are swallowed
377 // here anyway and every invoke re-polls the first step
378 // (`RetryableStep::invoke` calls `ready()` before the step's
379 // `call`), so a pre-invoke first-step poll only duplicates the
380 // tracer adapter's `poll_ready` Err-arm recording. Skip it —
381 // Pending backpressure is preserved at the invoke re-poll.
382 // Non-handler routes keep this poll: its Err is their only
383 // readiness signal (the call never runs on failure).
384 if self.handler.is_some() {
385 return Poll::Ready(Ok(()));
386 }
387 match self.steps.0.first() {
388 Some(CompiledStep::Process { processor, .. }) => {
389 let mut proc = processor.clone();
390 match proc.poll_ready(cx) {
391 Poll::Pending => Poll::Pending,
392 Poll::Ready(Err(_)) if self.handler.is_some() => Poll::Ready(Ok(())),
393 Poll::Ready(other) => Poll::Ready(other),
394 }
395 }
396 Some(CompiledStep::Stop) => Poll::Ready(Ok(())),
397 Some(CompiledStep::Segment { .. }) => Poll::Ready(Ok(())),
398 None => Poll::Ready(Ok(())),
399 }
400 }
401
402 // ADR-0024 reply-channel adapter: PipelineOutcome → Result<Exchange, CamelError>.
403 // Completed(ex) and Stopped(ex) both map to Ok(ex); Failed(err) maps to Err.
404 // Downstream consumers (RouteChannelService, ExchangeUoWLayer, HTTP/Kafka reply
405 // finalisers) see Result<Exchange, CamelError> and treat Stop as success.
406 fn call(&mut self, exchange: Exchange) -> Self::Future {
407 // Cheap Arc::clone (refcount bump) on the SharedSnapshot newtype.
408 // `SharedSnapshot: Send` so the future returned by `run_steps`
409 // captures it directly without needing a Send-asserting wrapper.
410 let steps = self.steps.clone();
411 let handler = self.handler.clone();
412 let ctx = self.ctx.clone();
413 Box::pin(async move {
414 run_steps(steps, exchange, handler, false, &ctx.route_id, &ctx)
415 .await
416 .into_tower_result()
417 })
418 }
419}
420
421/// A traced service pipeline for wrapped CompiledSteps.
422///
423/// Each invocation opens one Internal route root span named after the route;
424/// step spans (from `TracingProcessor`) nest under it. The root span handle
425/// lives inside the `call` async body — never on `self` — so hot-reload
426/// pipeline swaps are unaffected.
427#[derive(Clone)]
428struct TracedPipeline {
429 steps: SharedSnapshot,
430 route_id: String,
431 handler: Option<Arc<dyn RouteErrorHandler>>,
432 ctx: PipelineRuntimeCtx,
433}
434
435impl Service<Exchange> for TracedPipeline {
436 type Response = Exchange;
437 type Error = CamelError;
438 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
439
440 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
441 // rc-mn8n review: with a handler, readiness errors are swallowed
442 // here anyway and every invoke re-polls the first step
443 // (`RetryableStep::invoke` calls `ready()` before the step's
444 // `call`), so a pre-invoke first-step poll only duplicates the
445 // tracer adapter's `poll_ready` Err-arm recording. Skip it —
446 // Pending backpressure is preserved at the invoke re-poll.
447 // Non-handler routes keep this poll: its Err is their only
448 // readiness signal (the call never runs on failure).
449 if self.handler.is_some() {
450 return Poll::Ready(Ok(()));
451 }
452 match self.steps.0.first() {
453 Some(CompiledStep::Process { processor, .. }) => {
454 let mut proc = processor.clone();
455 match proc.poll_ready(cx) {
456 Poll::Pending => Poll::Pending,
457 Poll::Ready(Err(_)) if self.handler.is_some() => Poll::Ready(Ok(())),
458 Poll::Ready(other) => Poll::Ready(other),
459 }
460 }
461 Some(CompiledStep::Stop) => Poll::Ready(Ok(())),
462 Some(CompiledStep::Segment { .. }) => Poll::Ready(Ok(())),
463 None => Poll::Ready(Ok(())),
464 }
465 }
466
467 // ADR-0024 reply-channel adapter (same as SequentialPipeline::call):
468 // Completed(ex) and Stopped(ex) both map to Ok(ex). Bug B fix.
469 //
470 // Route root span (trace-model-tree T1.3): one Internal span per route
471 // invocation, named after the route, parenting every step span. Derived
472 // from the entry context (not the ambient current context) so parent
473 // entries such as baggage stay attached; the entry context is restored
474 // on the result exchange when one comes back.
475 fn call(&mut self, exchange: Exchange) -> Self::Future {
476 let steps = self.steps.clone();
477 let route_id = self.route_id.clone();
478 let handler = self.handler.clone();
479 let ctx = self.ctx.clone();
480 Box::pin(async move {
481 let tracer = global::tracer_with_scope(
482 InstrumentationScope::builder("camel-core")
483 .with_version(env!("CARGO_PKG_VERSION"))
484 .build(),
485 );
486 let entry_cx = exchange.otel_context.clone();
487 let root_span = tracer
488 .span_builder(route_id.clone())
489 .with_kind(SpanKind::Internal)
490 .with_attributes([
491 KeyValue::new("messaging.system", "camel"),
492 KeyValue::new("route_id", route_id.clone()),
493 KeyValue::new(
494 "correlation_id",
495 capped_correlation_id(exchange.correlation_id()).to_string(),
496 ),
497 ])
498 .start_with_context(&tracer, &entry_cx);
499 let root_cx = entry_cx.with_span(root_span);
500 // Guard ends the root span even if a step panics.
501 let _root_guard = SpanEndGuard(root_cx.clone());
502 let mut exchange = exchange;
503 exchange.otel_context = root_cx.clone();
504
505 let outcome = run_steps(steps, exchange, handler, true, &route_id, &ctx).await;
506 finish_span_outcome(outcome, &root_cx, entry_cx).into_tower_result()
507 })
508 }
509}
510
511/// Run a sequence of CompiledSteps with optional error recovery.
512///
513/// Each step is unified under [`OwnedRetryable`] — Process and
514/// Segment variants are treated uniformly via a stack-allocated enum
515/// that dispatches to the existing `RetryableStep` impls on
516/// `BoxProcessor` and `OutcomeSegment`. This eliminates the per-step
517/// `Box::new(...) as Box<dyn RetryableStep>` heap allocation that the
518/// pre-A2 implementation paid for every step of every Exchange (A2).
519///
520/// On the traced path (`trace == true`, `route_id` from the traced
521/// pipeline), Segment steps dispatch through [`TracedSegmentStep`]
522/// instead, so the initial invocation AND every retry attempt opened by
523/// the error handler runs through the same span wrapper (T1.4).
524///
525/// On failure:
526/// 1. If a handler is present, `match_policy` selects a retry policy.
527/// 2. `retry_step` attempts recovery; if exhausted, `handle_step` determines
528/// the disposition:
529/// - `Propagate` — return the error
530/// - `Handled` — return the exchange early (success)
531/// - `Continued` — clear the error and continue to the next step
532/// 3. If no handler is present, the error is propagated directly.
533///
534/// CompiledStep::Stop short-circuits to `PipelineOutcome::Stopped(ex)` — the
535/// handler is bypassed and no Tower service is invoked (ADR-0024 §3.5).
536async fn run_steps(
537 steps: SharedSnapshot,
538 exchange: Exchange,
539 handler: Option<Arc<dyn RouteErrorHandler>>,
540 trace: bool,
541 route_id: &str,
542 ctx: &PipelineRuntimeCtx,
543) -> PipelineOutcome {
544 use camel_api::error_handler::RetryableStep;
545 let mut ex = exchange;
546 // Index-based loop (not `for (i, step) in steps.0.iter().enumerate()`):
547 // retained to avoid holding a `&[CompiledStep]` borrow across the
548 // `.await` below — `&steps.0[i]` is consumed by the `match` scrutinee
549 // and drops before the await, so no borrow is live across the await
550 // point. The original `CompiledStep: !Sync` rationale is gone
551 // (`BoxProcessor` is now `Send + Sync` via `BoxCloneSyncService`);
552 // the loop shape is kept purely for borrow hygiene — no behavior change.
553 let len = steps.0.len();
554 for i in 0..len {
555 // B1: cooperative cancellation between steps via task-local.
556 // If the task-local is not set (direct test calls), skip the check.
557 let cancelled = CANCEL_TOKEN.try_with(|t| t.is_cancelled()).unwrap_or(false);
558 if cancelled {
559 return PipelineOutcome::Failed(CamelError::ConsumerStopping);
560 }
561 // A2: dispatch to existing `RetryableStep` impls through a stack
562 // enum instead of paying `Box::new(...) as Box<dyn RetryableStep>`
563 // per step. `OwnedRetryable` is `enum { Processor, Segment }` with
564 // discriminant-by-value layout — no extra heap alloc. On the traced
565 // path, Segment steps take the `TracedSegment` variant so every
566 // attempt (initial + retries) gets its own step span (T1.4).
567 let mut retryable: OwnedRetryable = match &steps.0[i] {
568 CompiledStep::Stop => return PipelineOutcome::Stopped(ex),
569 CompiledStep::Process { processor, .. } => OwnedRetryable::Processor(processor.clone()),
570 CompiledStep::Segment { segment, label, .. } => {
571 if trace {
572 OwnedRetryable::TracedSegment(TracedSegmentStep {
573 segment: segment.clone(),
574 route_id: route_id.to_string(),
575 index: i,
576 label: label.clone(),
577 })
578 } else {
579 OwnedRetryable::Segment(segment.clone())
580 }
581 }
582 };
583
584 let original = handler.as_ref().map(|_| ex.clone());
585 let outcome = if trace {
586 invoke_with_span(&mut retryable, ex, i).await
587 } else {
588 retryable.invoke(ex).await
589 };
590
591 match outcome {
592 PipelineOutcome::Completed(next) => {
593 if camel_api::is_camel_stop(&next) {
594 return PipelineOutcome::Stopped(next);
595 }
596 ex = next;
597 }
598 PipelineOutcome::Stopped(stopped_ex) => {
599 return PipelineOutcome::Stopped(stopped_ex);
600 }
601 PipelineOutcome::Failed(err) => {
602 let (Some(handler), Some(original)) = (handler.as_ref(), original) else {
603 return PipelineOutcome::Failed(err);
604 };
605 let policy = handler.match_policy(&err);
606 // `&mut retryable` auto-coerces from `&mut OwnedRetryable` to
607 // `&mut dyn RetryableStep` via the trait impl on the enum.
608 match handler
609 .retry_step(policy, &mut retryable, original, err)
610 .await
611 {
612 RetryOutcome::Recovered(exchange) => {
613 // allow-open-label rc-xl5k (route label: user-defined route id, bounded by route count)
614 ctx.metrics.record_counter(
615 "pipeline_disposition",
616 1.0,
617 &[("disposition", "recovered"), ("route_id", &ctx.route_id)],
618 );
619 ex = exchange;
620 }
621 RetryOutcome::Stopped(stopped_ex) => {
622 // allow-open-label rc-xl5k (route label: user-defined route id, bounded by route count)
623 ctx.metrics.record_counter(
624 "pipeline_disposition",
625 1.0,
626 &[("disposition", "stopped"), ("route_id", &ctx.route_id)],
627 );
628 return PipelineOutcome::Stopped(stopped_ex);
629 }
630 RetryOutcome::Exhausted {
631 exchange,
632 error,
633 policy,
634 } => {
635 let disposition = if trace {
636 handler
637 .handle_step(policy, exchange, error)
638 .instrument(tracing::debug_span!("error_handler", step_index = i))
639 .await
640 } else {
641 handler.handle_step(policy, exchange, error).await
642 };
643 match disposition {
644 Ok(StepDisposition::Propagate(e)) => {
645 // allow-open-label rc-xl5k (route label: user-defined route id, bounded by route count)
646 ctx.metrics.record_counter(
647 "pipeline_disposition",
648 1.0,
649 &[("disposition", "propagated"), ("route_id", &ctx.route_id)],
650 );
651 return PipelineOutcome::Failed(e);
652 }
653 Ok(StepDisposition::Handled(done)) => {
654 // allow-open-label rc-xl5k (route label: user-defined route id, bounded by route count)
655 ctx.metrics.record_counter(
656 "pipeline_disposition",
657 1.0,
658 &[("disposition", "handled"), ("route_id", &ctx.route_id)],
659 );
660 return PipelineOutcome::Completed(done);
661 }
662 Ok(StepDisposition::Continued(next)) => {
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", "continued"), ("route_id", &ctx.route_id)],
668 );
669 ex = next;
670 }
671 Err(e) => {
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 &[
677 ("disposition", "handler_error"),
678 ("route_id", &ctx.route_id),
679 ],
680 );
681 return PipelineOutcome::Failed(e);
682 }
683 // Future StepDisposition variants fail the pipeline.
684 _ => {
685 return PipelineOutcome::Failed(CamelError::ProcessorError(
686 "unknown step disposition".to_string(),
687 ));
688 }
689 }
690 }
691 // Future RetryOutcome variants fail the pipeline.
692 _ => {
693 return PipelineOutcome::Failed(CamelError::ProcessorError(
694 "unknown retry outcome".to_string(),
695 ));
696 }
697 }
698 }
699 }
700 }
701 PipelineOutcome::Completed(ex)
702}
703
704/// Stack-allocated dispatcher that unifies `BoxProcessor` and
705/// `OutcomeSegment` for the retry path without the heap allocation a
706/// `Box<dyn RetryableStep>` would require. Sized by-value, dispatched
707/// through a single trait method that fans out to the existing
708/// `RetryableStep` impls on each variant.
709///
710/// A2: replaces `Box::new(processor.clone()) as Box<dyn RetryableStep>`
711/// (and the equivalent for segments) with this enum, saving one heap
712/// allocation per pipeline step per Exchange invocation.
713enum OwnedRetryable {
714 Processor(camel_api::BoxProcessor),
715 Segment(camel_api::OutcomeSegment),
716 /// Traced segment dispatch (T1.4): every attempt — the initial
717 /// invocation and each retry opened by the error handler — goes
718 /// through `TracedSegmentStep` so each gets its own step span.
719 TracedSegment(TracedSegmentStep),
720}
721
722impl camel_api::error_handler::RetryableStep for OwnedRetryable {
723 fn invoke<'a>(
724 &'a mut self,
725 exchange: Exchange,
726 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
727 match self {
728 OwnedRetryable::Processor(p) => p.invoke(exchange),
729 OwnedRetryable::Segment(s) => s.invoke(exchange),
730 OwnedRetryable::TracedSegment(s) => s.invoke(exchange),
731 }
732 }
733}
734
735/// Start an Internal span for one segment step attempt, parented by
736/// `entry_cx` (the traced pipeline's root context) with the Minimal-level
737/// attribute set from `step_span_attributes` (trace-model-tree T1.4).
738///
739/// Named `{route_id}:{label}` when the segment step carries a DSL label
740/// (e.g. `split`); unlabeled segments fall back to the positional
741/// `{route_id}:step-{index}` name — same contract as process step spans.
742fn segment_span(
743 tracer: &global::BoxedTracer,
744 route_id: &str,
745 index: usize,
746 label: Option<Arc<str>>,
747 entry_cx: &OtelContext,
748 correlation_id: &str,
749) -> global::BoxedSpan {
750 tracer
751 .span_builder(format!(
752 "{route_id}:{}",
753 label.as_deref().unwrap_or(&step_id_for(index))
754 ))
755 .with_kind(SpanKind::Internal)
756 .with_attributes(step_span_attributes(route_id, index, correlation_id))
757 .start_with_context(tracer, entry_cx)
758}
759
760/// Per-attempt span adapter for `CompiledStep::Segment` on traced
761/// pipelines (trace-model-tree T1.4).
762///
763/// Implements `RetryableStep` so BOTH the initial invocation and every
764/// retry attempt dispatched by `RouteErrorHandler::retry_step` run
765/// through the same wrapper: each `invoke` opens one fresh Internal span
766/// parented by the incoming context (the route root), named
767/// `{route_id}:{label}` when the segment step carries a DSL label (e.g.
768/// `split`) and `{route_id}:step-{index}` otherwise. It runs the inner
769/// segment with that span active, restores the incoming context on
770/// outcomes that carry the exchange, and ends the span with the future —
771/// spans never outlive the attempt.
772///
773/// Retry inputs are the error handler's preserved pre-attempt exchange,
774/// which still carries the route root context (restored by a previous
775/// attempt's Ok path, or never left on the first attempt), so every
776/// attempt span nests under the route root, not under each other.
777struct TracedSegmentStep {
778 segment: camel_api::OutcomeSegment,
779 route_id: String,
780 index: usize,
781 label: Option<Arc<str>>,
782}
783
784fn finish_span_outcome(
785 outcome: PipelineOutcome,
786 span_cx: &OtelContext,
787 entry_cx: OtelContext,
788) -> PipelineOutcome {
789 match outcome {
790 PipelineOutcome::Completed(mut ex) => {
791 span_cx.span().set_status(Status::Ok);
792 ex.otel_context = entry_cx;
793 PipelineOutcome::Completed(ex)
794 }
795 PipelineOutcome::Stopped(mut ex) => {
796 span_cx.span().set_status(Status::Ok);
797 ex.otel_context = entry_cx;
798 PipelineOutcome::Stopped(ex)
799 }
800 PipelineOutcome::Failed(e) => {
801 record_exception(&span_cx.span(), &e);
802 PipelineOutcome::Failed(e)
803 }
804 }
805}
806
807impl camel_api::error_handler::RetryableStep for TracedSegmentStep {
808 fn invoke<'a>(
809 &'a mut self,
810 mut exchange: Exchange,
811 ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
812 Box::pin(async move {
813 let tracer = global::tracer_with_scope(
814 InstrumentationScope::builder("camel-core")
815 .with_version(env!("CARGO_PKG_VERSION"))
816 .build(),
817 );
818 let entry_cx = exchange.otel_context.clone();
819 let span = segment_span(
820 &tracer,
821 &self.route_id,
822 self.index,
823 self.label.clone(),
824 &entry_cx,
825 exchange.correlation_id(),
826 );
827 let cx = entry_cx.with_span(span);
828 // Guard ends the attempt span even if the segment panics.
829 let _guard = SpanEndGuard(cx.clone());
830 exchange.otel_context = cx.clone();
831 finish_span_outcome(self.segment.run(exchange).await, &cx, entry_cx)
832 })
833 }
834}
835
836async fn invoke_with_span(
837 retryable: &mut dyn camel_api::error_handler::RetryableStep,
838 exchange: Exchange,
839 idx: usize,
840) -> PipelineOutcome {
841 retryable
842 .invoke(exchange)
843 .instrument(tracing::debug_span!("pipeline_step", index = idx))
844 .await
845}
846
847/// Route channel with explicit security and circuit-breaker gates.
848///
849/// Gate order: Security → CB(before_call) → Pipeline → CB(after_result).
850/// Errors from Security/CB gates go to `handler.handle_boundary`.
851/// Errors from Pipeline go through the injected handler's retry/handle_step.
852/// Pipeline Propagate returns Err — passed through to upstream.
853#[derive(Clone)]
854pub struct RouteChannelService {
855 handler: Arc<dyn RouteErrorHandler>,
856 security: Option<BoxProcessor>,
857 cb_gate: Option<CircuitBreakerGate>,
858 pipeline: BoxProcessor,
859 /// When true, stash the original Message as `ORIGINAL_MESSAGE_EXTENSION`
860 /// before any gate runs, so the error handler can restore it on failure.
861 use_original_message: bool,
862}
863
864impl RouteChannelService {
865 pub fn new(
866 handler: Arc<dyn RouteErrorHandler>,
867 security: Option<BoxProcessor>,
868 cb_gate: Option<CircuitBreakerGate>,
869 pipeline: BoxProcessor,
870 use_original_message: bool,
871 ) -> Self {
872 Self {
873 handler,
874 security,
875 cb_gate,
876 pipeline,
877 use_original_message,
878 }
879 }
880}
881
882impl Service<Exchange> for RouteChannelService {
883 type Response = Exchange;
884 type Error = CamelError;
885 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
886
887 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
888 // Swallow readiness errors from security gate — deferred to call()
889 if let Some(ref mut sec) = self.security {
890 match sec.clone().poll_ready(cx) {
891 Poll::Pending => return Poll::Pending,
892 Poll::Ready(Err(_)) | Poll::Ready(Ok(())) => {}
893 }
894 }
895 // rc-mn8n review: do NOT poll the pipeline here. Every handler
896 // route re-polls at invoke time (`RetryableStep::invoke` calls
897 // `ready()` before the step's `call`), so this pre-call poll only
898 // duplicated the tracer adapter's `poll_ready` Err-arm recording —
899 // one readiness failure counted more than once. Pending
900 // backpressure is preserved at the invoke re-poll.
901 Poll::Ready(Ok(()))
902 }
903
904 fn call(&mut self, exchange: Exchange) -> Self::Future {
905 let handler = self.handler.clone();
906 let security = self.security.clone();
907 let cb_gate = self.cb_gate.clone();
908 let mut pipeline = self.pipeline.clone();
909 let use_original_message = self.use_original_message;
910
911 Box::pin(async move {
912 let mut ex = exchange;
913
914 // Stash original message for use_original_message support.
915 // Done BEFORE any gate so the DLC can restore the pre-route message.
916 // Only stashes when the flag is true to avoid perf regression on every Exchange.
917 if use_original_message {
918 let original: Arc<Message> = Arc::new(ex.input.clone());
919 ex.set_extension(ORIGINAL_MESSAGE_EXTENSION, original);
920 }
921
922 // Gate 1: Security
923 if let Some(mut sec) = security {
924 let original = ex.clone();
925 match invoke_processor(&mut sec, ex).await {
926 Ok(next) => ex = next,
927 Err(err) => {
928 return handler
929 .handle_boundary(BoundaryKind::Security, original, err)
930 .await;
931 }
932 }
933 }
934
935 // Gate 2: CircuitBreaker — before_call
936 if let Some(ref cb) = cb_gate {
937 match cb.before_call() {
938 CircuitBreakerDecision::Allow => { /* proceed to pipeline */ }
939 CircuitBreakerDecision::Fallback(mut fb) => {
940 // Circuit open with fallback — call fallback.
941 // Fallback errors go through handle_boundary, not raw to upstream.
942 let original = ex.clone();
943 match invoke_processor(&mut fb, ex).await {
944 Ok(result) => return Ok(result),
945 Err(err) => {
946 return handler
947 .handle_boundary(BoundaryKind::CircuitBreaker, original, err)
948 .await;
949 }
950 }
951 }
952 CircuitBreakerDecision::Reject(err) => {
953 let original = ex.clone();
954 return handler
955 .handle_boundary(BoundaryKind::CircuitBreaker, original, err)
956 .await;
957 }
958 }
959 }
960
961 // Pipeline (handler already injected for step errors)
962 let result = invoke_processor(&mut pipeline, ex).await;
963
964 // Gate 2: CircuitBreaker — after_result
965 if let Some(ref cb) = cb_gate {
966 cb.after_result(&result);
967 }
968
969 // Propagate from inner handler — pass through to upstream
970 result
971 })
972 }
973}
974
975#[cfg(test)]
976#[path = "route_compiler_tests.rs"]
977mod tests;