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::Empty => Value::Null,
273                    Body::Stream(s) => serde_json::json!({
274                        "_stream": {
275                            "origin": s.metadata.origin,
276                            "placeholder": true,
277                            "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
278                        }
279                    }),
280                };
281                bodies.push(value);
282            }
283            let mut out = original;
284            out.input.body = Body::Json(Value::Array(bodies));
285            Ok(out)
286        }
287        AggregationStrategy::Original => Ok(original),
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    }
298}
299
300// ── Tests ──────────────────────────────────────────────────────────────
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use camel_api::{BoxProcessorExt, Message};
306    use std::sync::Arc;
307    use std::sync::atomic::{AtomicUsize, Ordering};
308    use tower::ServiceExt;
309
310    // ── Test helpers ───────────────────────────────────────────────────
311
312    fn passthrough_pipeline() -> BoxProcessor {
313        BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }))
314    }
315
316    fn uppercase_pipeline() -> BoxProcessor {
317        BoxProcessor::from_fn(|mut ex: Exchange| {
318            Box::pin(async move {
319                if let Body::Text(s) = &ex.input.body {
320                    ex.input.body = Body::Text(s.to_uppercase());
321                }
322                Ok(ex)
323            })
324        })
325    }
326
327    fn failing_pipeline() -> BoxProcessor {
328        BoxProcessor::from_fn(|_ex| {
329            Box::pin(async { Err(CamelError::ProcessorError("boom".into())) })
330        })
331    }
332
333    fn fail_on_nth(n: usize) -> BoxProcessor {
334        let count = Arc::new(AtomicUsize::new(0));
335        BoxProcessor::from_fn(move |ex: Exchange| {
336            let count = Arc::clone(&count);
337            Box::pin(async move {
338                let c = count.fetch_add(1, Ordering::SeqCst);
339                if c == n {
340                    Err(CamelError::ProcessorError(format!("fail on {c}")))
341                } else {
342                    Ok(ex)
343                }
344            })
345        })
346    }
347
348    fn make_exchange(text: &str) -> Exchange {
349        Exchange::new(Message::new(text))
350    }
351
352    #[test]
353    fn test_splitter_zero_parallel_limit_rejected() {
354        let config = SplitterConfig::new(camel_api::split_body_lines())
355            .parallel(true)
356            .parallel_limit(0);
357        let result = SplitterService::new(config, passthrough_pipeline());
358        assert!(result.is_err(), "zero parallel_limit should return Err");
359    }
360
361    // ── 1. Sequential + LastWins ───────────────────────────────────────
362
363    #[tokio::test]
364    async fn test_split_sequential_last_wins() {
365        let config = SplitterConfig::new(camel_api::split_body_lines())
366            .aggregation(AggregationStrategy::LastWins);
367        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
368
369        let result = svc
370            .ready()
371            .await
372            .unwrap()
373            .call(make_exchange("a\nb\nc"))
374            .await
375            .unwrap();
376        assert_eq!(result.input.body.as_text(), Some("C"));
377    }
378
379    // ── 2. Sequential + CollectAll ─────────────────────────────────────
380
381    #[tokio::test]
382    async fn test_split_sequential_collect_all() {
383        let config = SplitterConfig::new(camel_api::split_body_lines())
384            .aggregation(AggregationStrategy::CollectAll);
385        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
386
387        let result = svc
388            .ready()
389            .await
390            .unwrap()
391            .call(make_exchange("a\nb\nc"))
392            .await
393            .unwrap();
394        let expected = serde_json::json!(["A", "B", "C"]);
395        match &result.input.body {
396            Body::Json(v) => assert_eq!(*v, expected),
397            other => panic!("expected JSON body, got {other:?}"),
398        }
399    }
400
401    // ── 3. Sequential + Original ───────────────────────────────────────
402
403    #[tokio::test]
404    async fn test_split_sequential_original() {
405        let config = SplitterConfig::new(camel_api::split_body_lines())
406            .aggregation(AggregationStrategy::Original);
407        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
408
409        let result = svc
410            .ready()
411            .await
412            .unwrap()
413            .call(make_exchange("a\nb\nc"))
414            .await
415            .unwrap();
416        // Original body should be unchanged.
417        assert_eq!(result.input.body.as_text(), Some("a\nb\nc"));
418    }
419
420    // ── 4. Sequential + Custom aggregation ─────────────────────────────
421
422    #[tokio::test]
423    async fn test_split_sequential_custom_aggregation() {
424        let joiner: Arc<dyn Fn(Exchange, Exchange) -> Exchange + Send + Sync> =
425            Arc::new(|mut acc: Exchange, next: Exchange| {
426                let acc_text = acc.input.body.as_text().unwrap_or("").to_string();
427                let next_text = next.input.body.as_text().unwrap_or("").to_string();
428                acc.input.body = Body::Text(format!("{acc_text}+{next_text}"));
429                acc
430            });
431
432        let config = SplitterConfig::new(camel_api::split_body_lines())
433            .aggregation(AggregationStrategy::Custom(joiner));
434        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
435
436        let result = svc
437            .ready()
438            .await
439            .unwrap()
440            .call(make_exchange("a\nb\nc"))
441            .await
442            .unwrap();
443        assert_eq!(result.input.body.as_text(), Some("A+B+C"));
444    }
445
446    // ── 5. Stop on exception ───────────────────────────────────────────
447
448    #[tokio::test]
449    async fn test_split_stop_on_exception() {
450        // 5 fragments, fail on the 2nd (index 1), stop=true
451        let config = SplitterConfig::new(camel_api::split_body_lines()).stop_on_exception(true);
452        let mut svc = SplitterService::new(config, fail_on_nth(1)).unwrap();
453
454        let result = svc
455            .ready()
456            .await
457            .unwrap()
458            .call(make_exchange("a\nb\nc\nd\ne"))
459            .await;
460
461        // LastWins is default, the last result should be the error from fragment 1.
462        assert!(result.is_err(), "expected error due to stop_on_exception");
463    }
464
465    // ── 6. Continue on exception ───────────────────────────────────────
466
467    #[tokio::test]
468    async fn test_split_continue_on_exception() {
469        // 3 fragments, fail on 2nd (index 1), stop=false, LastWins.
470        let config = SplitterConfig::new(camel_api::split_body_lines())
471            .stop_on_exception(false)
472            .aggregation(AggregationStrategy::LastWins);
473        let mut svc = SplitterService::new(config, fail_on_nth(1)).unwrap();
474
475        let result = svc
476            .ready()
477            .await
478            .unwrap()
479            .call(make_exchange("a\nb\nc"))
480            .await;
481
482        // LastWins: last fragment (index 2) succeeded.
483        assert!(result.is_ok(), "last fragment should succeed");
484    }
485
486    // ── 7. Empty fragments ─────────────────────────────────────────────
487
488    #[tokio::test]
489    async fn test_split_empty_fragments() {
490        // Body::Empty → no fragments → return original unchanged.
491        let config = SplitterConfig::new(camel_api::split_body_lines());
492        let mut svc = SplitterService::new(config, passthrough_pipeline()).unwrap();
493
494        let mut ex = Exchange::new(Message::default()); // Body::Empty
495        ex.set_property("marker", Value::Bool(true));
496
497        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
498        assert!(result.input.body.is_empty());
499        assert_eq!(result.property("marker"), Some(&Value::Bool(true)));
500    }
501
502    // ── 8. Metadata properties ─────────────────────────────────────────
503
504    #[tokio::test]
505    async fn test_split_metadata_properties() {
506        // Use passthrough so we can inspect metadata on returned fragments.
507        // CollectAll won't preserve metadata, so use a pipeline that records
508        // the metadata into the body as JSON.
509        let recorder = BoxProcessor::from_fn(|ex: Exchange| {
510            Box::pin(async move {
511                let idx = ex.property(CAMEL_SPLIT_INDEX).cloned();
512                let size = ex.property(CAMEL_SPLIT_SIZE).cloned();
513                let complete = ex.property(CAMEL_SPLIT_COMPLETE).cloned();
514                let body = serde_json::json!({
515                    "index": idx,
516                    "size": size,
517                    "complete": complete,
518                });
519                let mut out = ex;
520                out.input.body = Body::Json(body);
521                Ok(out)
522            })
523        });
524
525        let config = SplitterConfig::new(camel_api::split_body_lines())
526            .aggregation(AggregationStrategy::CollectAll);
527        let mut svc = SplitterService::new(config, recorder).unwrap();
528
529        let result = svc
530            .ready()
531            .await
532            .unwrap()
533            .call(make_exchange("x\ny\nz"))
534            .await
535            .unwrap();
536
537        let expected = serde_json::json!([
538            {"index": 0, "size": 3, "complete": false},
539            {"index": 1, "size": 3, "complete": false},
540            {"index": 2, "size": 3, "complete": true},
541        ]);
542        match &result.input.body {
543            Body::Json(v) => assert_eq!(*v, expected),
544            other => panic!("expected JSON body, got {other:?}"),
545        }
546    }
547
548    // ── 9. poll_ready delegates to sub-pipeline ────────────────────────
549
550    #[tokio::test]
551    async fn test_poll_ready_delegates_to_sub_pipeline() {
552        use std::sync::atomic::AtomicBool;
553
554        // A service that is initially not ready, then becomes ready.
555        #[derive(Clone)]
556        struct DelayedReady {
557            ready: Arc<AtomicBool>,
558        }
559
560        impl Service<Exchange> for DelayedReady {
561            type Response = Exchange;
562            type Error = CamelError;
563            type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
564
565            fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
566                if self.ready.load(Ordering::SeqCst) {
567                    Poll::Ready(Ok(()))
568                } else {
569                    cx.waker().wake_by_ref();
570                    Poll::Pending
571                }
572            }
573
574            fn call(&mut self, exchange: Exchange) -> Self::Future {
575                Box::pin(async move { Ok(exchange) })
576            }
577        }
578
579        let ready_flag = Arc::new(AtomicBool::new(false));
580        let inner = DelayedReady {
581            ready: Arc::clone(&ready_flag),
582        };
583        let boxed: BoxProcessor = BoxProcessor::new(inner);
584
585        let config = SplitterConfig::new(camel_api::split_body_lines());
586        let mut svc = SplitterService::new(config, boxed).unwrap();
587
588        // First poll should be Pending.
589        let waker = futures::task::noop_waker();
590        let mut cx = Context::from_waker(&waker);
591        let poll = Pin::new(&mut svc).poll_ready(&mut cx);
592        assert!(
593            poll.is_pending(),
594            "expected Pending when sub_pipeline not ready"
595        );
596
597        // Mark inner as ready.
598        ready_flag.store(true, Ordering::SeqCst);
599
600        let poll = Pin::new(&mut svc).poll_ready(&mut cx);
601        assert!(
602            matches!(poll, Poll::Ready(Ok(()))),
603            "expected Ready after sub_pipeline becomes ready"
604        );
605    }
606
607    // ── 10. Parallel basic ─────────────────────────────────────────────
608
609    #[tokio::test]
610    async fn test_split_parallel_basic() {
611        let config = SplitterConfig::new(camel_api::split_body_lines())
612            .parallel(true)
613            .aggregation(AggregationStrategy::CollectAll);
614        let mut svc = SplitterService::new(config, uppercase_pipeline()).unwrap();
615
616        let result = svc
617            .ready()
618            .await
619            .unwrap()
620            .call(make_exchange("a\nb\nc"))
621            .await
622            .unwrap();
623
624        let expected = serde_json::json!(["A", "B", "C"]);
625        match &result.input.body {
626            Body::Json(v) => assert_eq!(*v, expected),
627            other => panic!("expected JSON body, got {other:?}"),
628        }
629    }
630
631    // ── 11. Parallel with limit ────────────────────────────────────────
632
633    #[tokio::test]
634    async fn test_split_parallel_with_limit() {
635        use std::sync::atomic::AtomicUsize;
636
637        let concurrent = Arc::new(AtomicUsize::new(0));
638        let max_concurrent = Arc::new(AtomicUsize::new(0));
639
640        let c = Arc::clone(&concurrent);
641        let mc = Arc::clone(&max_concurrent);
642        let pipeline = BoxProcessor::from_fn(move |ex: Exchange| {
643            let c = Arc::clone(&c);
644            let mc = Arc::clone(&mc);
645            Box::pin(async move {
646                let current = c.fetch_add(1, Ordering::SeqCst) + 1;
647                // Record the high-water mark.
648                mc.fetch_max(current, Ordering::SeqCst);
649                // Yield to let other tasks run.
650                tokio::task::yield_now().await;
651                c.fetch_sub(1, Ordering::SeqCst);
652                Ok(ex)
653            })
654        });
655
656        let config = SplitterConfig::new(camel_api::split_body_lines())
657            .parallel(true)
658            .parallel_limit(2)
659            .aggregation(AggregationStrategy::CollectAll);
660        let mut svc = SplitterService::new(config, pipeline).unwrap();
661
662        let result = svc
663            .ready()
664            .await
665            .unwrap()
666            .call(make_exchange("a\nb\nc\nd"))
667            .await;
668        assert!(result.is_ok());
669
670        let observed_max = max_concurrent.load(Ordering::SeqCst);
671        assert!(
672            observed_max <= 2,
673            "max concurrency was {observed_max}, expected <= 2"
674        );
675    }
676
677    // ── 12. Parallel stop on exception ─────────────────────────────────
678
679    #[tokio::test]
680    async fn test_split_parallel_stop_on_exception() {
681        let config = SplitterConfig::new(camel_api::split_body_lines())
682            .parallel(true)
683            .stop_on_exception(true);
684        let mut svc = SplitterService::new(config, failing_pipeline()).unwrap();
685
686        let result = svc
687            .ready()
688            .await
689            .unwrap()
690            .call(make_exchange("a\nb\nc"))
691            .await;
692
693        // All fragments fail; LastWins returns the last error.
694        assert!(result.is_err(), "expected error when all fragments fail");
695    }
696
697    // ── 13. Stream body aggregation creates valid JSON ───────────────────
698
699    #[tokio::test]
700    async fn test_splitter_stream_bodies_creates_valid_json() {
701        use bytes::Bytes;
702        use camel_api::{StreamBody, StreamMetadata};
703        use futures::stream;
704        use tokio::sync::Mutex;
705
706        let chunks = vec![Ok(Bytes::from("test"))];
707        let stream_body = StreamBody {
708            stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
709            metadata: StreamMetadata {
710                origin: Some("kafka://topic/partition".to_string()),
711                ..Default::default()
712            },
713        };
714
715        let original = Exchange::new(Message {
716            headers: Default::default(),
717            body: Body::Empty,
718        });
719
720        let results = vec![Ok(Exchange::new(Message {
721            headers: Default::default(),
722            body: Body::Stream(stream_body),
723        }))];
724
725        let result = aggregate(results, original, AggregationStrategy::CollectAll);
726
727        let exchange = result.expect("Expected Ok result");
728        assert!(
729            matches!(exchange.input.body, Body::Json(_)),
730            "Expected Json body"
731        );
732
733        if let Body::Json(value) = exchange.input.body {
734            let json_str = serde_json::to_string(&value).unwrap();
735            let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
736
737            assert!(parsed.is_array());
738            let arr = parsed.as_array().unwrap();
739            assert!(arr[0].is_object());
740            assert!(arr[0]["_stream"].is_object());
741            assert_eq!(arr[0]["_stream"]["origin"], "kafka://topic/partition");
742            assert_eq!(arr[0]["_stream"]["placeholder"], true);
743        }
744    }
745
746    #[tokio::test]
747    async fn test_splitter_stream_with_none_origin_creates_valid_json() {
748        use bytes::Bytes;
749        use camel_api::{StreamBody, StreamMetadata};
750        use futures::stream;
751        use tokio::sync::Mutex;
752
753        let chunks = vec![Ok(Bytes::from("test"))];
754        let stream_body = StreamBody {
755            stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
756            metadata: StreamMetadata {
757                origin: None,
758                ..Default::default()
759            },
760        };
761
762        let original = Exchange::new(Message {
763            headers: Default::default(),
764            body: Body::Empty,
765        });
766
767        let results = vec![Ok(Exchange::new(Message {
768            headers: Default::default(),
769            body: Body::Stream(stream_body),
770        }))];
771
772        let result = aggregate(results, original, AggregationStrategy::CollectAll);
773
774        let exchange = result.expect("Expected Ok result");
775        assert!(
776            matches!(exchange.input.body, Body::Json(_)),
777            "Expected Json body"
778        );
779
780        if let Body::Json(value) = exchange.input.body {
781            let json_str = serde_json::to_string(&value).unwrap();
782            let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
783
784            assert!(parsed.is_array());
785            let arr = parsed.as_array().unwrap();
786            assert!(arr[0].is_object());
787            assert!(arr[0]["_stream"].is_object());
788            assert_eq!(arr[0]["_stream"]["origin"], serde_json::Value::Null);
789            assert_eq!(arr[0]["_stream"]["placeholder"], true);
790        }
791    }
792
793    // ── 14. Parallel cancellation ──────────────────────────────────────
794
795    #[tokio::test]
796    async fn test_splitter_parallel_cancel_aborts_processing() {
797        use std::sync::atomic::AtomicBool;
798
799        let started = Arc::new(AtomicBool::new(false));
800
801        let s = Arc::clone(&started);
802        let pipeline = BoxProcessor::from_fn(move |ex: Exchange| {
803            let s = Arc::clone(&s);
804            Box::pin(async move {
805                s.store(true, Ordering::SeqCst);
806                // Long-running task that should be cancelled.
807                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
808                Ok(ex)
809            })
810        });
811
812        let config = SplitterConfig::new(camel_api::split_body_lines())
813            .parallel(true)
814            .aggregation(AggregationStrategy::LastWins);
815        let svc = SplitterService::new(config, pipeline).unwrap();
816
817        // Cancel before calling — call should return an error.
818        svc.cancel();
819        assert!(svc.is_cancelled());
820
821        let mut svc_clone = svc.clone();
822        let result = svc_clone
823            .ready()
824            .await
825            .unwrap()
826            .call(make_exchange("a\nb\nc"))
827            .await;
828
829        assert!(result.is_err(), "cancelled splitter should return error");
830    }
831
832    // ── 15. Fragment count cap ─────────────────────────────────────────
833
834    #[tokio::test]
835    async fn test_splitter_rejects_fragment_flood() {
836        // Expression that produces 5 fragments; cap at 2.
837        let expression: camel_api::SplitExpression = std::sync::Arc::new(|_| {
838            (0..5)
839                .map(|i| Exchange::new(Message::new(Body::Text(i.to_string()))))
840                .collect::<Vec<_>>()
841        });
842        let cfg = SplitterConfig::new(expression).max_fragments(2);
843        let passthrough = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
844        let mut svc = SplitterService::new(cfg, passthrough).unwrap();
845
846        let ex = Exchange::new(Message::new(Body::Text("parent".into())));
847        let result = svc.ready().await.unwrap().call(ex).await;
848        let err = result.unwrap_err();
849        assert!(
850            format!("{err}").contains("max_fragments"),
851            "error should mention max_fragments: {err}"
852        );
853    }
854}