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