Skip to main content

camel_processor/
splitter.rs

1use futures::future::join_all;
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::Arc;
5use std::task::{Context, Poll};
6use tokio::sync::Semaphore;
7use tokio_util::sync::CancellationToken;
8use tower::Service;
9
10use camel_api::{
11    AggregationStrategy, Body, BoxProcessor, CamelError, Exchange, SplitSource, SplitterConfig,
12    Value,
13};
14
15// ── Metadata property keys ─────────────────────────────────────────────
16
17/// Property key for the zero-based index of a fragment within the split.
18pub const CAMEL_SPLIT_INDEX: &str = "CamelSplitIndex";
19/// Property key for the total number of fragments produced by the split.
20pub const CAMEL_SPLIT_SIZE: &str = "CamelSplitSize";
21/// Property key indicating whether this fragment is the last one.
22pub const CAMEL_SPLIT_COMPLETE: &str = "CamelSplitComplete";
23
24// ── SplitterService ────────────────────────────────────────────────────
25
26/// Tower Service implementing the Splitter EIP.
27///
28/// Splits an incoming exchange into fragments via a configurable expression,
29/// processes each fragment through a sub-pipeline, and aggregates the results.
30///
31/// **DoS bound (R3-M4):** the eager splitter materializes the whole fragment
32/// `Vec` before processing. `SplitterConfig::max_fragments` (default 100_000)
33/// rejects a split that would explode memory. For unbounded or lazy byte-stream
34/// input, prefer `StreamingSplitterService`, which processes fragments as they
35/// arrive and never materializes the full set.
36///
37/// **Note:** In parallel mode, `stop_on_exception` only affects the aggregation
38/// phase. All spawned fragments run to completion because `join_all` cannot
39/// cancel in-flight futures. Sequential mode stops processing immediately.
40#[derive(Clone)]
41pub struct SplitterService {
42    expression: SplitSource,
43    sub_pipeline: BoxProcessor,
44    aggregation: AggregationStrategy,
45    parallel: bool,
46    parallel_limit: Option<usize>,
47    stop_on_exception: bool,
48    max_fragments: usize,
49    cancel_token: CancellationToken,
50}
51
52impl SplitterService {
53    /// Create a new `SplitterService` from a [`SplitterConfig`] and a sub-pipeline.
54    pub fn new(config: SplitterConfig, sub_pipeline: BoxProcessor) -> Result<Self, CamelError> {
55        config.validate()?;
56        Ok(Self {
57            expression: config.expression,
58            sub_pipeline,
59            aggregation: config.aggregation,
60            parallel: config.parallel,
61            parallel_limit: config.parallel_limit,
62            stop_on_exception: config.stop_on_exception,
63            max_fragments: config.max_fragments,
64            cancel_token: CancellationToken::new(),
65        })
66    }
67
68    /// Cancel all in-flight parallel tasks and prevent new ones from starting.
69    pub fn cancel(&self) {
70        self.cancel_token.cancel();
71    }
72
73    /// Check whether the splitter has been cancelled.
74    pub fn is_cancelled(&self) -> bool {
75        self.cancel_token.is_cancelled()
76    }
77}
78
79impl Service<Exchange> for SplitterService {
80    type Response = Exchange;
81    type Error = CamelError;
82    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
83
84    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
85        self.sub_pipeline.poll_ready(cx)
86    }
87
88    fn call(&mut self, exchange: Exchange) -> Self::Future {
89        let original = exchange.clone();
90        let expression = self.expression.clone();
91        let sub_pipeline = self.sub_pipeline.clone();
92        let aggregation = self.aggregation.clone();
93        let parallel = self.parallel;
94        let parallel_limit = self.parallel_limit;
95        let stop_on_exception = self.stop_on_exception;
96        let max_fragments = self.max_fragments;
97        let cancel_token = self.cancel_token.clone();
98
99        Box::pin(async move {
100            // Split the exchange into fragments. A typed expression error
101            // (e.g. wrong body type) fails loud instead of degrading to an
102            // empty-fragment pass-through.
103            let mut fragments = expression.split(&exchange).await?;
104
105            // If no fragments were produced, return the original exchange.
106            if fragments.is_empty() {
107                return Ok(original);
108            }
109
110            // R3-M4: the eager splitter materializes the whole Vec before
111            // processing — cap the fragment count to bound memory.
112            if fragments.len() > max_fragments {
113                return Err(CamelError::ProcessorError(format!(
114                    "Splitter produced {} fragments, exceeding max_fragments {}",
115                    fragments.len(),
116                    max_fragments
117                )));
118            }
119
120            let total = fragments.len();
121
122            // Set metadata on each fragment.
123            for (i, frag) in fragments.iter_mut().enumerate() {
124                frag.set_property(CAMEL_SPLIT_INDEX, Value::from(i as u64));
125                frag.set_property(CAMEL_SPLIT_SIZE, Value::from(total as u64));
126                frag.set_property(CAMEL_SPLIT_COMPLETE, Value::Bool(i == total - 1));
127            }
128
129            // Check cancellation before processing.
130            if cancel_token.is_cancelled() {
131                return Err(CamelError::ProcessorError(
132                    "Splitter cancelled, dropping exchange".to_string(),
133                ));
134            }
135
136            // Process fragments through the sub-pipeline.
137            let results = if parallel {
138                process_parallel(
139                    fragments,
140                    sub_pipeline,
141                    parallel_limit,
142                    stop_on_exception,
143                    cancel_token,
144                )
145                .await
146            } else {
147                process_sequential(fragments, sub_pipeline, stop_on_exception).await
148            };
149
150            // Aggregate the results.
151            aggregate(results, original, aggregation)
152        })
153    }
154}
155
156// ── Sequential processing ──────────────────────────────────────────────
157
158async fn process_sequential(
159    fragments: Vec<Exchange>,
160    sub_pipeline: BoxProcessor,
161    stop_on_exception: bool,
162) -> Vec<Result<Exchange, CamelError>> {
163    let mut results = Vec::with_capacity(fragments.len());
164
165    for fragment in fragments {
166        let mut pipeline = sub_pipeline.clone();
167        match tower::ServiceExt::ready(&mut pipeline).await {
168            Err(e) => {
169                results.push(Err(e));
170                if stop_on_exception {
171                    break;
172                }
173            }
174            Ok(svc) => {
175                let result = svc.call(fragment).await;
176                let is_err = result.is_err();
177                results.push(result);
178                if stop_on_exception && is_err {
179                    break;
180                }
181            }
182        }
183    }
184
185    results
186}
187
188// ── Parallel processing ────────────────────────────────────────────────
189
190async fn process_parallel(
191    fragments: Vec<Exchange>,
192    sub_pipeline: BoxProcessor,
193    parallel_limit: Option<usize>,
194    _stop_on_exception: bool,
195    cancel_token: CancellationToken,
196) -> Vec<Result<Exchange, CamelError>> {
197    let semaphore = parallel_limit.map(|limit| Arc::new(Semaphore::new(limit)));
198
199    let futures: Vec<_> = fragments
200        .into_iter()
201        .map(|fragment| {
202            let mut pipeline = sub_pipeline.clone();
203            let sem = semaphore.clone();
204            let cancel = cancel_token.clone();
205            async move {
206                // Check cancellation before acquiring semaphore.
207                if cancel.is_cancelled() {
208                    return Err(CamelError::ProcessorError("Splitter cancelled".to_string()));
209                }
210
211                // Acquire semaphore permit if a limit is set.
212                let _permit = match &sem {
213                    Some(s) => {
214                        tokio::select! {
215                            result = s.acquire() => {
216                                Some(result.map_err(|e| {
217                                    CamelError::ProcessorError(format!("semaphore error: {e}"))
218                                })?)
219                            }
220                            _ = cancel.cancelled() => {
221                                return Err(CamelError::ProcessorError(
222                                    "Splitter cancelled while waiting for semaphore".to_string(),
223                                ));
224                            }
225                        }
226                    }
227                    None => None,
228                };
229
230                // Check cancellation again after acquiring.
231                if cancel.is_cancelled() {
232                    return Err(CamelError::ProcessorError("Splitter cancelled".to_string()));
233                }
234
235                tokio::select! {
236                    result = async {
237                        tower::ServiceExt::ready(&mut pipeline).await?;
238                        pipeline.call(fragment).await
239                    } => result,
240                    _ = cancel.cancelled() => {
241                        Err(CamelError::ProcessorError(
242                            "Splitter cancelled during processing".to_string(),
243                        ))
244                    }
245                }
246            }
247        })
248        .collect();
249
250    join_all(futures).await
251}
252
253// ── Aggregation ────────────────────────────────────────────────────────
254
255fn aggregate(
256    results: Vec<Result<Exchange, CamelError>>,
257    original: Exchange,
258    strategy: AggregationStrategy,
259) -> Result<Exchange, CamelError> {
260    match strategy {
261        AggregationStrategy::LastWins => {
262            // Return the last result (error or success).
263            results.into_iter().last().unwrap_or_else(|| Ok(original))
264        }
265        AggregationStrategy::CollectAll => {
266            // Collect all bodies into a JSON array. Errors propagate.
267            let mut bodies = Vec::new();
268            for result in results {
269                let ex = result?;
270                let value = match &ex.input.body {
271                    Body::Text(s) => Value::String(s.clone()),
272                    Body::Json(v) => v.clone(),
273                    Body::Xml(s) => Value::String(s.clone()),
274                    Body::Bytes(b) => Value::String(String::from_utf8_lossy(b).into_owned()),
275                    Body::Stream(s) => serde_json::json!({
276                        "_stream": {
277                            "origin": s.metadata.origin,
278                            "placeholder": true,
279                            "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
280                        }
281                    }),
282                    // Empty and future variants contribute no extractable value.
283                    _ => Value::Null,
284                };
285                bodies.push(value);
286            }
287            let mut out = original;
288            out.input.body = Body::Json(Value::Array(bodies));
289            Ok(out)
290        }
291        AggregationStrategy::Custom(fold_fn) => {
292            // Fold using the custom function, starting from the first result.
293            let mut iter = results.into_iter();
294            let first = iter.next().unwrap_or_else(|| Ok(original.clone()))?;
295            iter.try_fold(first, |acc, next_result| {
296                let next = next_result?;
297                Ok(fold_fn(acc, next))
298            })
299        }
300        // Original and any future variant return the original exchange.
301        _ => Ok(original),
302    }
303}
304
305// ── Tests ──────────────────────────────────────────────────────────────
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use camel_api::{BoxProcessorExt, Message};
311    use std::sync::Arc;
312    use std::sync::atomic::{AtomicUsize, Ordering};
313    use tower::ServiceExt;
314
315    // ── Test helpers ───────────────────────────────────────────────────
316
317    fn passthrough_pipeline() -> BoxProcessor {
318        BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }))
319    }
320
321    fn uppercase_pipeline() -> BoxProcessor {
322        BoxProcessor::from_fn(|mut ex: Exchange| {
323            Box::pin(async move {
324                if let Body::Text(s) = &ex.input.body {
325                    ex.input.body = Body::Text(s.to_uppercase());
326                }
327                Ok(ex)
328            })
329        })
330    }
331
332    fn failing_pipeline() -> BoxProcessor {
333        BoxProcessor::from_fn(|_ex| {
334            Box::pin(async { Err(CamelError::ProcessorError("boom".into())) })
335        })
336    }
337
338    fn fail_on_nth(n: usize) -> BoxProcessor {
339        let count = Arc::new(AtomicUsize::new(0));
340        BoxProcessor::from_fn(move |ex: Exchange| {
341            let count = Arc::clone(&count);
342            Box::pin(async move {
343                let c = count.fetch_add(1, Ordering::SeqCst);
344                if c == n {
345                    Err(CamelError::ProcessorError(format!("fail on {c}")))
346                } else {
347                    Ok(ex)
348                }
349            })
350        })
351    }
352
353    fn make_exchange(text: &str) -> Exchange {
354        Exchange::new(Message::new(text))
355    }
356
357    #[tokio::test]
358    async fn split_sync_builtin_still_works() {
359        // The Sync arm of SplitSource must keep the built-in expressions
360        // compiling and behaving unchanged.
361        let config = SplitterConfig::new(camel_api::split_body_lines());
362        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
363
364        let result = svc
365            .ready()
366            .await
367            .unwrap()
368            .call(make_exchange("a\nb\nc"))
369            .await
370            .unwrap();
371        assert_eq!(result.input.body.as_text(), Some("C"));
372    }
373
374    #[tokio::test]
375    async fn split_async_error_propagates_before_fragments() {
376        use camel_api::{BoxValueFuture, SplitSource};
377
378        let pipeline_calls = Arc::new(AtomicUsize::new(0));
379        let calls_clone = pipeline_calls.clone();
380        let pipeline = BoxProcessor::from_fn(move |ex: Exchange| {
381            calls_clone.fetch_add(1, Ordering::SeqCst);
382            Box::pin(async move { Ok(ex) })
383        });
384
385        let config = SplitterConfig::new(SplitSource::Async(Arc::new(|_: &Exchange| {
386            Box::pin(async { Err(CamelError::ProcessorError("split boom".into())) })
387                as BoxValueFuture
388        })));
389        let mut svc = SplitterService::new(config, pipeline).unwrap();
390
391        let result = svc
392            .ready()
393            .await
394            .unwrap()
395            .call(make_exchange("a\nb\nc"))
396            .await;
397
398        assert!(
399            matches!(result, Err(CamelError::ProcessorError(ref e)) if e.contains("split boom")),
400            "an async split-expression failure must propagate"
401        );
402        assert_eq!(
403            pipeline_calls.load(Ordering::SeqCst),
404            0,
405            "no fragment may reach the sub-pipeline when the split expression fails"
406        );
407    }
408
409    #[test]
410    fn test_splitter_zero_parallel_limit_rejected() {
411        let config = SplitterConfig::new(camel_api::split_body_lines())
412            .parallel(true)
413            .parallel_limit(0);
414        let result = SplitterService::new(config, passthrough_pipeline());
415        assert!(result.is_err(), "zero parallel_limit should return Err");
416    }
417
418    // ── 1. Sequential + LastWins ───────────────────────────────────────
419
420    #[tokio::test]
421    async fn test_split_sequential_last_wins() {
422        let config = SplitterConfig::new(camel_api::split_body_lines())
423            .aggregation(AggregationStrategy::LastWins);
424        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
425
426        let result = svc
427            .ready()
428            .await
429            .unwrap()
430            .call(make_exchange("a\nb\nc"))
431            .await
432            .unwrap();
433        assert_eq!(result.input.body.as_text(), Some("C"));
434    }
435
436    // ── 2. Sequential + CollectAll ─────────────────────────────────────
437
438    #[tokio::test]
439    async fn test_split_sequential_collect_all() {
440        let config = SplitterConfig::new(camel_api::split_body_lines())
441            .aggregation(AggregationStrategy::CollectAll);
442        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
443
444        let result = svc
445            .ready()
446            .await
447            .unwrap()
448            .call(make_exchange("a\nb\nc"))
449            .await
450            .unwrap();
451        let expected = serde_json::json!(["A", "B", "C"]);
452        match &result.input.body {
453            Body::Json(v) => assert_eq!(*v, expected),
454            other => panic!("expected JSON body, got {other:?}"),
455        }
456    }
457
458    // ── 3. Sequential + Original ───────────────────────────────────────
459
460    #[tokio::test]
461    async fn test_split_sequential_original() {
462        let config = SplitterConfig::new(camel_api::split_body_lines())
463            .aggregation(AggregationStrategy::Original);
464        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
465
466        let result = svc
467            .ready()
468            .await
469            .unwrap()
470            .call(make_exchange("a\nb\nc"))
471            .await
472            .unwrap();
473        // Original body should be unchanged.
474        assert_eq!(result.input.body.as_text(), Some("a\nb\nc"));
475    }
476
477    // ── 4. Sequential + Custom aggregation ─────────────────────────────
478
479    #[tokio::test]
480    async fn test_split_sequential_custom_aggregation() {
481        let joiner: Arc<dyn Fn(Exchange, Exchange) -> Exchange + Send + Sync> =
482            Arc::new(|mut acc: Exchange, next: Exchange| {
483                let acc_text = acc.input.body.as_text().unwrap_or("").to_string();
484                let next_text = next.input.body.as_text().unwrap_or("").to_string();
485                acc.input.body = Body::Text(format!("{acc_text}+{next_text}"));
486                acc
487            });
488
489        let config = SplitterConfig::new(camel_api::split_body_lines())
490            .aggregation(AggregationStrategy::Custom(joiner));
491        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
492
493        let result = svc
494            .ready()
495            .await
496            .unwrap()
497            .call(make_exchange("a\nb\nc"))
498            .await
499            .unwrap();
500        assert_eq!(result.input.body.as_text(), Some("A+B+C"));
501    }
502
503    // ── 5. Stop on exception ───────────────────────────────────────────
504
505    #[tokio::test]
506    async fn test_split_stop_on_exception() {
507        // 5 fragments, fail on the 2nd (index 1), stop=true
508        let config = SplitterConfig::new(camel_api::split_body_lines()).stop_on_exception(true);
509        let mut svc = SplitterService::new(config, fail_on_nth(1)).unwrap();
510
511        let result = svc
512            .ready()
513            .await
514            .unwrap()
515            .call(make_exchange("a\nb\nc\nd\ne"))
516            .await;
517
518        // LastWins is default, the last result should be the error from fragment 1.
519        assert!(result.is_err(), "expected error due to stop_on_exception");
520    }
521
522    // ── 6. Continue on exception ───────────────────────────────────────
523
524    #[tokio::test]
525    async fn test_split_continue_on_exception() {
526        // 3 fragments, fail on 2nd (index 1), stop=false, LastWins.
527        let config = SplitterConfig::new(camel_api::split_body_lines())
528            .stop_on_exception(false)
529            .aggregation(AggregationStrategy::LastWins);
530        let mut svc = SplitterService::new(config, fail_on_nth(1)).unwrap();
531
532        let result = svc
533            .ready()
534            .await
535            .unwrap()
536            .call(make_exchange("a\nb\nc"))
537            .await;
538
539        // LastWins: last fragment (index 2) succeeded.
540        assert!(result.is_ok(), "last fragment should succeed");
541    }
542
543    // ── 7. Empty fragments ─────────────────────────────────────────────
544
545    #[tokio::test]
546    async fn test_split_empty_fragments() {
547        // Body::Empty → no fragments → return original unchanged.
548        let config = SplitterConfig::new(camel_api::split_body_lines());
549        let mut svc = SplitterService::new(config, passthrough_pipeline()).unwrap();
550
551        let mut ex = Exchange::new(Message::default()); // Body::Empty
552        ex.set_property("marker", Value::Bool(true));
553
554        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
555        assert!(result.input.body.is_empty());
556        assert_eq!(result.property("marker"), Some(&Value::Bool(true)));
557    }
558
559    // ── 8. Metadata properties ─────────────────────────────────────────
560
561    #[tokio::test]
562    async fn test_split_metadata_properties() {
563        // Use passthrough so we can inspect metadata on returned fragments.
564        // CollectAll won't preserve metadata, so use a pipeline that records
565        // the metadata into the body as JSON.
566        let recorder = BoxProcessor::from_fn(|ex: Exchange| {
567            Box::pin(async move {
568                let idx = ex.property(CAMEL_SPLIT_INDEX).cloned();
569                let size = ex.property(CAMEL_SPLIT_SIZE).cloned();
570                let complete = ex.property(CAMEL_SPLIT_COMPLETE).cloned();
571                let body = serde_json::json!({
572                    "index": idx,
573                    "size": size,
574                    "complete": complete,
575                });
576                let mut out = ex;
577                out.input.body = Body::Json(body);
578                Ok(out)
579            })
580        });
581
582        let config = SplitterConfig::new(camel_api::split_body_lines())
583            .aggregation(AggregationStrategy::CollectAll);
584        let mut svc = SplitterService::new(config, recorder).unwrap();
585
586        let result = svc
587            .ready()
588            .await
589            .unwrap()
590            .call(make_exchange("x\ny\nz"))
591            .await
592            .unwrap();
593
594        let expected = serde_json::json!([
595            {"index": 0, "size": 3, "complete": false},
596            {"index": 1, "size": 3, "complete": false},
597            {"index": 2, "size": 3, "complete": true},
598        ]);
599        match &result.input.body {
600            Body::Json(v) => assert_eq!(*v, expected),
601            other => panic!("expected JSON body, got {other:?}"),
602        }
603    }
604
605    // ── 9. poll_ready delegates to sub-pipeline ────────────────────────
606
607    #[tokio::test]
608    async fn test_poll_ready_delegates_to_sub_pipeline() {
609        use std::sync::atomic::AtomicBool;
610
611        // A service that is initially not ready, then becomes ready.
612        #[derive(Clone)]
613        struct DelayedReady {
614            ready: Arc<AtomicBool>,
615        }
616
617        impl Service<Exchange> for DelayedReady {
618            type Response = Exchange;
619            type Error = CamelError;
620            type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
621
622            fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
623                if self.ready.load(Ordering::SeqCst) {
624                    Poll::Ready(Ok(()))
625                } else {
626                    cx.waker().wake_by_ref();
627                    Poll::Pending
628                }
629            }
630
631            fn call(&mut self, exchange: Exchange) -> Self::Future {
632                Box::pin(async move { Ok(exchange) })
633            }
634        }
635
636        let ready_flag = Arc::new(AtomicBool::new(false));
637        let inner = DelayedReady {
638            ready: Arc::clone(&ready_flag),
639        };
640        let boxed: BoxProcessor = BoxProcessor::new(inner);
641
642        let config = SplitterConfig::new(camel_api::split_body_lines());
643        let mut svc = SplitterService::new(config, boxed).unwrap();
644
645        // First poll should be Pending.
646        let waker = futures::task::noop_waker();
647        let mut cx = Context::from_waker(&waker);
648        let poll = Pin::new(&mut svc).poll_ready(&mut cx);
649        assert!(
650            poll.is_pending(),
651            "expected Pending when sub_pipeline not ready"
652        );
653
654        // Mark inner as ready.
655        ready_flag.store(true, Ordering::SeqCst);
656
657        let poll = Pin::new(&mut svc).poll_ready(&mut cx);
658        assert!(
659            matches!(poll, Poll::Ready(Ok(()))),
660            "expected Ready after sub_pipeline becomes ready"
661        );
662    }
663
664    // ── 10. Parallel basic ─────────────────────────────────────────────
665
666    #[tokio::test]
667    async fn test_split_parallel_basic() {
668        let config = SplitterConfig::new(camel_api::split_body_lines())
669            .parallel(true)
670            .aggregation(AggregationStrategy::CollectAll);
671        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
672
673        let result = svc
674            .ready()
675            .await
676            .unwrap()
677            .call(make_exchange("a\nb\nc"))
678            .await
679            .unwrap();
680
681        let expected = serde_json::json!(["A", "B", "C"]);
682        match &result.input.body {
683            Body::Json(v) => assert_eq!(*v, expected),
684            other => panic!("expected JSON body, got {other:?}"),
685        }
686    }
687
688    // ── 11. Parallel with limit ────────────────────────────────────────
689
690    #[tokio::test]
691    async fn test_split_parallel_with_limit() {
692        use std::sync::atomic::AtomicUsize;
693
694        let concurrent = Arc::new(AtomicUsize::new(0));
695        let max_concurrent = Arc::new(AtomicUsize::new(0));
696
697        let c = Arc::clone(&concurrent);
698        let mc = Arc::clone(&max_concurrent);
699        let pipeline = BoxProcessor::from_fn(move |ex: Exchange| {
700            let c = Arc::clone(&c);
701            let mc = Arc::clone(&mc);
702            Box::pin(async move {
703                let current = c.fetch_add(1, Ordering::SeqCst) + 1;
704                // Record the high-water mark.
705                mc.fetch_max(current, Ordering::SeqCst);
706                // Yield to let other tasks run.
707                tokio::task::yield_now().await;
708                c.fetch_sub(1, Ordering::SeqCst);
709                Ok(ex)
710            })
711        });
712
713        let config = SplitterConfig::new(camel_api::split_body_lines())
714            .parallel(true)
715            .parallel_limit(2)
716            .aggregation(AggregationStrategy::CollectAll);
717        let mut svc = SplitterService::new(config, pipeline).unwrap();
718
719        let result = svc
720            .ready()
721            .await
722            .unwrap()
723            .call(make_exchange("a\nb\nc\nd"))
724            .await;
725        assert!(result.is_ok());
726
727        let observed_max = max_concurrent.load(Ordering::SeqCst);
728        assert!(
729            observed_max <= 2,
730            "max concurrency was {observed_max}, expected <= 2"
731        );
732    }
733
734    // ── 12. Parallel stop on exception ─────────────────────────────────
735
736    #[tokio::test]
737    async fn test_split_parallel_stop_on_exception() {
738        let config = SplitterConfig::new(camel_api::split_body_lines())
739            .parallel(true)
740            .stop_on_exception(true);
741        let mut svc = SplitterService::new(config, failing_pipeline()).unwrap();
742
743        let result = svc
744            .ready()
745            .await
746            .unwrap()
747            .call(make_exchange("a\nb\nc"))
748            .await;
749
750        // All fragments fail; LastWins returns the last error.
751        assert!(result.is_err(), "expected error when all fragments fail");
752    }
753
754    // ── 13. Stream body aggregation creates valid JSON ───────────────────
755
756    #[tokio::test]
757    async fn test_splitter_stream_bodies_creates_valid_json() {
758        use bytes::Bytes;
759        use camel_api::{StreamBody, StreamMetadata};
760        use futures::stream;
761        use tokio::sync::Mutex;
762
763        let chunks = vec![Ok(Bytes::from("test"))];
764        let stream_body = StreamBody {
765            stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
766            metadata: StreamMetadata {
767                origin: Some("kafka://topic/partition".to_string()),
768                ..Default::default()
769            },
770        };
771
772        let original = Exchange::new(Message {
773            headers: Default::default(),
774            body: Body::Empty,
775        });
776
777        let results = vec![Ok(Exchange::new(Message {
778            headers: Default::default(),
779            body: Body::Stream(stream_body),
780        }))];
781
782        let result = aggregate(results, original, AggregationStrategy::CollectAll);
783
784        let exchange = result.expect("Expected Ok result");
785        assert!(
786            matches!(exchange.input.body, Body::Json(_)),
787            "Expected Json body"
788        );
789
790        if let Body::Json(value) = exchange.input.body {
791            let json_str = serde_json::to_string(&value).unwrap();
792            let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
793
794            assert!(parsed.is_array());
795            let arr = parsed.as_array().unwrap();
796            assert!(arr[0].is_object());
797            assert!(arr[0]["_stream"].is_object());
798            assert_eq!(arr[0]["_stream"]["origin"], "kafka://topic/partition");
799            assert_eq!(arr[0]["_stream"]["placeholder"], true);
800        }
801    }
802
803    #[tokio::test]
804    async fn test_splitter_stream_with_none_origin_creates_valid_json() {
805        use bytes::Bytes;
806        use camel_api::{StreamBody, StreamMetadata};
807        use futures::stream;
808        use tokio::sync::Mutex;
809
810        let chunks = vec![Ok(Bytes::from("test"))];
811        let stream_body = StreamBody {
812            stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
813            metadata: StreamMetadata {
814                origin: None,
815                ..Default::default()
816            },
817        };
818
819        let original = Exchange::new(Message {
820            headers: Default::default(),
821            body: Body::Empty,
822        });
823
824        let results = vec![Ok(Exchange::new(Message {
825            headers: Default::default(),
826            body: Body::Stream(stream_body),
827        }))];
828
829        let result = aggregate(results, original, AggregationStrategy::CollectAll);
830
831        let exchange = result.expect("Expected Ok result");
832        assert!(
833            matches!(exchange.input.body, Body::Json(_)),
834            "Expected Json body"
835        );
836
837        if let Body::Json(value) = exchange.input.body {
838            let json_str = serde_json::to_string(&value).unwrap();
839            let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
840
841            assert!(parsed.is_array());
842            let arr = parsed.as_array().unwrap();
843            assert!(arr[0].is_object());
844            assert!(arr[0]["_stream"].is_object());
845            assert_eq!(arr[0]["_stream"]["origin"], serde_json::Value::Null);
846            assert_eq!(arr[0]["_stream"]["placeholder"], true);
847        }
848    }
849
850    // ── 14. Parallel cancellation ──────────────────────────────────────
851
852    #[tokio::test]
853    async fn test_splitter_parallel_cancel_aborts_processing() {
854        use std::sync::atomic::AtomicBool;
855
856        let started = Arc::new(AtomicBool::new(false));
857
858        let s = Arc::clone(&started);
859        let pipeline = BoxProcessor::from_fn(move |ex: Exchange| {
860            let s = Arc::clone(&s);
861            Box::pin(async move {
862                s.store(true, Ordering::SeqCst);
863                // Long-running task that should be cancelled.
864                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
865                Ok(ex)
866            })
867        });
868
869        let config = SplitterConfig::new(camel_api::split_body_lines())
870            .parallel(true)
871            .aggregation(AggregationStrategy::LastWins);
872        let svc = SplitterService::new(config, pipeline).unwrap();
873
874        // Cancel before calling — call should return an error.
875        svc.cancel();
876        assert!(svc.is_cancelled());
877
878        let mut svc_clone = svc.clone();
879        let result = svc_clone
880            .ready()
881            .await
882            .unwrap()
883            .call(make_exchange("a\nb\nc"))
884            .await;
885
886        assert!(result.is_err(), "cancelled splitter should return error");
887    }
888
889    // ── 15. Fragment count cap ─────────────────────────────────────────
890
891    #[tokio::test]
892    async fn test_splitter_rejects_fragment_flood() {
893        // Expression that produces 5 fragments; cap at 2.
894        let expression: camel_api::SplitExpression = std::sync::Arc::new(|_| {
895            Ok((0..5)
896                .map(|i| Exchange::new(Message::new(Body::Text(i.to_string()))))
897                .collect::<Vec<_>>())
898        });
899        let cfg = SplitterConfig::new(expression).max_fragments(2);
900        let passthrough = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
901        let mut svc = SplitterService::new(cfg, passthrough).unwrap();
902
903        let ex = Exchange::new(Message::new(Body::Text("parent".into())));
904        let result = svc.ready().await.unwrap().call(ex).await;
905        let err = result.unwrap_err();
906        assert!(
907            format!("{err}").contains("max_fragments"),
908            "error should mention max_fragments: {err}"
909        );
910    }
911
912    // ── 16. Fragments have unique correlation IDs ──────────────────────
913    // Inspiration: Camel SplitterTest C1/C4 — fragments must be distinguishable
914    // downstream (e.g. for idempotency keys). fragment_exchange (camel-api)
915    // assigns a fresh UUID per fragment; this test pins the invariant.
916
917    #[tokio::test]
918    async fn test_splitter_each_fragment_has_unique_correlation_id() {
919        // Pipeline records each fragment's correlation_id into the body.
920        let recorder = BoxProcessor::from_fn(|ex: Exchange| {
921            Box::pin(async move {
922                let id = ex.correlation_id().to_string();
923                let mut out = ex;
924                out.input.body = Body::Text(id);
925                Ok(out)
926            })
927        });
928
929        let config = SplitterConfig::new(camel_api::split_body_lines())
930            .aggregation(AggregationStrategy::CollectAll);
931        let mut svc = SplitterService::new(config, recorder).unwrap();
932
933        let result = svc
934            .ready()
935            .await
936            .unwrap()
937            .call(make_exchange("a\nb\nc\nd"))
938            .await
939            .unwrap();
940
941        let ids: Vec<String> = match &result.input.body {
942            Body::Json(serde_json::Value::Array(arr)) => arr
943                .iter()
944                .map(|v| v.as_str().unwrap_or("").to_string())
945                .collect(),
946            other => panic!("expected JSON array of ids, got {other:?}"),
947        };
948        assert_eq!(ids.len(), 4, "should have 4 fragments");
949        let unique: std::collections::HashSet<&String> = ids.iter().collect();
950        assert_eq!(unique.len(), 4, "fragment correlation_ids must be unique");
951    }
952
953    // ── 17. split(body()) on a JSON Array body ─────────────────────────
954    // Inspiration: Camel SplitterTest C11 — when body is already a collection,
955    // split iterates elements without tokenizing. camel_api::split_body_json_array
956    // provides this; verify the wiring through SplitterService.
957
958    #[tokio::test]
959    async fn test_splitter_json_array_body() {
960        let recorder = BoxProcessor::from_fn(|ex: Exchange| {
961            Box::pin(async move {
962                let v = match &ex.input.body {
963                    Body::Json(v) => v.clone(),
964                    other => panic!("expected JSON fragment, got {other:?}"),
965                };
966                let mut out = ex;
967                out.input.body = Body::Json(v);
968                Ok(out)
969            })
970        });
971
972        let config = SplitterConfig::new(camel_api::split_body_json_array())
973            .aggregation(AggregationStrategy::CollectAll);
974        let mut svc = SplitterService::new(config, recorder).unwrap();
975
976        let msg = Message::new(Body::Json(serde_json::json!([1, 2, 3])));
977        let parent = Exchange::new(msg);
978
979        let result = svc.ready().await.unwrap().call(parent).await.unwrap();
980        match &result.input.body {
981            Body::Json(serde_json::Value::Array(arr)) => {
982                assert_eq!(arr.len(), 3, "should split array into 3 fragments");
983                assert_eq!(arr[0], 1);
984                assert_eq!(arr[1], 2);
985                assert_eq!(arr[2], 3);
986            }
987            other => panic!("expected JSON array body, got {other:?}"),
988        }
989    }
990
991    // ── 18. Wrong body type fails loud ─────────────────────────────────
992
993    #[tokio::test]
994    async fn test_splitter_wrong_type_body_fails_loud() {
995        // Splitting a Text body with body_json_array must return Err with
996        // the typed message, not silently pass the original through.
997        let config = SplitterConfig::new(camel_api::split_body_json_array());
998        let mut svc = SplitterService::new(config, passthrough_pipeline()).unwrap();
999
1000        let ex = Exchange::new(Message::new(Body::Text("a,b".to_string())));
1001        let result = svc.ready().await.unwrap().call(ex).await;
1002
1003        let err = result.expect_err("wrong body type must fail loud, not pass through");
1004        let msg = err.to_string();
1005        for needle in [
1006            "body_json_array",
1007            "text",
1008            "json (array)",
1009            "add an unmarshal step before split",
1010        ] {
1011            assert!(msg.contains(needle), "message '{msg}' missing '{needle}'");
1012        }
1013    }
1014}