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
14pub const CAMEL_SPLIT_INDEX: &str = "CamelSplitIndex";
18pub const CAMEL_SPLIT_SIZE: &str = "CamelSplitSize";
20pub const CAMEL_SPLIT_COMPLETE: &str = "CamelSplitComplete";
22
23#[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 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 pub fn cancel(&self) {
69 self.cancel_token.cancel();
70 }
71
72 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 let mut fragments = expression(&exchange);
101
102 if fragments.is_empty() {
104 return Ok(original);
105 }
106
107 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 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 if cancel_token.is_cancelled() {
128 return Err(CamelError::ProcessorError(
129 "Splitter cancelled, dropping exchange".to_string(),
130 ));
131 }
132
133 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(results, original, aggregation)
149 })
150 }
151}
152
153async 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
185async 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 if cancel.is_cancelled() {
205 return Err(CamelError::ProcessorError("Splitter cancelled".to_string()));
206 }
207
208 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 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
250fn aggregate(
253 results: Vec<Result<Exchange, CamelError>>,
254 original: Exchange,
255 strategy: AggregationStrategy,
256) -> Result<Exchange, CamelError> {
257 match strategy {
258 AggregationStrategy::LastWins => {
259 results.into_iter().last().unwrap_or_else(|| Ok(original))
261 }
262 AggregationStrategy::CollectAll => {
263 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 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#[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 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 #[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 #[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 #[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 assert_eq!(result.input.body.as_text(), Some("a\nb\nc"));
418 }
419
420 #[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 #[tokio::test]
449 async fn test_split_stop_on_exception() {
450 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 assert!(result.is_err(), "expected error due to stop_on_exception");
463 }
464
465 #[tokio::test]
468 async fn test_split_continue_on_exception() {
469 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 assert!(result.is_ok(), "last fragment should succeed");
484 }
485
486 #[tokio::test]
489 async fn test_split_empty_fragments() {
490 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()); 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 #[tokio::test]
505 async fn test_split_metadata_properties() {
506 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 #[tokio::test]
551 async fn test_poll_ready_delegates_to_sub_pipeline() {
552 use std::sync::atomic::AtomicBool;
553
554 #[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 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 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 #[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 #[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 mc.fetch_max(current, Ordering::SeqCst);
649 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 #[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 assert!(result.is_err(), "expected error when all fragments fail");
695 }
696
697 #[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 #[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 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 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 #[tokio::test]
835 async fn test_splitter_rejects_fragment_flood() {
836 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}