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