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