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