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, SplitSource,
13    Value,
14};
15
16use crate::splitter::{CAMEL_SPLIT_COMPLETE, CAMEL_SPLIT_INDEX, CAMEL_SPLIT_SIZE};
17
18// ── aggregate_completed (SplitSegment helper) ─────────────────────────
19
20/// Aggregate completed fragment outputs into a single Exchange.
21///
22/// Unlike `aggregate` (which works on `Vec<Result<Exchange, CamelError>>`),
23/// this operates on `Vec<Exchange>` where all entries are `Completed` outcomes.
24pub(crate) fn aggregate_completed(
25    completed: Vec<Exchange>,
26    original: Exchange,
27    strategy: AggregationStrategy,
28) -> Exchange {
29    match strategy {
30        AggregationStrategy::LastWins => completed.into_iter().last().unwrap_or(original),
31        AggregationStrategy::CollectAll => {
32            let mut bodies = Vec::new();
33            for ex in &completed {
34                let value = match &ex.input.body {
35                    Body::Text(s) => Value::String(s.clone()),
36                    Body::Json(v) => v.clone(),
37                    Body::Xml(s) => Value::String(s.clone()),
38                    Body::Bytes(b) => Value::String(String::from_utf8_lossy(b).into_owned()),
39                    Body::Stream(s) => serde_json::json!({
40                        "_stream": {
41                            "origin": s.metadata.origin,
42                            "placeholder": true,
43                            "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
44                        }
45                    }),
46                    // Empty and future variants contribute no extractable value.
47                    _ => Value::Null,
48                };
49                bodies.push(value);
50            }
51            let mut out = original;
52            out.input.body = Body::Json(Value::Array(bodies));
53            out
54        }
55        AggregationStrategy::Custom(fold_fn) => {
56            let mut iter = completed.into_iter();
57            let first = iter.next().unwrap_or(original);
58            iter.fold(first, |acc, next| fold_fn(acc, next))
59        }
60        // Original and any future variant return the original exchange.
61        _ => original,
62    }
63}
64
65// ── SplitSegment (ADR-0025 OutcomePipeline) ────────────────────────────
66
67/// Outcome-aware structural EIP segment for the Split pattern.
68///
69/// Splits an incoming exchange into fragments, processes each fragment through
70/// `body`, and aggregates the results. Supports sequential and parallel modes.
71///
72/// In sequential mode, fragments are processed in order. A `Stopped` or `Failed`
73/// outcome from any fragment halts processing immediately and propagates.
74///
75/// In parallel mode, all fragments are spawned as tokio tasks. The first
76/// `Stopped` outcome (lowest fragment index wins via CAS) propagates as the
77/// outer `Stopped`. In-flight tasks run to completion (spec §5.6: no abrupt
78/// abort — child sub-pipelines may have HTTP/SQL side effects). Tasks that
79/// have not started yet are short-circuited via a pre-start gate.
80///
81/// Unlike the removed Tower split wrapper (which could not preserve
82/// `Stopped(ex)` with mutations), `SplitSegment` operates at the
83/// `PipelineOutcome` layer and preserves the exchange including all mutations
84/// at the Stop point.
85pub struct SplitSegment {
86    /// Splits an exchange into fragment exchanges.
87    pub splitter: SplitSource,
88    /// The sub-pipeline executed for each fragment.
89    pub body: OutcomeSegment,
90    /// Whether to process fragments in parallel.
91    pub parallel: bool,
92    /// Maximum number of concurrent fragments in parallel mode (None = unlimited).
93    pub parallel_limit: Option<usize>,
94    /// Whether to stop processing on the first exception.
95    ///
96    /// When `true`, a `Failed` outcome from any fragment halts processing
97    /// immediately. When `false`, the error is collected and processing
98    /// continues; the last-seen error is propagated (last-wins, matching legacy multicast.rs::process_parallel)
99    /// is propagated after all fragments complete.
100    ///
101    /// `Stopped` outcomes always propagate immediately regardless of this
102    /// flag (per ADR-0025 §7 — Stop is successful control flow).
103    pub stop_on_exception: bool,
104    /// Maximum number of fragments the eager split may materialize.
105    ///
106    /// Defaults to `camel_api::splitter::DEFAULT_MAX_FRAGMENTS`. A split whose
107    /// materialized `len` exceeds this cap is rejected before metadata stamping
108    /// and before any fragment is processed; `0` fails closed for any
109    /// non-empty split.
110    pub max_fragments: usize,
111    /// Strategy for aggregating fragment results.
112    pub aggregation: AggregationStrategy,
113}
114
115impl Clone for SplitSegment {
116    fn clone(&self) -> Self {
117        Self {
118            splitter: self.splitter.clone(),
119            body: self.body.clone(),
120            parallel: self.parallel,
121            parallel_limit: self.parallel_limit,
122            stop_on_exception: self.stop_on_exception,
123            max_fragments: self.max_fragments,
124            aggregation: self.aggregation.clone(),
125        }
126    }
127}
128
129impl camel_api::OutcomePipeline for SplitSegment {
130    fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
131        Box::new(self.clone())
132    }
133
134    fn run<'a>(
135        &'a mut self,
136        exchange: camel_api::Exchange,
137    ) -> Pin<Box<dyn Future<Output = camel_api::PipelineOutcome> + Send + 'a>> {
138        let splitter = self.splitter.clone();
139        let aggregation = self.aggregation.clone();
140        let parallel = self.parallel;
141        let parallel_limit = self.parallel_limit;
142        let stop_on_exception = self.stop_on_exception;
143        let max_fragments = self.max_fragments;
144        let body = &mut self.body;
145
146        Box::pin(async move {
147            let original = exchange;
148            // A typed error from the split expression fails loud as
149            // `Failed`, carrying the original error untouched.
150            let mut fragments = match splitter.split(&original).await {
151                Ok(fragments) => fragments,
152                Err(err) => return PipelineOutcome::Failed(err),
153            };
154
155            if fragments.is_empty() {
156                return PipelineOutcome::Completed(original);
157            }
158
159            if fragments.len() > max_fragments {
160                return PipelineOutcome::Failed(CamelError::ProcessorError(format!(
161                    "Splitter produced {} fragments, exceeding max_fragments {}",
162                    fragments.len(),
163                    max_fragments
164                )));
165            }
166
167            // Stamp split metadata on each fragment — exact parity with the
168            // previous eager split path (removed with `SplitterService`), so
169            // downstream consumers observe the same `CamelSplit*` properties
170            // on either split path.
171            let total = fragments.len();
172            for (i, frag) in fragments.iter_mut().enumerate() {
173                frag.set_property(CAMEL_SPLIT_INDEX, Value::from(i as u64));
174                frag.set_property(CAMEL_SPLIT_SIZE, Value::from(total as u64));
175                frag.set_property(CAMEL_SPLIT_COMPLETE, Value::Bool(i == total - 1));
176            }
177
178            if parallel {
179                parallel_split(
180                    fragments,
181                    original,
182                    body,
183                    &aggregation,
184                    parallel_limit,
185                    stop_on_exception,
186                )
187                .await
188            } else {
189                sequential_split(fragments, original, body, &aggregation, stop_on_exception).await
190            }
191        })
192    }
193}
194
195// ── Sequential split ────────────────────────────────────────────────────
196
197async fn sequential_split(
198    fragments: Vec<Exchange>,
199    original: Exchange,
200    body: &mut OutcomeSegment,
201    aggregation: &AggregationStrategy,
202    stop_on_exception: bool,
203) -> PipelineOutcome {
204    let mut outputs = Vec::new();
205    let mut last_error: Option<CamelError> = None;
206    for frag in fragments {
207        match body.run(frag).await {
208            PipelineOutcome::Completed(ex) => outputs.push(ex),
209            PipelineOutcome::Stopped(ex) => return PipelineOutcome::Stopped(ex),
210            PipelineOutcome::Failed(err) => {
211                if stop_on_exception {
212                    return PipelineOutcome::Failed(err);
213                }
214                // stop_on_exception=false: collect the error and continue.
215                last_error = Some(err);
216            }
217        }
218    }
219    if let Some(err) = last_error {
220        return PipelineOutcome::Failed(err);
221    }
222    PipelineOutcome::Completed(aggregate_completed(outputs, original, aggregation.clone()))
223}
224
225// ── Parallel split ──────────────────────────────────────────────────────
226
227/// Parallel split with lowest-index-wins CAS semantics.
228///
229/// See spec §5.2.2 line 497 for the CAS guarantee and §5.6 line 544 for the
230/// "no abrupt abort" in-flight task policy (pre-start gate + run-to-completion).
231async fn parallel_split(
232    fragments: Vec<Exchange>,
233    original: Exchange,
234    body: &mut OutcomeSegment,
235    aggregation: &AggregationStrategy,
236    parallel_limit: Option<usize>,
237    stop_on_exception: bool,
238) -> PipelineOutcome {
239    use tokio::sync::Semaphore;
240
241    let stopped_seen = Arc::new(AtomicBool::new(false));
242    let stopped_idx = Arc::new(AtomicUsize::new(usize::MAX));
243    let aggregation = aggregation.clone();
244    let semaphore = parallel_limit
245        .filter(|&limit| limit > 0)
246        .map(|limit| Arc::new(Semaphore::new(limit)));
247
248    let mut set: JoinSet<(usize, Option<PipelineOutcome>)> = JoinSet::new();
249
250    for (idx, frag) in fragments.into_iter().enumerate() {
251        let mut body = body.clone();
252        let stopped_seen = Arc::clone(&stopped_seen);
253        let stopped_idx = Arc::clone(&stopped_idx);
254        let sem = semaphore.clone();
255        set.spawn(async move {
256            // Pre-start gate: a lower-index branch already stopped.
257            // This is the ONLY cancellation check — once a body starts
258            // running, it runs to completion (spec §5.6: "in-flight futures
259            // MUST NOT be abruptly aborted").
260            if stopped_seen.load(Ordering::SeqCst) {
261                return (idx, None);
262            }
263            // Acquire semaphore permit if parallel_limit is set.
264            let _permit: Option<tokio::sync::OwnedSemaphorePermit> = match &sem {
265                Some(s) => match std::sync::Arc::clone(s).acquire_owned().await {
266                    Ok(p) => Some(p),
267                    Err(_) => {
268                        return (
269                            idx,
270                            Some(PipelineOutcome::Failed(CamelError::ProcessorError(
271                                "semaphore closed".into(),
272                            ))),
273                        );
274                    }
275                },
276                None => None,
277            };
278            // Re-check pre-start gate after permit acquisition
279            // (another branch may have stopped while we were waiting).
280            if stopped_seen.load(Ordering::SeqCst) {
281                return (idx, None);
282            }
283            let outcome = body.run(frag).await;
284            if let PipelineOutcome::Stopped(_) = &outcome {
285                // Lower-the-value CAS: ensures lowest-branch-index wins
286                // even under simultaneous Stop (spec §5.2.2 line 497).
287                // Loop until our idx is recorded or a lower idx has already
288                // claimed it.
289                loop {
290                    let cur = stopped_idx.load(Ordering::SeqCst);
291                    if idx >= cur {
292                        break; // a lower index already won
293                    }
294                    match stopped_idx.compare_exchange_weak(
295                        cur,
296                        idx,
297                        Ordering::SeqCst,
298                        Ordering::SeqCst,
299                    ) {
300                        Ok(_) => break,
301                        Err(actual) => {
302                            // CAS failed — `actual` is the new cur; loop and
303                            // retry with updated value.
304                            if actual <= idx {
305                                break;
306                            }
307                        }
308                    }
309                }
310                stopped_seen.store(true, Ordering::SeqCst);
311            }
312            (idx, Some(outcome))
313        });
314    }
315
316    // Wait for ALL in-flight branches to finish (spec §5.6: no abrupt
317    // abort). Post-stop outputs are discarded at aggregation, but the
318    // branches DO complete.
319    let mut results: Vec<(usize, PipelineOutcome)> = Vec::new();
320    while let Some(res) = set.join_next().await {
321        if let Ok((idx, Some(o))) = res {
322            results.push((idx, o));
323        }
324    }
325
326    // Deterministic lowest-branch-index wins (spec §5.2.2 line 497).
327    if stopped_seen.load(Ordering::SeqCst) {
328        let winning_idx = stopped_idx.load(Ordering::SeqCst);
329        if winning_idx == usize::MAX {
330            tracing::warn!(
331                target: "camel.phase4.split",
332                "stopped_seen=true but stopped_idx=usize::MAX — race condition; falling back to pre-split exchange"
333            );
334            return PipelineOutcome::Stopped(original);
335        }
336        let stopped_ex = results
337            .iter()
338            .find(|(idx, _)| *idx == winning_idx)
339            .and_then(|(_, o)| match o {
340                PipelineOutcome::Stopped(ex) => Some(ex.branch_clone()),
341                _ => None,
342            });
343        if let Some(ex) = stopped_ex {
344            return PipelineOutcome::Stopped(ex);
345        }
346        tracing::warn!(
347            target: "camel.phase4.split",
348            winning_idx = winning_idx,
349            "winning_idx not found in results — falling back to pre-split exchange"
350        );
351        return PipelineOutcome::Stopped(original);
352    }
353
354    // No Stop — check for Failed.
355    // stop_on_exception=true: propagate first Failed (lowest branch index).
356    // stop_on_exception=false: collect last error (last-wins) and propagate at end.
357    results.sort_by_key(|(idx, _)| *idx);
358    if stop_on_exception {
359        let mut first_failed: Option<(usize, CamelError)> = None;
360        for (idx, o) in &results {
361            if let PipelineOutcome::Failed(err) = o
362                && first_failed
363                    .as_ref()
364                    .map(|(i, _)| *i > *idx)
365                    .unwrap_or(true)
366            {
367                first_failed = Some((*idx, err.clone()));
368            }
369        }
370        if let Some((_, err)) = first_failed {
371            return PipelineOutcome::Failed(err);
372        }
373    } else {
374        // Collect last error (last-wins, matching MulticastSegment semantics).
375        let mut last_error: Option<CamelError> = None;
376        for (_, o) in &results {
377            if let PipelineOutcome::Failed(err) = o {
378                last_error = Some(err.clone());
379            }
380        }
381        if let Some(err) = last_error {
382            return PipelineOutcome::Failed(err);
383        }
384    }
385
386    // All Completed — aggregate.
387    let completed: Vec<Exchange> = results
388        .into_iter()
389        .filter_map(|(_, o)| match o {
390            PipelineOutcome::Completed(ex) => Some(ex),
391            _ => None,
392        })
393        .collect();
394    PipelineOutcome::Completed(aggregate_completed(completed, original, aggregation))
395}
396
397// ── Tests ──────────────────────────────────────────────────────────────
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use camel_api::Message;
403
404    // ── Test helpers ───────────────────────────────────────────────────
405
406    /// Helper: OutcomePipeline body that always returns `Completed(exchange)`.
407    #[derive(Clone)]
408    #[allow(dead_code)]
409    struct CompletedBody;
410    impl camel_api::OutcomePipeline for CompletedBody {
411        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
412            Box::new(CompletedBody)
413        }
414        fn run<'a>(
415            &'a mut self,
416            exchange: Exchange,
417        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
418            Box::pin(async move { PipelineOutcome::Completed(exchange) })
419        }
420    }
421
422    /// Helper: OutcomePipeline body that always returns `Stopped(exchange)`.
423    #[derive(Clone)]
424    #[allow(dead_code)]
425    struct StopBody;
426    impl camel_api::OutcomePipeline for StopBody {
427        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
428            Box::new(StopBody)
429        }
430        fn run<'a>(
431            &'a mut self,
432            exchange: Exchange,
433        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
434            Box::pin(async move { PipelineOutcome::Stopped(exchange) })
435        }
436    }
437
438    /// Helper: OutcomePipeline body that stops on the nth invocation (0-indexed).
439    #[derive(Clone)]
440    struct StopOnNthBody {
441        counter: Arc<AtomicUsize>,
442        stop_at: usize,
443    }
444    impl camel_api::OutcomePipeline for StopOnNthBody {
445        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
446            Box::new(self.clone())
447        }
448        fn run<'a>(
449            &'a mut self,
450            exchange: Exchange,
451        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
452            let count = self.counter.fetch_add(1, Ordering::SeqCst);
453            let stop_at = self.stop_at;
454            Box::pin(async move {
455                if count >= stop_at {
456                    PipelineOutcome::Stopped(exchange)
457                } else {
458                    PipelineOutcome::Completed(exchange)
459                }
460            })
461        }
462    }
463
464    /// Helper: OutcomePipeline body that mutates the exchange body then stops.
465    #[derive(Clone)]
466    struct MutateAndStopBody;
467    impl camel_api::OutcomePipeline for MutateAndStopBody {
468        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
469            Box::new(MutateAndStopBody)
470        }
471        fn run<'a>(
472            &'a mut self,
473            mut exchange: Exchange,
474        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
475            Box::pin(async move {
476                exchange.input.body = Body::Text("mutated-by-body".to_string());
477                PipelineOutcome::Stopped(exchange)
478            })
479        }
480    }
481
482    // ── Test 1: Sequential split — Stop halts remaining fragments ──
483
484    #[tokio::test]
485    async fn stop_inside_split_sequential_halts_remaining_fragments() {
486        let invocations = Arc::new(AtomicUsize::new(0));
487        let body = StopOnNthBody {
488            counter: Arc::clone(&invocations),
489            stop_at: 1, // stop on the 2nd fragment (index 1)
490        };
491
492        let mut seg = SplitSegment {
493            splitter: camel_api::SplitSource::Sync(camel_api::split_body_lines()),
494            body: OutcomeSegment::new(Box::new(body)),
495            parallel: false,
496            parallel_limit: None,
497            stop_on_exception: true,
498            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
499            aggregation: AggregationStrategy::LastWins,
500        };
501
502        let ex = Exchange::new(Message::new("a\nb\nc"));
503        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
504
505        assert!(matches!(result, PipelineOutcome::Stopped(_)));
506        // Fragments 0 (pass) + 1 (stop) = 2 invocations; fragment 2 never runs.
507        assert_eq!(invocations.load(Ordering::SeqCst), 2);
508    }
509
510    // ── Test 2: Sequential split — Stop preserves exchange mutations ──
511
512    #[tokio::test]
513    async fn stop_inside_split_sequential_preserves_exchange_mutations() {
514        let mut seg = SplitSegment {
515            splitter: camel_api::SplitSource::Sync(camel_api::split_body_lines()),
516            body: OutcomeSegment::new(Box::new(MutateAndStopBody)),
517            parallel: false,
518            parallel_limit: None,
519            stop_on_exception: true,
520            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
521            aggregation: AggregationStrategy::LastWins,
522        };
523
524        let ex = Exchange::new(Message::new("hello"));
525        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
526
527        match result {
528            PipelineOutcome::Stopped(ex) => {
529                assert_eq!(
530                    ex.input.body.as_text(),
531                    Some("mutated-by-body"),
532                    "Stopped exchange should carry body mutation"
533                );
534            }
535            other => panic!("Expected Stopped, got {other:?}"),
536        }
537    }
538
539    // ── Test 3: Parallel split — Stop cancels pending, waits in-flight ──
540    //
541    // NOTE: With JoinSet::spawn, all tasks are eagerly created. The
542    // pre-start gate only stops fragments whose spawned closure hasn't
543    // been polled yet. This test uses a tokio::sync::Barrier inside the
544    // body to ensure ALL fragments pass the pre-start gate, then verifies
545    // that in-flight (frag-1) completes even though Stop fires. The
546    // "cancels pending" invariant (frag-2 not started) is best-effort;
547    // the true invariant is: fragments that DO start MUST run to completion.
548
549    #[tokio::test(flavor = "multi_thread")]
550    async fn stop_inside_split_parallel_cancels_pending_and_waits_inflight() {
551        use tokio::sync::Barrier;
552
553        let barrier = Arc::new(Barrier::new(3));
554        let fragment1_completed = Arc::new(AtomicBool::new(false));
555        let fragment2_completed = Arc::new(AtomicBool::new(false));
556        let frag1_ok = Arc::clone(&fragment1_completed);
557        let frag2_ok = Arc::clone(&fragment2_completed);
558        let bar = Arc::clone(&barrier);
559
560        // Custom splitter producing 3 fragments.
561        let splitter = SplitSource::Sync(Arc::new(|ex: &Exchange| {
562            Ok((0..3)
563                .map(|i| {
564                    let mut frag = ex.clone();
565                    frag.input.body = Body::Text(format!("frag-{i}"));
566                    frag
567                })
568                .collect())
569        }));
570
571        /// Body that uses a barrier to synchronize all fragments past the
572        /// pre-start gate, then dispatches:
573        ///   - frag-0: Stop
574        ///   - frag-1: slow (100ms) Completed
575        ///   - frag-2: fast Completed (asserts it started, proving no abort)
576        struct BarrierDispatchBody {
577            barrier: Arc<Barrier>,
578            f1_completed: Arc<AtomicBool>,
579            f2_completed: Arc<AtomicBool>,
580        }
581        impl Clone for BarrierDispatchBody {
582            fn clone(&self) -> Self {
583                Self {
584                    barrier: Arc::clone(&self.barrier),
585                    f1_completed: Arc::clone(&self.f1_completed),
586                    f2_completed: Arc::clone(&self.f2_completed),
587                }
588            }
589        }
590        impl camel_api::OutcomePipeline for BarrierDispatchBody {
591            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
592                Box::new(self.clone())
593            }
594            fn run<'a>(
595                &'a mut self,
596                exchange: Exchange,
597            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
598                let bar = Arc::clone(&self.barrier);
599                let f1c = Arc::clone(&self.f1_completed);
600                let f2c = Arc::clone(&self.f2_completed);
601                Box::pin(async move {
602                    let body_text = exchange.input.body.as_text().unwrap_or("").to_string();
603
604                    // All fragments synchronize AFTER passing the pre-start gate
605                    // (the gate is checked before body.run()). This ensures all
606                    // three fragments are in-flight when Stop fires.
607                    bar.wait().await;
608
609                    match body_text.as_str() {
610                        "frag-0" => PipelineOutcome::Stopped(exchange),
611                        "frag-1" => {
612                            // Slow in-flight — fragment 0's Stop is recorded and
613                            // propagates, but we still complete (spec §5.6).
614                            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
615                            f1c.store(true, Ordering::SeqCst);
616                            PipelineOutcome::Completed(exchange)
617                        }
618                        "frag-2" => {
619                            f2c.store(true, Ordering::SeqCst);
620                            PipelineOutcome::Completed(exchange)
621                        }
622                        _ => PipelineOutcome::Completed(exchange),
623                    }
624                })
625            }
626        }
627
628        let body = BarrierDispatchBody {
629            barrier: bar,
630            f1_completed: frag1_ok,
631            f2_completed: frag2_ok,
632        };
633
634        let mut seg = SplitSegment {
635            splitter,
636            body: OutcomeSegment::new(Box::new(body)),
637            parallel: true,
638            parallel_limit: None,
639            stop_on_exception: true,
640            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
641            aggregation: AggregationStrategy::LastWins,
642        };
643
644        let ex = Exchange::new(Message::new("test"));
645        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
646
647        assert!(
648            matches!(result, PipelineOutcome::Stopped(_)),
649            "Expected Stopped, got {result:?}"
650        );
651        // Fragment 1 was in-flight and completed (no abrupt abort per §5.6).
652        assert!(
653            fragment1_completed.load(Ordering::SeqCst),
654            "fragment 1 should have completed despite Stop"
655        );
656        // Fragment 2 was also in-flight (barrier ensures all start) and completed.
657        assert!(
658            fragment2_completed.load(Ordering::SeqCst),
659            "fragment 2 should have completed despite Stop"
660        );
661    }
662
663    // ── Test 4: Parallel split — lowest stopped index wins ──
664
665    #[tokio::test(flavor = "multi_thread")]
666    async fn stop_inside_split_parallel_lowest_stopped_index_wins() {
667        // Custom splitter producing 3 fragments with index-identifiable body.
668        let splitter = SplitSource::Sync(Arc::new(|ex: &Exchange| {
669            Ok((0..3)
670                .map(|i| {
671                    let mut frag = ex.clone();
672                    frag.input.body = Body::Text(format!("from-fragment-{i}"));
673                    frag
674                })
675                .collect())
676        }));
677
678        // Body that stops for fragments 0 and 2; fragment 1 completes.
679        struct DualStopBody;
680        impl Clone for DualStopBody {
681            fn clone(&self) -> Self {
682                DualStopBody
683            }
684        }
685        impl camel_api::OutcomePipeline for DualStopBody {
686            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
687                Box::new(DualStopBody)
688            }
689            fn run<'a>(
690                &'a mut self,
691                exchange: Exchange,
692            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
693                let is_frag0 = exchange
694                    .input
695                    .body
696                    .as_text()
697                    .map(|s| s == "from-fragment-0")
698                    .unwrap_or(false);
699                let is_frag2 = exchange
700                    .input
701                    .body
702                    .as_text()
703                    .map(|s| s == "from-fragment-2")
704                    .unwrap_or(false);
705                Box::pin(async move {
706                    if is_frag0 {
707                        return PipelineOutcome::Stopped(exchange);
708                    }
709                    if is_frag2 {
710                        // Delay slightly to ensure fragment 0's Stop is recorded first
711                        // in the CAS, then verify that lowest index wins.
712                        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
713                        return PipelineOutcome::Stopped(exchange);
714                    }
715                    // frag-1: completed
716                    PipelineOutcome::Completed(exchange)
717                })
718            }
719        }
720
721        let mut seg = SplitSegment {
722            splitter,
723            body: OutcomeSegment::new(Box::new(DualStopBody)),
724            parallel: true,
725            parallel_limit: None,
726            stop_on_exception: true,
727            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
728            aggregation: AggregationStrategy::LastWins,
729        };
730
731        let ex = Exchange::new(Message::new("test"));
732        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
733
734        match result {
735            PipelineOutcome::Stopped(ex) => {
736                assert_eq!(
737                    ex.input.body.as_text(),
738                    Some("from-fragment-0"),
739                    "Lowest stopped index (0) should win, got body {:?}",
740                    ex.input.body.as_text()
741                );
742            }
743            other => panic!("Expected Stopped with fragment-0 body, got {other:?}"),
744        }
745    }
746
747    // ── Test 5: parallel_limit enforcement ─────────────────────────────
748
749    #[tokio::test(flavor = "multi_thread")]
750    async fn split_parallel_limit_enforces_concurrency_cap() {
751        let concurrent = Arc::new(AtomicUsize::new(0));
752        let max_concurrent = Arc::new(AtomicUsize::new(0));
753
754        // Split into 6 fragments. parallel_limit=2.
755        let splitter = SplitSource::Sync(Arc::new(|ex: &Exchange| {
756            Ok((0..6)
757                .map(|i| {
758                    let mut frag = ex.clone();
759                    frag.input.body = Body::Text(format!("frag-{i}"));
760                    frag
761                })
762                .collect())
763        }));
764
765        let c = Arc::clone(&concurrent);
766        let mc = Arc::clone(&max_concurrent);
767        struct LimitedBody {
768            concurrent: Arc<AtomicUsize>,
769            max_concurrent: Arc<AtomicUsize>,
770        }
771        impl Clone for LimitedBody {
772            fn clone(&self) -> Self {
773                Self {
774                    concurrent: Arc::clone(&self.concurrent),
775                    max_concurrent: Arc::clone(&self.max_concurrent),
776                }
777            }
778        }
779        impl camel_api::OutcomePipeline for LimitedBody {
780            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
781                Box::new(self.clone())
782            }
783            fn run<'a>(
784                &'a mut self,
785                exchange: Exchange,
786            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
787                let c = Arc::clone(&self.concurrent);
788                let mc = Arc::clone(&self.max_concurrent);
789                Box::pin(async move {
790                    let current = c.fetch_add(1, Ordering::SeqCst) + 1;
791                    mc.fetch_max(current, Ordering::SeqCst);
792                    tokio::task::yield_now().await;
793                    c.fetch_sub(1, Ordering::SeqCst);
794                    PipelineOutcome::Completed(exchange)
795                })
796            }
797        }
798
799        let mut seg = SplitSegment {
800            splitter,
801            body: OutcomeSegment::new(Box::new(LimitedBody {
802                concurrent: c,
803                max_concurrent: mc,
804            })),
805            parallel: true,
806            parallel_limit: Some(2),
807            stop_on_exception: true,
808            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
809            aggregation: AggregationStrategy::LastWins,
810        };
811
812        let ex = Exchange::new(Message::new("test"));
813        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
814        assert!(
815            matches!(result, PipelineOutcome::Completed(_)),
816            "Expected Completed, got {result:?}"
817        );
818
819        let observed_max = max_concurrent.load(Ordering::SeqCst);
820        assert!(
821            observed_max <= 2,
822            "parallel_limit=2 but max concurrency was {observed_max}"
823        );
824    }
825
826    // ── Test 6: stop_on_exception=true (sequential) ────────────────────
827
828    #[tokio::test]
829    async fn split_sequential_stop_on_exception_true() {
830        // 5 fragments, fail on 2nd (index 1). stop_on_exception=true → stops.
831        fn make_fail_body(
832            fail_at: usize,
833            counter: Arc<AtomicUsize>,
834        ) -> impl camel_api::OutcomePipeline + Clone {
835            #[derive(Clone)]
836            struct FailAtBody {
837                fail_at: usize,
838                counter: Arc<AtomicUsize>,
839            }
840            impl camel_api::OutcomePipeline for FailAtBody {
841                fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
842                    Box::new(self.clone())
843                }
844                fn run<'a>(
845                    &'a mut self,
846                    exchange: Exchange,
847                ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
848                    let count = self.counter.fetch_add(1, Ordering::SeqCst);
849                    let fail_at = self.fail_at;
850                    Box::pin(async move {
851                        if count == fail_at {
852                            PipelineOutcome::Failed(CamelError::ProcessorError(format!(
853                                "fail at {count}"
854                            )))
855                        } else {
856                            PipelineOutcome::Completed(exchange)
857                        }
858                    })
859                }
860            }
861            FailAtBody { fail_at, counter }
862        }
863
864        let invocations = Arc::new(AtomicUsize::new(0));
865        let body = make_fail_body(1, Arc::clone(&invocations));
866        let mut seg = SplitSegment {
867            splitter: camel_api::SplitSource::Sync(camel_api::split_body_lines()),
868            body: OutcomeSegment::new(Box::new(body)),
869            parallel: false,
870            parallel_limit: None,
871            stop_on_exception: true,
872            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
873            aggregation: AggregationStrategy::LastWins,
874        };
875
876        let ex = Exchange::new(Message::new("a\nb\nc\nd\ne"));
877        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
878
879        assert!(
880            matches!(result, PipelineOutcome::Failed(_)),
881            "stop_on_exception=true should propagate first failure"
882        );
883        // Only 2 fragments processed (index 0 passed, index 1 failed);
884        // fragments 2-4 never run.
885        assert_eq!(
886            invocations.load(Ordering::SeqCst),
887            2,
888            "should stop after 2 fragments (0 pass, 1 fail)"
889        );
890    }
891
892    // ── Test 7: stop_on_exception=false (sequential) ───────────────────
893
894    #[tokio::test]
895    async fn split_sequential_stop_on_exception_false() {
896        // 5 fragments, fail on 2nd (index 1). stop_on_exception=false → continues.
897        fn make_fail_body(
898            fail_at: usize,
899            counter: Arc<AtomicUsize>,
900        ) -> impl camel_api::OutcomePipeline + Clone {
901            #[derive(Clone)]
902            struct FailAtBody {
903                fail_at: usize,
904                counter: Arc<AtomicUsize>,
905            }
906            impl camel_api::OutcomePipeline for FailAtBody {
907                fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
908                    Box::new(self.clone())
909                }
910                fn run<'a>(
911                    &'a mut self,
912                    exchange: Exchange,
913                ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
914                    let count = self.counter.fetch_add(1, Ordering::SeqCst);
915                    let fail_at = self.fail_at;
916                    Box::pin(async move {
917                        if count == fail_at {
918                            PipelineOutcome::Failed(CamelError::ProcessorError(format!(
919                                "fail at {count}"
920                            )))
921                        } else {
922                            PipelineOutcome::Completed(exchange)
923                        }
924                    })
925                }
926            }
927            FailAtBody { fail_at, counter }
928        }
929
930        let invocations = Arc::new(AtomicUsize::new(0));
931        let body = make_fail_body(1, Arc::clone(&invocations));
932        let mut seg = SplitSegment {
933            splitter: camel_api::SplitSource::Sync(camel_api::split_body_lines()),
934            body: OutcomeSegment::new(Box::new(body)),
935            parallel: false,
936            parallel_limit: None,
937            stop_on_exception: false,
938            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
939            aggregation: AggregationStrategy::LastWins,
940        };
941
942        let ex = Exchange::new(Message::new("a\nb\nc\nd\ne"));
943        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
944
945        // With stop_on_exception=false, processing continues after failure;
946        // last error is propagated.
947        assert!(
948            matches!(result, PipelineOutcome::Failed(_)),
949            "stop_on_exception=false should still propagate error at end"
950        );
951        // All 5 fragments processed.
952        assert_eq!(
953            invocations.load(Ordering::SeqCst),
954            5,
955            "all fragments should be processed when stop_on_exception=false"
956        );
957    }
958
959    // ── Test 8: stop_on_exception=true (parallel) ──────────────────────
960
961    #[tokio::test(flavor = "multi_thread")]
962    async fn split_parallel_stop_on_exception_true() {
963        let splitter = SplitSource::Sync(Arc::new(|ex: &Exchange| {
964            Ok((0..5)
965                .map(|i| {
966                    let mut frag = ex.clone();
967                    frag.input.body = Body::Text(format!("frag-{i}"));
968                    frag
969                })
970                .collect())
971        }));
972
973        // All fragments fail. stop_on_exception=true → first Failed propagated.
974        let invocations = Arc::new(AtomicUsize::new(0));
975        struct FailBody {
976            counter: Arc<AtomicUsize>,
977        }
978        impl Clone for FailBody {
979            fn clone(&self) -> Self {
980                Self {
981                    counter: Arc::clone(&self.counter),
982                }
983            }
984        }
985        impl camel_api::OutcomePipeline for FailBody {
986            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
987                Box::new(self.clone())
988            }
989            fn run<'a>(
990                &'a mut self,
991                _exchange: Exchange,
992            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
993                let count = self.counter.fetch_add(1, Ordering::SeqCst);
994                Box::pin(async move {
995                    PipelineOutcome::Failed(CamelError::ProcessorError(format!("fail {count}")))
996                })
997            }
998        }
999
1000        let mut seg = SplitSegment {
1001            splitter,
1002            body: OutcomeSegment::new(Box::new(FailBody {
1003                counter: Arc::clone(&invocations),
1004            })),
1005            parallel: true,
1006            parallel_limit: None,
1007            stop_on_exception: true,
1008            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1009            aggregation: AggregationStrategy::LastWins,
1010        };
1011
1012        let ex = Exchange::new(Message::new("test"));
1013        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1014
1015        assert!(
1016            matches!(result, PipelineOutcome::Failed(_)),
1017            "stop_on_exception=true should propagate first failure"
1018        );
1019        // All 5 spawned (JoinSet), all completed.
1020        assert_eq!(
1021            invocations.load(Ordering::SeqCst),
1022            5,
1023            "all fragments should be spawned"
1024        );
1025    }
1026
1027    // ── Test 9: stop_on_exception=false (parallel) ─────────────────────
1028
1029    #[tokio::test(flavor = "multi_thread")]
1030    async fn split_parallel_stop_on_exception_false() {
1031        let splitter = SplitSource::Sync(Arc::new(|ex: &Exchange| {
1032            Ok((0..5)
1033                .map(|i| {
1034                    let mut frag = ex.clone();
1035                    frag.input.body = Body::Text(format!("frag-{i}"));
1036                    frag
1037                })
1038                .collect())
1039        }));
1040
1041        // Fragment 0 passes, 1 fails, 2-4 pass.
1042        let invocations = Arc::new(AtomicUsize::new(0));
1043        struct MixedBody {
1044            counter: Arc<AtomicUsize>,
1045        }
1046        impl Clone for MixedBody {
1047            fn clone(&self) -> Self {
1048                Self {
1049                    counter: Arc::clone(&self.counter),
1050                }
1051            }
1052        }
1053        impl camel_api::OutcomePipeline for MixedBody {
1054            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
1055                Box::new(self.clone())
1056            }
1057            fn run<'a>(
1058                &'a mut self,
1059                exchange: Exchange,
1060            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1061                let count = self.counter.fetch_add(1, Ordering::SeqCst);
1062                Box::pin(async move {
1063                    if count == 1 {
1064                        PipelineOutcome::Failed(CamelError::ProcessorError("fail 1".into()))
1065                    } else {
1066                        PipelineOutcome::Completed(exchange)
1067                    }
1068                })
1069            }
1070        }
1071
1072        let mut seg = SplitSegment {
1073            splitter,
1074            body: OutcomeSegment::new(Box::new(MixedBody {
1075                counter: Arc::clone(&invocations),
1076            })),
1077            parallel: true,
1078            parallel_limit: None,
1079            stop_on_exception: false,
1080            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1081            aggregation: AggregationStrategy::LastWins,
1082        };
1083
1084        let ex = Exchange::new(Message::new("test"));
1085        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1086
1087        // stop_on_exception=false → last error propagated at end.
1088        assert!(
1089            matches!(result, PipelineOutcome::Failed(_)),
1090            "stop_on_exception=false should propagate failure at end; got {result:?}"
1091        );
1092        assert_eq!(
1093            invocations.load(Ordering::SeqCst),
1094            5,
1095            "all fragments should be spawned"
1096        );
1097    }
1098
1099    // ── Test 10: Split expression error → Failed, body never runs ──────
1100
1101    #[tokio::test]
1102    async fn test_split_segment_expression_error_is_failed() {
1103        /// Recording body: counts invocations, always completes.
1104        #[derive(Clone)]
1105        struct RecordingBody {
1106            counter: Arc<AtomicUsize>,
1107        }
1108        impl camel_api::OutcomePipeline for RecordingBody {
1109            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
1110                Box::new(self.clone())
1111            }
1112            fn run<'a>(
1113                &'a mut self,
1114                exchange: Exchange,
1115            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1116                self.counter.fetch_add(1, Ordering::SeqCst);
1117                Box::pin(async move { PipelineOutcome::Completed(exchange) })
1118            }
1119        }
1120
1121        let invocations = Arc::new(AtomicUsize::new(0));
1122        let splitter = SplitSource::Sync(Arc::new(|_| {
1123            Err(CamelError::TypeConversionFailed(
1124                "declarative split requires a text or array value, got number; add an unmarshal step before split"
1125                    .to_string(),
1126            ))
1127        }));
1128
1129        let mut seg = SplitSegment {
1130            splitter,
1131            body: OutcomeSegment::new(Box::new(RecordingBody {
1132                counter: Arc::clone(&invocations),
1133            })),
1134            parallel: false,
1135            parallel_limit: None,
1136            stop_on_exception: true,
1137            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1138            aggregation: AggregationStrategy::LastWins,
1139        };
1140
1141        let ex = Exchange::new(Message::new("anything"));
1142        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1143
1144        match result {
1145            PipelineOutcome::Failed(err) => {
1146                let msg = err.to_string();
1147                assert!(
1148                    msg.contains("declarative split"),
1149                    "carried error should mention 'declarative split': {msg}"
1150                );
1151            }
1152            other => panic!("Expected Failed, got {other:?}"),
1153        }
1154        assert_eq!(
1155            invocations.load(Ordering::SeqCst),
1156            0,
1157            "body segment must record zero invocations when the split expression errors"
1158        );
1159    }
1160
1161    #[tokio::test]
1162    async fn split_segment_wrong_type_body_fails_loud() {
1163        // Splitting a Text body with body_json_array must fail with the typed
1164        // message, not silently pass the original body through.
1165        let invocations = Arc::new(AtomicUsize::new(0));
1166
1167        #[derive(Clone)]
1168        struct RecordingBody {
1169            counter: Arc<AtomicUsize>,
1170        }
1171        impl camel_api::OutcomePipeline for RecordingBody {
1172            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
1173                Box::new(self.clone())
1174            }
1175            fn run<'a>(
1176                &'a mut self,
1177                exchange: Exchange,
1178            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1179                self.counter.fetch_add(1, Ordering::SeqCst);
1180                Box::pin(async move { PipelineOutcome::Completed(exchange) })
1181            }
1182        }
1183
1184        let mut seg = SplitSegment {
1185            splitter: SplitSource::Sync(camel_api::split_body_json_array()),
1186            body: OutcomeSegment::new(Box::new(RecordingBody {
1187                counter: Arc::clone(&invocations),
1188            })),
1189            parallel: false,
1190            parallel_limit: None,
1191            stop_on_exception: true,
1192            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1193            aggregation: AggregationStrategy::LastWins,
1194        };
1195
1196        let ex = Exchange::new(Message::new(Body::Text("a,b".to_string())));
1197        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1198
1199        match result {
1200            PipelineOutcome::Failed(err) => {
1201                let msg = err.to_string();
1202                for needle in [
1203                    "body_json_array",
1204                    "text",
1205                    "json (array)",
1206                    "add an unmarshal step before split",
1207                ] {
1208                    assert!(msg.contains(needle), "message '{msg}' missing '{needle}'");
1209                }
1210            }
1211            other => panic!("wrong body type must fail loud, got {other:?}"),
1212        }
1213        assert_eq!(
1214            invocations.load(Ordering::SeqCst),
1215            0,
1216            "body segment must record zero invocations when the split expression errors"
1217        );
1218    }
1219
1220    #[tokio::test]
1221    async fn split_segment_async_error_propagates_before_fragments() {
1222        use camel_api::BoxValueFuture;
1223
1224        let invocations = Arc::new(AtomicUsize::new(0));
1225
1226        #[derive(Clone)]
1227        struct RecordingBody {
1228            counter: Arc<AtomicUsize>,
1229        }
1230        impl camel_api::OutcomePipeline for RecordingBody {
1231            fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
1232                Box::new(self.clone())
1233            }
1234            fn run<'a>(
1235                &'a mut self,
1236                exchange: Exchange,
1237            ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1238                self.counter.fetch_add(1, Ordering::SeqCst);
1239                Box::pin(async move { PipelineOutcome::Completed(exchange) })
1240            }
1241        }
1242
1243        let splitter = SplitSource::Async(Arc::new(|_: &Exchange| {
1244            Box::pin(async { Err(CamelError::ProcessorError("split boom".into())) })
1245                as BoxValueFuture
1246        }));
1247
1248        let mut seg = SplitSegment {
1249            splitter,
1250            body: OutcomeSegment::new(Box::new(RecordingBody {
1251                counter: Arc::clone(&invocations),
1252            })),
1253            parallel: false,
1254            parallel_limit: None,
1255            stop_on_exception: true,
1256            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1257            aggregation: AggregationStrategy::LastWins,
1258        };
1259
1260        let ex = Exchange::new(Message::new("a\nb\nc"));
1261        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1262
1263        assert!(
1264            matches!(result, PipelineOutcome::Failed(CamelError::ProcessorError(ref e)) if e.contains("split boom")),
1265            "an async split-expression failure must propagate; got {result:?}"
1266        );
1267        assert_eq!(
1268            invocations.load(Ordering::SeqCst),
1269            0,
1270            "no fragment may reach the sub-pipeline when the split expression fails"
1271        );
1272    }
1273    // ── Tests 11-13: fragment metadata stamping ────────────────────────
1274
1275    /// Helper: OutcomePipeline body that captures the split metadata
1276    /// properties (index, size, complete) from each fragment exchange
1277    /// into a shared buffer.
1278    #[derive(Clone)]
1279    struct MetadataCaptureBody {
1280        triples: Arc<std::sync::Mutex<Vec<(u64, u64, bool)>>>,
1281    }
1282    impl camel_api::OutcomePipeline for MetadataCaptureBody {
1283        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
1284            Box::new(self.clone())
1285        }
1286        fn run<'a>(
1287            &'a mut self,
1288            exchange: Exchange,
1289        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1290            let triples = Arc::clone(&self.triples);
1291            Box::pin(async move {
1292                let index = exchange
1293                    .property(crate::splitter::CAMEL_SPLIT_INDEX)
1294                    .and_then(|v| v.as_u64())
1295                    .unwrap_or(u64::MAX);
1296                let size = exchange
1297                    .property(crate::splitter::CAMEL_SPLIT_SIZE)
1298                    .and_then(|v| v.as_u64())
1299                    .unwrap_or(u64::MAX);
1300                let complete = exchange
1301                    .property(crate::splitter::CAMEL_SPLIT_COMPLETE)
1302                    .and_then(|v| v.as_bool())
1303                    .unwrap_or(false);
1304                triples.lock().unwrap().push((index, size, complete));
1305                PipelineOutcome::Completed(exchange)
1306            })
1307        }
1308    }
1309
1310    /// Custom splitter producing `n` fragments with distinguishable bodies.
1311    fn n_fragment_splitter(n: u64) -> SplitSource {
1312        SplitSource::Sync(Arc::new(move |ex: &Exchange| {
1313            Ok((0..n)
1314                .map(|i| {
1315                    let mut frag = ex.clone();
1316                    frag.input.body = Body::Text(format!("frag-{i}"));
1317                    frag
1318                })
1319                .collect())
1320        }))
1321    }
1322
1323    #[tokio::test]
1324    async fn sequential_split_stamps_fragment_metadata() {
1325        let triples = Arc::new(std::sync::Mutex::new(Vec::new()));
1326
1327        let mut seg = SplitSegment {
1328            splitter: n_fragment_splitter(3),
1329            body: OutcomeSegment::new(Box::new(MetadataCaptureBody {
1330                triples: Arc::clone(&triples),
1331            })),
1332            parallel: false,
1333            parallel_limit: None,
1334            stop_on_exception: true,
1335            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1336            aggregation: AggregationStrategy::LastWins,
1337        };
1338
1339        let ex = Exchange::new(Message::new("test"));
1340        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1341        assert!(
1342            matches!(result, PipelineOutcome::Completed(_)),
1343            "Expected Completed, got {result:?}"
1344        );
1345
1346        assert_eq!(
1347            *triples.lock().unwrap(),
1348            vec![(0, 3, false), (1, 3, false), (2, 3, true)]
1349        );
1350    }
1351
1352    #[tokio::test(flavor = "multi_thread")]
1353    async fn parallel_split_stamps_fragment_metadata() {
1354        let triples = Arc::new(std::sync::Mutex::new(Vec::new()));
1355
1356        let mut seg = SplitSegment {
1357            splitter: n_fragment_splitter(3),
1358            body: OutcomeSegment::new(Box::new(MetadataCaptureBody {
1359                triples: Arc::clone(&triples),
1360            })),
1361            parallel: true,
1362            parallel_limit: None,
1363            stop_on_exception: true,
1364            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1365            aggregation: AggregationStrategy::LastWins,
1366        };
1367
1368        let ex = Exchange::new(Message::new("test"));
1369        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1370        assert!(
1371            matches!(result, PipelineOutcome::Completed(_)),
1372            "Expected Completed, got {result:?}"
1373        );
1374
1375        let mut got = triples.lock().unwrap().clone();
1376        got.sort_by_key(|(idx, _, _)| *idx);
1377        assert_eq!(got, vec![(0, 3, false), (1, 3, false), (2, 3, true)]);
1378    }
1379
1380    #[tokio::test]
1381    async fn single_fragment_is_complete() {
1382        let triples = Arc::new(std::sync::Mutex::new(Vec::new()));
1383
1384        let mut seg = SplitSegment {
1385            splitter: n_fragment_splitter(1),
1386            body: OutcomeSegment::new(Box::new(MetadataCaptureBody {
1387                triples: Arc::clone(&triples),
1388            })),
1389            parallel: false,
1390            parallel_limit: None,
1391            stop_on_exception: true,
1392            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1393            aggregation: AggregationStrategy::LastWins,
1394        };
1395
1396        let ex = Exchange::new(Message::new("test"));
1397        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1398        assert!(
1399            matches!(result, PipelineOutcome::Completed(_)),
1400            "Expected Completed, got {result:?}"
1401        );
1402
1403        assert_eq!(*triples.lock().unwrap(), vec![(0, 1, true)]);
1404    }
1405
1406    // ── Ported from the removed Tower split tests ──────────────────────
1407
1408    #[tokio::test]
1409    async fn split_segment_empty_fragments_returns_original_unchanged() {
1410        // Body::Empty → zero fragments → return original unchanged, properties intact.
1411        let mut seg = SplitSegment {
1412            splitter: SplitSource::Sync(camel_api::split_body_lines()),
1413            body: OutcomeSegment::new(Box::new(CompletedBody)),
1414            parallel: false,
1415            parallel_limit: None,
1416            stop_on_exception: true,
1417            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1418            aggregation: AggregationStrategy::LastWins,
1419        };
1420
1421        let mut ex = Exchange::new(Message::default()); // Body::Empty
1422        ex.set_property("marker", Value::Bool(true));
1423
1424        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1425        match result {
1426            PipelineOutcome::Completed(out) => {
1427                assert!(out.input.body.is_empty());
1428                assert_eq!(out.property("marker"), Some(&Value::Bool(true)));
1429            }
1430            other => panic!("Expected Completed(original), got {other:?}"),
1431        }
1432    }
1433
1434    /// Body that records the split metadata into the body as JSON.
1435    #[derive(Clone)]
1436    struct MetadataJsonBody;
1437    impl camel_api::OutcomePipeline for MetadataJsonBody {
1438        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
1439            Box::new(MetadataJsonBody)
1440        }
1441        fn run<'a>(
1442            &'a mut self,
1443            exchange: Exchange,
1444        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1445            Box::pin(async move {
1446                let index = exchange
1447                    .property(crate::splitter::CAMEL_SPLIT_INDEX)
1448                    .cloned();
1449                let size = exchange
1450                    .property(crate::splitter::CAMEL_SPLIT_SIZE)
1451                    .cloned();
1452                let complete = exchange
1453                    .property(crate::splitter::CAMEL_SPLIT_COMPLETE)
1454                    .cloned();
1455                let mut out = exchange;
1456                out.input.body = Body::Json(serde_json::json!({
1457                    "index": index,
1458                    "size": size,
1459                    "complete": complete,
1460                }));
1461                PipelineOutcome::Completed(out)
1462            })
1463        }
1464    }
1465
1466    #[tokio::test]
1467    async fn split_segment_metadata_properties() {
1468        // Uses split_body_lines + CollectAll so the metadata stamped on each
1469        // fragment is observable in the aggregated JSON body.
1470        let mut seg = SplitSegment {
1471            splitter: SplitSource::Sync(camel_api::split_body_lines()),
1472            body: OutcomeSegment::new(Box::new(MetadataJsonBody)),
1473            parallel: false,
1474            parallel_limit: None,
1475            stop_on_exception: true,
1476            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1477            aggregation: AggregationStrategy::CollectAll,
1478        };
1479
1480        let ex = Exchange::new(Message::new("x\ny\nz"));
1481        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1482
1483        let expected = serde_json::json!([
1484            {"index": 0, "size": 3, "complete": false},
1485            {"index": 1, "size": 3, "complete": false},
1486            {"index": 2, "size": 3, "complete": true},
1487        ]);
1488        match result {
1489            PipelineOutcome::Completed(out) => match &out.input.body {
1490                Body::Json(v) => assert_eq!(*v, expected),
1491                other => panic!("expected JSON body, got {other:?}"),
1492            },
1493            other => panic!("Expected Completed, got {other:?}"),
1494        }
1495    }
1496
1497    #[tokio::test]
1498    async fn split_segment_json_array_body() {
1499        // split(body_json_array) iterates array elements without tokenizing.
1500        let mut seg = SplitSegment {
1501            splitter: SplitSource::Sync(camel_api::split_body_json_array()),
1502            body: OutcomeSegment::new(Box::new(CompletedBody)),
1503            parallel: false,
1504            parallel_limit: None,
1505            stop_on_exception: true,
1506            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1507            aggregation: AggregationStrategy::CollectAll,
1508        };
1509
1510        let msg = Message::new(Body::Json(serde_json::json!([1, 2, 3])));
1511        let parent = Exchange::new(msg);
1512
1513        let result = camel_api::OutcomePipeline::run(&mut seg, parent).await;
1514        match result {
1515            PipelineOutcome::Completed(out) => match &out.input.body {
1516                Body::Json(serde_json::Value::Array(arr)) => {
1517                    assert_eq!(
1518                        arr,
1519                        &vec![
1520                            serde_json::json!(1),
1521                            serde_json::json!(2),
1522                            serde_json::json!(3)
1523                        ]
1524                    );
1525                }
1526                other => panic!("expected JSON array body, got {other:?}"),
1527            },
1528            other => panic!("Expected Completed, got {other:?}"),
1529        }
1530    }
1531
1532    /// Body that replaces the body with the fragment correlation id.
1533    #[derive(Clone)]
1534    struct CorrelationIdBody;
1535    impl camel_api::OutcomePipeline for CorrelationIdBody {
1536        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
1537            Box::new(CorrelationIdBody)
1538        }
1539        fn run<'a>(
1540            &'a mut self,
1541            mut exchange: Exchange,
1542        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1543            Box::pin(async move {
1544                exchange.input.body = Body::Text(exchange.correlation_id().to_string());
1545                PipelineOutcome::Completed(exchange)
1546            })
1547        }
1548    }
1549
1550    #[tokio::test]
1551    async fn split_segment_each_fragment_has_unique_correlation_id() {
1552        let mut seg = SplitSegment {
1553            splitter: SplitSource::Sync(camel_api::split_body_lines()),
1554            body: OutcomeSegment::new(Box::new(CorrelationIdBody)),
1555            parallel: false,
1556            parallel_limit: None,
1557            stop_on_exception: true,
1558            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1559            aggregation: AggregationStrategy::CollectAll,
1560        };
1561
1562        let ex = Exchange::new(Message::new("a\nb\nc\nd"));
1563        let result = camel_api::OutcomePipeline::run(&mut seg, ex).await;
1564
1565        let ids: Vec<String> = match result {
1566            PipelineOutcome::Completed(out) => match &out.input.body {
1567                Body::Json(serde_json::Value::Array(arr)) => arr
1568                    .iter()
1569                    .map(|v| v.as_str().unwrap_or("").to_string())
1570                    .collect(),
1571                other => panic!("expected JSON array of ids, got {other:?}"),
1572            },
1573            other => panic!("Expected Completed, got {other:?}"),
1574        };
1575        assert_eq!(ids.len(), 4, "should have 4 fragments");
1576        let unique: std::collections::HashSet<&String> = ids.iter().collect();
1577        assert_eq!(unique.len(), 4, "fragment correlation_ids must be unique");
1578    }
1579
1580    // ── Fragment cap ───────────────────────────────────────────────────
1581
1582    /// Helper: OutcomePipeline body that counts invocations and completes.
1583    #[derive(Clone)]
1584    struct CountingBody {
1585        counter: Arc<AtomicUsize>,
1586    }
1587    impl camel_api::OutcomePipeline for CountingBody {
1588        fn clone_box(&self) -> Box<dyn camel_api::OutcomePipeline> {
1589            Box::new(self.clone())
1590        }
1591        fn run<'a>(
1592            &'a mut self,
1593            exchange: Exchange,
1594        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
1595            self.counter.fetch_add(1, Ordering::SeqCst);
1596            Box::pin(async move { PipelineOutcome::Completed(exchange) })
1597        }
1598    }
1599
1600    fn counting_body(counter: &Arc<AtomicUsize>) -> OutcomeSegment {
1601        OutcomeSegment::new(Box::new(CountingBody {
1602            counter: Arc::clone(counter),
1603        }))
1604    }
1605
1606    #[tokio::test]
1607    async fn split_rejects_above_custom_cap() {
1608        let invocations = Arc::new(AtomicUsize::new(0));
1609        let mut seg = SplitSegment {
1610            splitter: n_fragment_splitter(5),
1611            body: counting_body(&invocations),
1612            parallel: false,
1613            parallel_limit: None,
1614            stop_on_exception: true,
1615            max_fragments: 4,
1616            aggregation: AggregationStrategy::LastWins,
1617        };
1618
1619        let result =
1620            camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
1621
1622        match result {
1623            PipelineOutcome::Failed(err) => assert!(
1624                err.to_string()
1625                    .contains("Splitter produced 5 fragments, exceeding max_fragments 4"),
1626                "error should name the produced count and cap, got {err}"
1627            ),
1628            other => panic!("expected Failed, got {other:?}"),
1629        }
1630        assert_eq!(
1631            invocations.load(Ordering::SeqCst),
1632            0,
1633            "no fragment may run when the cap is exceeded"
1634        );
1635    }
1636
1637    #[tokio::test]
1638    async fn split_accepts_at_and_below_custom_cap() {
1639        let below = Arc::new(AtomicUsize::new(0));
1640        let mut below_seg = SplitSegment {
1641            splitter: n_fragment_splitter(3),
1642            body: counting_body(&below),
1643            parallel: false,
1644            parallel_limit: None,
1645            stop_on_exception: true,
1646            max_fragments: 4,
1647            aggregation: AggregationStrategy::LastWins,
1648        };
1649        let below_result =
1650            camel_api::OutcomePipeline::run(&mut below_seg, Exchange::new(Message::new("test")))
1651                .await;
1652        assert!(
1653            matches!(below_result, PipelineOutcome::Completed(_)),
1654            "cap-1 must complete, got {below_result:?}"
1655        );
1656        assert_eq!(below.load(Ordering::SeqCst), 3, "cap-1 should run 3 bodies");
1657
1658        let at = Arc::new(AtomicUsize::new(0));
1659        let mut at_seg = SplitSegment {
1660            splitter: n_fragment_splitter(4),
1661            body: counting_body(&at),
1662            parallel: false,
1663            parallel_limit: None,
1664            stop_on_exception: true,
1665            max_fragments: 4,
1666            aggregation: AggregationStrategy::LastWins,
1667        };
1668        let at_result =
1669            camel_api::OutcomePipeline::run(&mut at_seg, Exchange::new(Message::new("test"))).await;
1670        assert!(
1671            matches!(at_result, PipelineOutcome::Completed(_)),
1672            "exactly-at-cap must complete, got {at_result:?}"
1673        );
1674        assert_eq!(at.load(Ordering::SeqCst), 4, "cap should run 4 bodies");
1675    }
1676
1677    #[tokio::test(flavor = "multi_thread")]
1678    async fn split_rejects_before_spawning_parallel() {
1679        let invocations = Arc::new(AtomicUsize::new(0));
1680        let mut seg = SplitSegment {
1681            splitter: n_fragment_splitter(3),
1682            body: counting_body(&invocations),
1683            parallel: true,
1684            parallel_limit: None,
1685            stop_on_exception: true,
1686            max_fragments: 2,
1687            aggregation: AggregationStrategy::LastWins,
1688        };
1689
1690        let result =
1691            camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
1692
1693        match result {
1694            PipelineOutcome::Failed(err) => assert!(
1695                err.to_string()
1696                    .contains("Splitter produced 3 fragments, exceeding max_fragments 2"),
1697                "error should name the produced count and cap, got {err}"
1698            ),
1699            other => panic!("expected Failed, got {other:?}"),
1700        }
1701        assert_eq!(
1702            invocations.load(Ordering::SeqCst),
1703            0,
1704            "no parallel task may spawn when the cap is exceeded"
1705        );
1706    }
1707
1708    #[tokio::test]
1709    async fn split_rejects_before_processing_sequential() {
1710        let invocations = Arc::new(AtomicUsize::new(0));
1711        let mut seg = SplitSegment {
1712            splitter: n_fragment_splitter(3),
1713            body: counting_body(&invocations),
1714            parallel: false,
1715            parallel_limit: None,
1716            stop_on_exception: true,
1717            max_fragments: 2,
1718            aggregation: AggregationStrategy::LastWins,
1719        };
1720
1721        let result =
1722            camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
1723
1724        match result {
1725            PipelineOutcome::Failed(err) => assert!(
1726                err.to_string()
1727                    .contains("Splitter produced 3 fragments, exceeding max_fragments 2"),
1728                "error should name the produced count and cap, got {err}"
1729            ),
1730            other => panic!("expected Failed, got {other:?}"),
1731        }
1732        assert_eq!(
1733            invocations.load(Ordering::SeqCst),
1734            0,
1735            "no fragment may run when the cap is exceeded"
1736        );
1737    }
1738
1739    #[tokio::test]
1740    async fn split_empty_fragments_pass_through_with_cap() {
1741        let invocations = Arc::new(AtomicUsize::new(0));
1742        let splitter = SplitSource::Sync(
1743            Arc::new(|_: &Exchange| Ok(Vec::new())) as camel_api::SplitExpression
1744        );
1745        let mut seg = SplitSegment {
1746            splitter,
1747            body: counting_body(&invocations),
1748            parallel: false,
1749            parallel_limit: None,
1750            stop_on_exception: true,
1751            max_fragments: 4,
1752            aggregation: AggregationStrategy::LastWins,
1753        };
1754
1755        let result =
1756            camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("original")))
1757                .await;
1758
1759        match result {
1760            PipelineOutcome::Completed(out) => {
1761                assert_eq!(
1762                    out.input.body.as_text(),
1763                    Some("original"),
1764                    "empty split must pass the original exchange through"
1765                );
1766            }
1767            other => panic!("expected Completed, got {other:?}"),
1768        }
1769        assert_eq!(
1770            invocations.load(Ordering::SeqCst),
1771            0,
1772            "an empty split must not invoke the body"
1773        );
1774    }
1775
1776    #[tokio::test]
1777    async fn split_default_cap_respected() {
1778        assert_eq!(camel_api::splitter::DEFAULT_MAX_FRAGMENTS, 100_000);
1779
1780        let invocations = Arc::new(AtomicUsize::new(0));
1781        let mut seg = SplitSegment {
1782            splitter: n_fragment_splitter(3),
1783            body: counting_body(&invocations),
1784            parallel: false,
1785            parallel_limit: None,
1786            stop_on_exception: true,
1787            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1788            aggregation: AggregationStrategy::LastWins,
1789        };
1790
1791        let result =
1792            camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
1793
1794        assert!(
1795            matches!(result, PipelineOutcome::Completed(_)),
1796            "3 fragments are well below the default cap, got {result:?}"
1797        );
1798        assert_eq!(invocations.load(Ordering::SeqCst), 3, "all 3 bodies run");
1799    }
1800
1801    #[tokio::test]
1802    async fn split_default_cap_plus_one_rejected() {
1803        let invocations = Arc::new(AtomicUsize::new(0));
1804        let mut seg = SplitSegment {
1805            splitter: n_fragment_splitter(100_001),
1806            body: counting_body(&invocations),
1807            parallel: false,
1808            parallel_limit: None,
1809            stop_on_exception: true,
1810            max_fragments: camel_api::splitter::DEFAULT_MAX_FRAGMENTS,
1811            aggregation: AggregationStrategy::LastWins,
1812        };
1813
1814        let result =
1815            camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
1816
1817        match result {
1818            PipelineOutcome::Failed(err) => assert!(
1819                err.to_string().contains("exceeding max_fragments 100000"),
1820                "default cap error should name 100000, got {err}"
1821            ),
1822            other => panic!("expected Failed, got {other:?}"),
1823        }
1824        assert_eq!(
1825            invocations.load(Ordering::SeqCst),
1826            0,
1827            "no fragment may run when the default cap is exceeded"
1828        );
1829    }
1830
1831    #[tokio::test]
1832    async fn split_zero_cap_fails_closed_non_empty() {
1833        let invocations = Arc::new(AtomicUsize::new(0));
1834        let mut seg = SplitSegment {
1835            splitter: n_fragment_splitter(2),
1836            body: counting_body(&invocations),
1837            parallel: false,
1838            parallel_limit: None,
1839            stop_on_exception: true,
1840            max_fragments: 0,
1841            aggregation: AggregationStrategy::LastWins,
1842        };
1843
1844        let result =
1845            camel_api::OutcomePipeline::run(&mut seg, Exchange::new(Message::new("test"))).await;
1846
1847        match result {
1848            PipelineOutcome::Failed(err) => assert!(
1849                err.to_string().contains("exceeding max_fragments 0"),
1850                "zero cap must fail closed for non-empty splits, got {err}"
1851            ),
1852            other => panic!("expected Failed, got {other:?}"),
1853        }
1854        assert_eq!(
1855            invocations.load(Ordering::SeqCst),
1856            0,
1857            "zero cap must reject before processing"
1858        );
1859    }
1860}