Skip to main content

camel_processor/
split_segment.rs

1//! ## Stop semantics (ADR-0025)
2//!
3//! This segment implements `OutcomePipeline` and propagates `PipelineOutcome::Stopped(ex)` with the exchange state intact (including mutations made inside the segment body before Stop fired). See ADR-0025 §3 (stopped-exchange-state-preservation invariant).
4
5use std::future::Future;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9use tokio::task::JoinSet;
10
11use camel_api::{
12    AggregationStrategy, Body, CamelError, Exchange, OutcomeSegment, PipelineOutcome,
13    SplitExpression, Value,
14};
15
16// ── aggregate_completed (SplitSegment helper) ─────────────────────────
17
18/// Aggregate completed fragment outputs into a single Exchange.
19///
20/// Unlike `aggregate` (which works on `Vec<Result<Exchange, CamelError>>`),
21/// this operates on `Vec<Exchange>` where all entries are `Completed` outcomes.
22pub(crate) fn aggregate_completed(
23    completed: Vec<Exchange>,
24    original: Exchange,
25    strategy: AggregationStrategy,
26) -> Exchange {
27    match strategy {
28        AggregationStrategy::LastWins => completed.into_iter().last().unwrap_or(original),
29        AggregationStrategy::CollectAll => {
30            let mut bodies = Vec::new();
31            for ex in &completed {
32                let value = match &ex.input.body {
33                    Body::Text(s) => Value::String(s.clone()),
34                    Body::Json(v) => v.clone(),
35                    Body::Xml(s) => Value::String(s.clone()),
36                    Body::Bytes(b) => Value::String(String::from_utf8_lossy(b).into_owned()),
37                    Body::Stream(s) => serde_json::json!({
38                        "_stream": {
39                            "origin": s.metadata.origin,
40                            "placeholder": true,
41                            "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
42                        }
43                    }),
44                    // Empty and future variants contribute no extractable value.
45                    _ => Value::Null,
46                };
47                bodies.push(value);
48            }
49            let mut out = original;
50            out.input.body = Body::Json(Value::Array(bodies));
51            out
52        }
53        AggregationStrategy::Custom(fold_fn) => {
54            let mut iter = completed.into_iter();
55            let first = iter.next().unwrap_or(original);
56            iter.fold(first, |acc, next| fold_fn(acc, next))
57        }
58        // Original and any future variant return the original exchange.
59        _ => original,
60    }
61}
62
63// ── SplitSegment (ADR-0025 OutcomePipeline) ────────────────────────────
64
65/// Outcome-aware structural EIP segment for the Split pattern.
66///
67/// Splits an incoming exchange into fragments, processes each fragment through
68/// `body`, and aggregates the results. Supports sequential and parallel modes.
69///
70/// In sequential mode, fragments are processed in order. A `Stopped` or `Failed`
71/// outcome from any fragment halts processing immediately and propagates.
72///
73/// In parallel mode, all fragments are spawned as tokio tasks. The first
74/// `Stopped` outcome (lowest fragment index wins via CAS) propagates as the
75/// outer `Stopped`. In-flight tasks run to completion (spec §5.6: no abrupt
76/// abort — child sub-pipelines may have HTTP/SQL side effects). Tasks that
77/// have not started yet are short-circuited via a pre-start gate.
78///
79/// Unlike `SplitterService` (which operates at the Tower layer and cannot
80/// preserve `Stopped(ex)` with mutations), `SplitSegment` operates at the
81/// `PipelineOutcome` layer and preserves the exchange including all mutations
82/// at the Stop point.
83pub struct SplitSegment {
84    /// Splits an exchange into fragment exchanges.
85    pub splitter: SplitExpression,
86    /// The sub-pipeline executed for each fragment.
87    pub body: OutcomeSegment,
88    /// Whether to process fragments in parallel.
89    pub parallel: bool,
90    /// Maximum number of concurrent fragments in parallel mode (None = unlimited).
91    pub parallel_limit: Option<usize>,
92    /// Whether to stop processing on the first exception.
93    ///
94    /// When `true`, a `Failed` outcome from any fragment halts processing
95    /// immediately. When `false`, the error is collected and processing
96    /// continues; the last-seen error is propagated (last-wins, matching legacy multicast.rs::process_parallel)
97    /// is propagated after all fragments complete.
98    ///
99    /// `Stopped` outcomes always propagate immediately regardless of this
100    /// flag (per ADR-0025 §7 — Stop is successful control flow).
101    pub stop_on_exception: bool,
102    /// Strategy for aggregating fragment results.
103    pub aggregation: AggregationStrategy,
104}
105
106impl Clone for SplitSegment {
107    fn clone(&self) -> Self {
108        Self {
109            splitter: Arc::clone(&self.splitter),
110            body: self.body.clone(),
111            parallel: self.parallel,
112            parallel_limit: self.parallel_limit,
113            stop_on_exception: self.stop_on_exception,
114            aggregation: self.aggregation.clone(),
115        }
116    }
117}
118
119impl camel_api::OutcomePipeline for SplitSegment {
120    fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
121        Box::new(self.clone())
122    }
123
124    fn run<'a>(
125        &'a mut self,
126        exchange: camel_api::Exchange,
127    ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
128        let splitter = Arc::clone(&self.splitter);
129        let aggregation = self.aggregation.clone();
130        let parallel = self.parallel;
131        let parallel_limit = self.parallel_limit;
132        let stop_on_exception = self.stop_on_exception;
133        let body = &mut self.body;
134
135        Box::pin(async move {
136            let original = exchange;
137            // A typed error from the split expression fails loud as
138            // `Failed`, carrying the original error untouched.
139            let fragments = match splitter(&original) {
140                Ok(fragments) => fragments,
141                Err(err) => return PipelineOutcome::Failed(err),
142            };
143
144            if fragments.is_empty() {
145                return PipelineOutcome::Completed(original);
146            }
147
148            if parallel {
149                parallel_split(
150                    fragments,
151                    original,
152                    body,
153                    &aggregation,
154                    parallel_limit,
155                    stop_on_exception,
156                )
157                .await
158            } else {
159                sequential_split(fragments, original, body, &aggregation, stop_on_exception).await
160            }
161        })
162    }
163}
164
165// ── Sequential split ────────────────────────────────────────────────────
166
167async fn sequential_split(
168    fragments: Vec<Exchange>,
169    original: Exchange,
170    body: &mut OutcomeSegment,
171    aggregation: &AggregationStrategy,
172    stop_on_exception: bool,
173) -> PipelineOutcome {
174    let mut outputs = Vec::new();
175    let mut last_error: Option<CamelError> = None;
176    for frag in fragments {
177        match body.run(frag).await {
178            PipelineOutcome::Completed(ex) => outputs.push(ex),
179            PipelineOutcome::Stopped(ex) => return PipelineOutcome::Stopped(ex),
180            PipelineOutcome::Failed(err) => {
181                if stop_on_exception {
182                    return PipelineOutcome::Failed(err);
183                }
184                // stop_on_exception=false: collect the error and continue.
185                last_error = Some(err);
186            }
187        }
188    }
189    if let Some(err) = last_error {
190        return PipelineOutcome::Failed(err);
191    }
192    PipelineOutcome::Completed(aggregate_completed(outputs, original, aggregation.clone()))
193}
194
195// ── Parallel split ──────────────────────────────────────────────────────
196
197/// Parallel split with lowest-index-wins CAS semantics.
198///
199/// See spec §5.2.2 line 497 for the CAS guarantee and §5.6 line 544 for the
200/// "no abrupt abort" in-flight task policy (pre-start gate + run-to-completion).
201async fn parallel_split(
202    fragments: Vec<Exchange>,
203    original: Exchange,
204    body: &mut OutcomeSegment,
205    aggregation: &AggregationStrategy,
206    parallel_limit: Option<usize>,
207    stop_on_exception: bool,
208) -> PipelineOutcome {
209    use tokio::sync::Semaphore;
210
211    let stopped_seen = Arc::new(AtomicBool::new(false));
212    let stopped_idx = Arc::new(AtomicUsize::new(usize::MAX));
213    let aggregation = aggregation.clone();
214    let semaphore = parallel_limit
215        .filter(|&limit| limit > 0)
216        .map(|limit| Arc::new(Semaphore::new(limit)));
217
218    let mut set: JoinSet<(usize, Option<PipelineOutcome>)> = JoinSet::new();
219
220    for (idx, frag) in fragments.into_iter().enumerate() {
221        let mut body = body.clone();
222        let stopped_seen = Arc::clone(&stopped_seen);
223        let stopped_idx = Arc::clone(&stopped_idx);
224        let sem = semaphore.clone();
225        set.spawn(async move {
226            // Pre-start gate: a lower-index branch already stopped.
227            // This is the ONLY cancellation check — once a body starts
228            // running, it runs to completion (spec §5.6: "in-flight futures
229            // MUST NOT be abruptly aborted").
230            if stopped_seen.load(Ordering::SeqCst) {
231                return (idx, None);
232            }
233            // Acquire semaphore permit if parallel_limit is set.
234            let _permit: Option<tokio::sync::OwnedSemaphorePermit> = match &sem {
235                Some(s) => match std::sync::Arc::clone(s).acquire_owned().await {
236                    Ok(p) => Some(p),
237                    Err(_) => {
238                        return (
239                            idx,
240                            Some(PipelineOutcome::Failed(CamelError::ProcessorError(
241                                "semaphore closed".into(),
242                            ))),
243                        );
244                    }
245                },
246                None => None,
247            };
248            // Re-check pre-start gate after permit acquisition
249            // (another branch may have stopped while we were waiting).
250            if stopped_seen.load(Ordering::SeqCst) {
251                return (idx, None);
252            }
253            let outcome = body.run(frag).await;
254            if let PipelineOutcome::Stopped(_) = &outcome {
255                // Lower-the-value CAS: ensures lowest-branch-index wins
256                // even under simultaneous Stop (spec §5.2.2 line 497).
257                // Loop until our idx is recorded or a lower idx has already
258                // claimed it.
259                loop {
260                    let cur = stopped_idx.load(Ordering::SeqCst);
261                    if idx >= cur {
262                        break; // a lower index already won
263                    }
264                    match stopped_idx.compare_exchange_weak(
265                        cur,
266                        idx,
267                        Ordering::SeqCst,
268                        Ordering::SeqCst,
269                    ) {
270                        Ok(_) => break,
271                        Err(actual) => {
272                            // CAS failed — `actual` is the new cur; loop and
273                            // retry with updated value.
274                            if actual <= idx {
275                                break;
276                            }
277                        }
278                    }
279                }
280                stopped_seen.store(true, Ordering::SeqCst);
281            }
282            (idx, Some(outcome))
283        });
284    }
285
286    // Wait for ALL in-flight branches to finish (spec §5.6: no abrupt
287    // abort). Post-stop outputs are discarded at aggregation, but the
288    // branches DO complete.
289    let mut results: Vec<(usize, PipelineOutcome)> = Vec::new();
290    while let Some(res) = set.join_next().await {
291        if let Ok((idx, Some(o))) = res {
292            results.push((idx, o));
293        }
294    }
295
296    // Deterministic lowest-branch-index wins (spec §5.2.2 line 497).
297    if stopped_seen.load(Ordering::SeqCst) {
298        let winning_idx = stopped_idx.load(Ordering::SeqCst);
299        if winning_idx == usize::MAX {
300            tracing::warn!(
301                target: "camel.phase4.split",
302                "stopped_seen=true but stopped_idx=usize::MAX — race condition; falling back to pre-split exchange"
303            );
304            return PipelineOutcome::Stopped(original);
305        }
306        let stopped_ex = results
307            .iter()
308            .find(|(idx, _)| *idx == winning_idx)
309            .and_then(|(_, o)| match o {
310                PipelineOutcome::Stopped(ex) => Some(ex.clone()),
311                _ => None,
312            });
313        if let Some(ex) = stopped_ex {
314            return PipelineOutcome::Stopped(ex);
315        }
316        tracing::warn!(
317            target: "camel.phase4.split",
318            winning_idx = winning_idx,
319            "winning_idx not found in results — falling back to pre-split exchange"
320        );
321        return PipelineOutcome::Stopped(original);
322    }
323
324    // No Stop — check for Failed.
325    // stop_on_exception=true: propagate first Failed (lowest branch index).
326    // stop_on_exception=false: collect last error (last-wins) and propagate at end.
327    results.sort_by_key(|(idx, _)| *idx);
328    if stop_on_exception {
329        let mut first_failed: Option<(usize, CamelError)> = None;
330        for (idx, o) in &results {
331            if let PipelineOutcome::Failed(err) = o
332                && first_failed
333                    .as_ref()
334                    .map(|(i, _)| *i > *idx)
335                    .unwrap_or(true)
336            {
337                first_failed = Some((*idx, err.clone()));
338            }
339        }
340        if let Some((_, err)) = first_failed {
341            return PipelineOutcome::Failed(err);
342        }
343    } else {
344        // Collect last error (last-wins, matching MulticastSegment semantics).
345        let mut last_error: Option<CamelError> = None;
346        for (_, o) in &results {
347            if let PipelineOutcome::Failed(err) = o {
348                last_error = Some(err.clone());
349            }
350        }
351        if let Some(err) = last_error {
352            return PipelineOutcome::Failed(err);
353        }
354    }
355
356    // All Completed — aggregate.
357    let completed: Vec<Exchange> = results
358        .into_iter()
359        .filter_map(|(_, o)| match o {
360            PipelineOutcome::Completed(ex) => Some(ex),
361            _ => None,
362        })
363        .collect();
364    PipelineOutcome::Completed(aggregate_completed(completed, original, aggregation))
365}
366
367// ── Tests ──────────────────────────────────────────────────────────────
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use camel_api::Message;
373
374    // ── Test helpers ───────────────────────────────────────────────────
375
376    /// Helper: OutcomePipeline body that always returns `Completed(exchange)`.
377    #[derive(Clone)]
378    #[allow(dead_code)]
379    struct CompletedBody;
380    impl camel_api::OutcomePipeline for CompletedBody {
381        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
382            Box::new(CompletedBody)
383        }
384        fn run<'a>(
385            &'a mut self,
386            exchange: Exchange,
387        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
388            Box::pin(async move { PipelineOutcome::Completed(exchange) })
389        }
390    }
391
392    /// Helper: OutcomePipeline body that always returns `Stopped(exchange)`.
393    #[derive(Clone)]
394    #[allow(dead_code)]
395    struct StopBody;
396    impl camel_api::OutcomePipeline for StopBody {
397        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
398            Box::new(StopBody)
399        }
400        fn run<'a>(
401            &'a mut self,
402            exchange: Exchange,
403        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
404            Box::pin(async move { PipelineOutcome::Stopped(exchange) })
405        }
406    }
407
408    /// Helper: OutcomePipeline body that stops on the nth invocation (0-indexed).
409    #[derive(Clone)]
410    struct StopOnNthBody {
411        counter: Arc<AtomicUsize>,
412        stop_at: usize,
413    }
414    impl camel_api::OutcomePipeline for StopOnNthBody {
415        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
416            Box::new(self.clone())
417        }
418        fn run<'a>(
419            &'a mut self,
420            exchange: Exchange,
421        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
422            let count = self.counter.fetch_add(1, Ordering::SeqCst);
423            let stop_at = self.stop_at;
424            Box::pin(async move {
425                if count >= stop_at {
426                    PipelineOutcome::Stopped(exchange)
427                } else {
428                    PipelineOutcome::Completed(exchange)
429                }
430            })
431        }
432    }
433
434    /// Helper: OutcomePipeline body that mutates the exchange body then stops.
435    #[derive(Clone)]
436    struct MutateAndStopBody;
437    impl camel_api::OutcomePipeline for MutateAndStopBody {
438        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
439            Box::new(MutateAndStopBody)
440        }
441        fn run<'a>(
442            &'a mut self,
443            mut exchange: Exchange,
444        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
445            Box::pin(async move {
446                exchange.input.body = Body::Text("mutated-by-body".to_string());
447                PipelineOutcome::Stopped(exchange)
448            })
449        }
450    }
451
452    // ── Test 1: Sequential split — Stop halts remaining fragments ──
453
454    #[tokio::test]
455    async fn stop_inside_split_sequential_halts_remaining_fragments() {
456        let invocations = Arc::new(AtomicUsize::new(0));
457        let body = StopOnNthBody {
458            counter: Arc::clone(&invocations),
459            stop_at: 1, // stop on the 2nd fragment (index 1)
460        };
461
462        let mut seg = SplitSegment {
463            splitter: camel_api::split_body_lines(),
464            body: OutcomeSegment::new(Box::new(body)),
465            parallel: false,
466            parallel_limit: None,
467            stop_on_exception: true,
468            aggregation: AggregationStrategy::LastWins,
469        };
470
471        let ex = Exchange::new(Message::new("a\nb\nc"));
472        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
473
474        assert!(matches!(result, PipelineOutcome::Stopped(_)));
475        // Fragments 0 (pass) + 1 (stop) = 2 invocations; fragment 2 never runs.
476        assert_eq!(invocations.load(Ordering::SeqCst), 2);
477    }
478
479    // ── Test 2: Sequential split — Stop preserves exchange mutations ──
480
481    #[tokio::test]
482    async fn stop_inside_split_sequential_preserves_exchange_mutations() {
483        let mut seg = SplitSegment {
484            splitter: camel_api::split_body_lines(),
485            body: OutcomeSegment::new(Box::new(MutateAndStopBody)),
486            parallel: false,
487            parallel_limit: None,
488            stop_on_exception: true,
489            aggregation: AggregationStrategy::LastWins,
490        };
491
492        let ex = Exchange::new(Message::new("hello"));
493        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
494
495        match result {
496            PipelineOutcome::Stopped(ex) => {
497                assert_eq!(
498                    ex.input.body.as_text(),
499                    Some("mutated-by-body"),
500                    "Stopped exchange should carry body mutation"
501                );
502            }
503            other => panic!("Expected Stopped, got {other:?}"),
504        }
505    }
506
507    // ── Test 3: Parallel split — Stop cancels pending, waits in-flight ──
508    //
509    // NOTE: With JoinSet::spawn, all tasks are eagerly created. The
510    // pre-start gate only stops fragments whose spawned closure hasn't
511    // been polled yet. This test uses a tokio::sync::Barrier inside the
512    // body to ensure ALL fragments pass the pre-start gate, then verifies
513    // that in-flight (frag-1) completes even though Stop fires. The
514    // "cancels pending" invariant (frag-2 not started) is best-effort;
515    // the true invariant is: fragments that DO start MUST run to completion.
516
517    #[tokio::test(flavor = "multi_thread")]
518    async fn stop_inside_split_parallel_cancels_pending_and_waits_inflight() {
519        use tokio::sync::Barrier;
520
521        let barrier = Arc::new(Barrier::new(3));
522        let fragment1_completed = Arc::new(AtomicBool::new(false));
523        let fragment2_completed = Arc::new(AtomicBool::new(false));
524        let frag1_ok = Arc::clone(&fragment1_completed);
525        let frag2_ok = Arc::clone(&fragment2_completed);
526        let bar = Arc::clone(&barrier);
527
528        // Custom splitter producing 3 fragments.
529        let splitter: SplitExpression = Arc::new(|ex: &Exchange| {
530            Ok((0..3)
531                .map(|i| {
532                    let mut frag = ex.clone();
533                    frag.input.body = Body::Text(format!("frag-{i}"));
534                    frag
535                })
536                .collect())
537        });
538
539        /// Body that uses a barrier to synchronize all fragments past the
540        /// pre-start gate, then dispatches:
541        ///   - frag-0: Stop
542        ///   - frag-1: slow (100ms) Completed
543        ///   - frag-2: fast Completed (asserts it started, proving no abort)
544        struct BarrierDispatchBody {
545            barrier: Arc<Barrier>,
546            f1_completed: Arc<AtomicBool>,
547            f2_completed: Arc<AtomicBool>,
548        }
549        impl Clone for BarrierDispatchBody {
550            fn clone(&self) -> Self {
551                Self {
552                    barrier: Arc::clone(&self.barrier),
553                    f1_completed: Arc::clone(&self.f1_completed),
554                    f2_completed: Arc::clone(&self.f2_completed),
555                }
556            }
557        }
558        impl camel_api::OutcomePipeline for BarrierDispatchBody {
559            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
560                Box::new(self.clone())
561            }
562            fn run<'a>(
563                &'a mut self,
564                exchange: Exchange,
565            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
566                let bar = Arc::clone(&self.barrier);
567                let f1c = Arc::clone(&self.f1_completed);
568                let f2c = Arc::clone(&self.f2_completed);
569                Box::pin(async move {
570                    let body_text = exchange.input.body.as_text().unwrap_or("").to_string();
571
572                    // All fragments synchronize AFTER passing the pre-start gate
573                    // (the gate is checked before body.run()). This ensures all
574                    // three fragments are in-flight when Stop fires.
575                    bar.wait().await;
576
577                    match body_text.as_str() {
578                        "frag-0" => PipelineOutcome::Stopped(exchange),
579                        "frag-1" => {
580                            // Slow in-flight — fragment 0's Stop is recorded and
581                            // propagates, but we still complete (spec §5.6).
582                            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
583                            f1c.store(true, Ordering::SeqCst);
584                            PipelineOutcome::Completed(exchange)
585                        }
586                        "frag-2" => {
587                            f2c.store(true, Ordering::SeqCst);
588                            PipelineOutcome::Completed(exchange)
589                        }
590                        _ => PipelineOutcome::Completed(exchange),
591                    }
592                })
593            }
594        }
595
596        let body = BarrierDispatchBody {
597            barrier: bar,
598            f1_completed: frag1_ok,
599            f2_completed: frag2_ok,
600        };
601
602        let mut seg = SplitSegment {
603            splitter,
604            body: OutcomeSegment::new(Box::new(body)),
605            parallel: true,
606            parallel_limit: None,
607            stop_on_exception: true,
608            aggregation: AggregationStrategy::LastWins,
609        };
610
611        let ex = Exchange::new(Message::new("test"));
612        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
613
614        assert!(
615            matches!(result, PipelineOutcome::Stopped(_)),
616            "Expected Stopped, got {result:?}"
617        );
618        // Fragment 1 was in-flight and completed (no abrupt abort per §5.6).
619        assert!(
620            fragment1_completed.load(Ordering::SeqCst),
621            "fragment 1 should have completed despite Stop"
622        );
623        // Fragment 2 was also in-flight (barrier ensures all start) and completed.
624        assert!(
625            fragment2_completed.load(Ordering::SeqCst),
626            "fragment 2 should have completed despite Stop"
627        );
628    }
629
630    // ── Test 4: Parallel split — lowest stopped index wins ──
631
632    #[tokio::test(flavor = "multi_thread")]
633    async fn stop_inside_split_parallel_lowest_stopped_index_wins() {
634        // Custom splitter producing 3 fragments with index-identifiable body.
635        let splitter: SplitExpression = Arc::new(|ex: &Exchange| {
636            Ok((0..3)
637                .map(|i| {
638                    let mut frag = ex.clone();
639                    frag.input.body = Body::Text(format!("from-fragment-{i}"));
640                    frag
641                })
642                .collect())
643        });
644
645        // Body that stops for fragments 0 and 2; fragment 1 completes.
646        struct DualStopBody;
647        impl Clone for DualStopBody {
648            fn clone(&self) -> Self {
649                DualStopBody
650            }
651        }
652        impl camel_api::OutcomePipeline for DualStopBody {
653            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
654                Box::new(DualStopBody)
655            }
656            fn run<'a>(
657                &'a mut self,
658                exchange: Exchange,
659            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
660                let is_frag0 = exchange
661                    .input
662                    .body
663                    .as_text()
664                    .map(|s| s == "from-fragment-0")
665                    .unwrap_or(false);
666                let is_frag2 = exchange
667                    .input
668                    .body
669                    .as_text()
670                    .map(|s| s == "from-fragment-2")
671                    .unwrap_or(false);
672                Box::pin(async move {
673                    if is_frag0 {
674                        return PipelineOutcome::Stopped(exchange);
675                    }
676                    if is_frag2 {
677                        // Delay slightly to ensure fragment 0's Stop is recorded first
678                        // in the CAS, then verify that lowest index wins.
679                        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
680                        return PipelineOutcome::Stopped(exchange);
681                    }
682                    // frag-1: completed
683                    PipelineOutcome::Completed(exchange)
684                })
685            }
686        }
687
688        let mut seg = SplitSegment {
689            splitter,
690            body: OutcomeSegment::new(Box::new(DualStopBody)),
691            parallel: true,
692            parallel_limit: None,
693            stop_on_exception: true,
694            aggregation: AggregationStrategy::LastWins,
695        };
696
697        let ex = Exchange::new(Message::new("test"));
698        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
699
700        match result {
701            PipelineOutcome::Stopped(ex) => {
702                assert_eq!(
703                    ex.input.body.as_text(),
704                    Some("from-fragment-0"),
705                    "Lowest stopped index (0) should win, got body {:?}",
706                    ex.input.body.as_text()
707                );
708            }
709            other => panic!("Expected Stopped with fragment-0 body, got {other:?}"),
710        }
711    }
712
713    // ── Test 5: parallel_limit enforcement ─────────────────────────────
714
715    #[tokio::test(flavor = "multi_thread")]
716    async fn split_parallel_limit_enforces_concurrency_cap() {
717        let concurrent = Arc::new(AtomicUsize::new(0));
718        let max_concurrent = Arc::new(AtomicUsize::new(0));
719
720        // Split into 6 fragments. parallel_limit=2.
721        let splitter: SplitExpression = Arc::new(|ex: &Exchange| {
722            Ok((0..6)
723                .map(|i| {
724                    let mut frag = ex.clone();
725                    frag.input.body = Body::Text(format!("frag-{i}"));
726                    frag
727                })
728                .collect())
729        });
730
731        let c = Arc::clone(&concurrent);
732        let mc = Arc::clone(&max_concurrent);
733        struct LimitedBody {
734            concurrent: Arc<AtomicUsize>,
735            max_concurrent: Arc<AtomicUsize>,
736        }
737        impl Clone for LimitedBody {
738            fn clone(&self) -> Self {
739                Self {
740                    concurrent: Arc::clone(&self.concurrent),
741                    max_concurrent: Arc::clone(&self.max_concurrent),
742                }
743            }
744        }
745        impl camel_api::OutcomePipeline for LimitedBody {
746            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
747                Box::new(self.clone())
748            }
749            fn run<'a>(
750                &'a mut self,
751                exchange: Exchange,
752            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
753                let c = Arc::clone(&self.concurrent);
754                let mc = Arc::clone(&self.max_concurrent);
755                Box::pin(async move {
756                    let current = c.fetch_add(1, Ordering::SeqCst) + 1;
757                    mc.fetch_max(current, Ordering::SeqCst);
758                    tokio::task::yield_now().await;
759                    c.fetch_sub(1, Ordering::SeqCst);
760                    PipelineOutcome::Completed(exchange)
761                })
762            }
763        }
764
765        let mut seg = SplitSegment {
766            splitter,
767            body: OutcomeSegment::new(Box::new(LimitedBody {
768                concurrent: c,
769                max_concurrent: mc,
770            })),
771            parallel: true,
772            parallel_limit: Some(2),
773            stop_on_exception: true,
774            aggregation: AggregationStrategy::LastWins,
775        };
776
777        let ex = Exchange::new(Message::new("test"));
778        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
779        assert!(
780            matches!(result, PipelineOutcome::Completed(_)),
781            "Expected Completed, got {result:?}"
782        );
783
784        let observed_max = max_concurrent.load(Ordering::SeqCst);
785        assert!(
786            observed_max <= 2,
787            "parallel_limit=2 but max concurrency was {observed_max}"
788        );
789    }
790
791    // ── Test 6: stop_on_exception=true (sequential) ────────────────────
792
793    #[tokio::test]
794    async fn split_sequential_stop_on_exception_true() {
795        // 5 fragments, fail on 2nd (index 1). stop_on_exception=true → stops.
796        fn make_fail_body(
797            fail_at: usize,
798            counter: Arc<AtomicUsize>,
799        ) -> impl camel_api::OutcomePipeline + Clone {
800            #[derive(Clone)]
801            struct FailAtBody {
802                fail_at: usize,
803                counter: Arc<AtomicUsize>,
804            }
805            impl camel_api::OutcomePipeline for FailAtBody {
806                fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
807                    Box::new(self.clone())
808                }
809                fn run<'a>(
810                    &'a mut self,
811                    exchange: Exchange,
812                ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
813                    let count = self.counter.fetch_add(1, Ordering::SeqCst);
814                    let fail_at = self.fail_at;
815                    Box::pin(async move {
816                        if count == fail_at {
817                            PipelineOutcome::Failed(CamelError::ProcessorError(format!(
818                                "fail at {count}"
819                            )))
820                        } else {
821                            PipelineOutcome::Completed(exchange)
822                        }
823                    })
824                }
825            }
826            FailAtBody { fail_at, counter }
827        }
828
829        let invocations = Arc::new(AtomicUsize::new(0));
830        let body = make_fail_body(1, Arc::clone(&invocations));
831        let mut seg = SplitSegment {
832            splitter: camel_api::split_body_lines(),
833            body: OutcomeSegment::new(Box::new(body)),
834            parallel: false,
835            parallel_limit: None,
836            stop_on_exception: true,
837            aggregation: AggregationStrategy::LastWins,
838        };
839
840        let ex = Exchange::new(Message::new("a\nb\nc\nd\ne"));
841        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
842
843        assert!(
844            matches!(result, PipelineOutcome::Failed(_)),
845            "stop_on_exception=true should propagate first failure"
846        );
847        // Only 2 fragments processed (index 0 passed, index 1 failed);
848        // fragments 2-4 never run.
849        assert_eq!(
850            invocations.load(Ordering::SeqCst),
851            2,
852            "should stop after 2 fragments (0 pass, 1 fail)"
853        );
854    }
855
856    // ── Test 7: stop_on_exception=false (sequential) ───────────────────
857
858    #[tokio::test]
859    async fn split_sequential_stop_on_exception_false() {
860        // 5 fragments, fail on 2nd (index 1). stop_on_exception=false → continues.
861        fn make_fail_body(
862            fail_at: usize,
863            counter: Arc<AtomicUsize>,
864        ) -> impl camel_api::OutcomePipeline + Clone {
865            #[derive(Clone)]
866            struct FailAtBody {
867                fail_at: usize,
868                counter: Arc<AtomicUsize>,
869            }
870            impl camel_api::OutcomePipeline for FailAtBody {
871                fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
872                    Box::new(self.clone())
873                }
874                fn run<'a>(
875                    &'a mut self,
876                    exchange: Exchange,
877                ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
878                    let count = self.counter.fetch_add(1, Ordering::SeqCst);
879                    let fail_at = self.fail_at;
880                    Box::pin(async move {
881                        if count == fail_at {
882                            PipelineOutcome::Failed(CamelError::ProcessorError(format!(
883                                "fail at {count}"
884                            )))
885                        } else {
886                            PipelineOutcome::Completed(exchange)
887                        }
888                    })
889                }
890            }
891            FailAtBody { fail_at, counter }
892        }
893
894        let invocations = Arc::new(AtomicUsize::new(0));
895        let body = make_fail_body(1, Arc::clone(&invocations));
896        let mut seg = SplitSegment {
897            splitter: camel_api::split_body_lines(),
898            body: OutcomeSegment::new(Box::new(body)),
899            parallel: false,
900            parallel_limit: None,
901            stop_on_exception: false,
902            aggregation: AggregationStrategy::LastWins,
903        };
904
905        let ex = Exchange::new(Message::new("a\nb\nc\nd\ne"));
906        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
907
908        // With stop_on_exception=false, processing continues after failure;
909        // last error is propagated.
910        assert!(
911            matches!(result, PipelineOutcome::Failed(_)),
912            "stop_on_exception=false should still propagate error at end"
913        );
914        // All 5 fragments processed.
915        assert_eq!(
916            invocations.load(Ordering::SeqCst),
917            5,
918            "all fragments should be processed when stop_on_exception=false"
919        );
920    }
921
922    // ── Test 8: stop_on_exception=true (parallel) ──────────────────────
923
924    #[tokio::test(flavor = "multi_thread")]
925    async fn split_parallel_stop_on_exception_true() {
926        let splitter: SplitExpression = Arc::new(|ex: &Exchange| {
927            Ok((0..5)
928                .map(|i| {
929                    let mut frag = ex.clone();
930                    frag.input.body = Body::Text(format!("frag-{i}"));
931                    frag
932                })
933                .collect())
934        });
935
936        // All fragments fail. stop_on_exception=true → first Failed propagated.
937        let invocations = Arc::new(AtomicUsize::new(0));
938        struct FailBody {
939            counter: Arc<AtomicUsize>,
940        }
941        impl Clone for FailBody {
942            fn clone(&self) -> Self {
943                Self {
944                    counter: Arc::clone(&self.counter),
945                }
946            }
947        }
948        impl camel_api::OutcomePipeline for FailBody {
949            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
950                Box::new(self.clone())
951            }
952            fn run<'a>(
953                &'a mut self,
954                _exchange: Exchange,
955            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
956                let count = self.counter.fetch_add(1, Ordering::SeqCst);
957                Box::pin(async move {
958                    PipelineOutcome::Failed(CamelError::ProcessorError(format!("fail {count}")))
959                })
960            }
961        }
962
963        let mut seg = SplitSegment {
964            splitter,
965            body: OutcomeSegment::new(Box::new(FailBody {
966                counter: Arc::clone(&invocations),
967            })),
968            parallel: true,
969            parallel_limit: None,
970            stop_on_exception: true,
971            aggregation: AggregationStrategy::LastWins,
972        };
973
974        let ex = Exchange::new(Message::new("test"));
975        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
976
977        assert!(
978            matches!(result, PipelineOutcome::Failed(_)),
979            "stop_on_exception=true should propagate first failure"
980        );
981        // All 5 spawned (JoinSet), all completed.
982        assert_eq!(
983            invocations.load(Ordering::SeqCst),
984            5,
985            "all fragments should be spawned"
986        );
987    }
988
989    // ── Test 9: stop_on_exception=false (parallel) ─────────────────────
990
991    #[tokio::test(flavor = "multi_thread")]
992    async fn split_parallel_stop_on_exception_false() {
993        let splitter: SplitExpression = Arc::new(|ex: &Exchange| {
994            Ok((0..5)
995                .map(|i| {
996                    let mut frag = ex.clone();
997                    frag.input.body = Body::Text(format!("frag-{i}"));
998                    frag
999                })
1000                .collect())
1001        });
1002
1003        // Fragment 0 passes, 1 fails, 2-4 pass.
1004        let invocations = Arc::new(AtomicUsize::new(0));
1005        struct MixedBody {
1006            counter: Arc<AtomicUsize>,
1007        }
1008        impl Clone for MixedBody {
1009            fn clone(&self) -> Self {
1010                Self {
1011                    counter: Arc::clone(&self.counter),
1012                }
1013            }
1014        }
1015        impl camel_api::OutcomePipeline for MixedBody {
1016            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
1017                Box::new(self.clone())
1018            }
1019            fn run<'a>(
1020                &'a mut self,
1021                exchange: Exchange,
1022            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1023                let count = self.counter.fetch_add(1, Ordering::SeqCst);
1024                Box::pin(async move {
1025                    if count == 1 {
1026                        PipelineOutcome::Failed(CamelError::ProcessorError("fail 1".into()))
1027                    } else {
1028                        PipelineOutcome::Completed(exchange)
1029                    }
1030                })
1031            }
1032        }
1033
1034        let mut seg = SplitSegment {
1035            splitter,
1036            body: OutcomeSegment::new(Box::new(MixedBody {
1037                counter: Arc::clone(&invocations),
1038            })),
1039            parallel: true,
1040            parallel_limit: None,
1041            stop_on_exception: false,
1042            aggregation: AggregationStrategy::LastWins,
1043        };
1044
1045        let ex = Exchange::new(Message::new("test"));
1046        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1047
1048        // stop_on_exception=false → last error propagated at end.
1049        assert!(
1050            matches!(result, PipelineOutcome::Failed(_)),
1051            "stop_on_exception=false should propagate failure at end; got {result:?}"
1052        );
1053        assert_eq!(
1054            invocations.load(Ordering::SeqCst),
1055            5,
1056            "all fragments should be spawned"
1057        );
1058    }
1059
1060    // ── Test 10: Split expression error → Failed, body never runs ──────
1061
1062    #[tokio::test]
1063    async fn test_split_segment_expression_error_is_failed() {
1064        /// Recording body: counts invocations, always completes.
1065        #[derive(Clone)]
1066        struct RecordingBody {
1067            counter: Arc<AtomicUsize>,
1068        }
1069        impl camel_api::OutcomePipeline for RecordingBody {
1070            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
1071                Box::new(self.clone())
1072            }
1073            fn run<'a>(
1074                &'a mut self,
1075                exchange: Exchange,
1076            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1077                self.counter.fetch_add(1, Ordering::SeqCst);
1078                Box::pin(async move { PipelineOutcome::Completed(exchange) })
1079            }
1080        }
1081
1082        let invocations = Arc::new(AtomicUsize::new(0));
1083        let splitter: SplitExpression = Arc::new(|_| {
1084            Err(CamelError::TypeConversionFailed(
1085                "declarative split requires a text or array value, got number; add an unmarshal step before split"
1086                    .to_string(),
1087            ))
1088        });
1089
1090        let mut seg = SplitSegment {
1091            splitter,
1092            body: OutcomeSegment::new(Box::new(RecordingBody {
1093                counter: Arc::clone(&invocations),
1094            })),
1095            parallel: false,
1096            parallel_limit: None,
1097            stop_on_exception: true,
1098            aggregation: AggregationStrategy::LastWins,
1099        };
1100
1101        let ex = Exchange::new(Message::new("anything"));
1102        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1103
1104        match result {
1105            PipelineOutcome::Failed(err) => {
1106                let msg = err.to_string();
1107                assert!(
1108                    msg.contains("declarative split"),
1109                    "carried error should mention 'declarative split': {msg}"
1110                );
1111            }
1112            other => panic!("Expected Failed, got {other:?}"),
1113        }
1114        assert_eq!(
1115            invocations.load(Ordering::SeqCst),
1116            0,
1117            "body segment must record zero invocations when the split expression errors"
1118        );
1119    }
1120}