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