1use std::pin::Pin;
2use std::sync::Arc;
3
4use futures::Stream;
5
6use crate::body::{Body, body_type_name};
7use crate::error::CamelError;
8use crate::exchange::Exchange;
9use crate::message::Message;
10use crate::value::Value;
11
12pub type SplitExpression =
18 Arc<dyn Fn(&Exchange) -> Result<Vec<Exchange>, CamelError> + Send + Sync>;
19
20#[derive(Clone)]
28#[non_exhaustive]
29pub enum SplitSource {
30 Sync(SplitExpression),
32 Async(Arc<dyn Fn(&Exchange) -> crate::filter::BoxValueFuture + Send + Sync>),
34}
35
36impl From<SplitExpression> for SplitSource {
37 fn from(f: SplitExpression) -> Self {
38 Self::Sync(f)
39 }
40}
41
42impl SplitSource {
43 pub async fn split(&self, exchange: &Exchange) -> Result<Vec<Exchange>, CamelError> {
45 match self {
46 Self::Sync(f) => f(exchange),
47 Self::Async(f) => derive_fragments(exchange, f(exchange).await?),
48 }
49 }
50}
51
52fn derive_fragments(exchange: &Exchange, value: Value) -> Result<Vec<Exchange>, CamelError> {
59 match value {
60 Value::String(s) => Ok(s
61 .lines()
62 .filter(|line| !line.is_empty())
63 .map(|line| {
64 let mut fragment = exchange.clone();
65 fragment.input.body = Body::from(line.to_string());
66 fragment
67 })
68 .collect()),
69 Value::Array(arr) => Ok(arr
70 .into_iter()
71 .map(|v| {
72 let mut fragment = exchange.clone();
73 fragment.input.body = match v {
74 Value::String(s) => Body::Text(s),
75 other => Body::Json(other),
76 };
77 fragment
78 })
79 .collect()),
80 other => Err(CamelError::TypeConversionFailed(format!(
81 "declarative split requires a text or array value, got {received}; add an unmarshal step before split",
82 received = crate::value_type_name(&other)
83 ))),
84 }
85}
86
87pub type StreamingSplitExpression = Arc<
94 dyn Fn(Exchange) -> Pin<Box<dyn Stream<Item = Result<Exchange, CamelError>> + Send>>
95 + Send
96 + Sync,
97>;
98
99pub fn streaming_split_type_error(body: &Body) -> CamelError {
103 CamelError::TypeConversionFailed(format!(
104 "streaming split requires body type stream, got {}; add an unmarshal step before split",
105 body_type_name(body)
106 ))
107}
108
109#[derive(Clone, Default)]
111#[non_exhaustive]
112pub enum AggregationStrategy {
113 #[default]
115 LastWins,
116 CollectAll,
118 Original,
120 Custom(Arc<dyn Fn(Exchange, Exchange) -> Exchange + Send + Sync>),
122}
123
124impl std::fmt::Debug for AggregationStrategy {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 match self {
127 AggregationStrategy::LastWins => f.write_str("LastWins"),
128 AggregationStrategy::CollectAll => f.write_str("CollectAll"),
129 AggregationStrategy::Original => f.write_str("Original"),
130 AggregationStrategy::Custom(_) => f.write_str("Custom(..)"),
131 }
132 }
133}
134
135#[derive(
137 Clone,
138 Debug,
139 Default,
140 PartialEq,
141 Eq,
142 serde::Serialize,
143 serde::Deserialize,
144 schemars::JsonSchema,
145 ts_rs::TS,
146)]
147#[serde(rename_all = "snake_case")]
148#[ts(rename_all = "snake_case")]
149#[non_exhaustive]
150pub enum StreamSplitFormat {
151 #[default]
153 Auto,
154 Ndjson,
156 Lines,
158 Chunks,
160 Zip,
162 Tar,
164 #[serde(rename = "tar.gz")]
166 #[ts(rename = "tar.gz")]
167 TarGz,
168}
169
170#[derive(
175 Clone,
176 Debug,
177 PartialEq,
178 Eq,
179 serde::Serialize,
180 serde::Deserialize,
181 schemars::JsonSchema,
182 ts_rs::TS,
183)]
184#[serde(rename_all = "snake_case")]
185#[ts(rename_all = "snake_case")]
186pub struct StreamSplitConfig {
187 pub format: StreamSplitFormat,
189 pub max_record_bytes: usize,
191 pub batch_size: usize,
193 pub chunk_size: Option<usize>,
195 pub include_origin: bool,
197}
198
199impl Default for StreamSplitConfig {
200 fn default() -> Self {
201 Self {
202 format: StreamSplitFormat::Auto,
203 max_record_bytes: 1024 * 1024,
204 batch_size: 1,
205 chunk_size: None,
206 include_origin: true,
207 }
208 }
209}
210
211impl StreamSplitConfig {
212 pub fn validate(&self) -> Result<(), CamelError> {
225 if self.batch_size == 0 {
226 return Err(CamelError::Config(
227 "stream split batch_size must be > 0".into(),
228 ));
229 }
230 if self.max_record_bytes == 0 {
231 return Err(CamelError::Config(
232 "stream split max_record_bytes must be > 0".into(),
233 ));
234 }
235 if self.format == StreamSplitFormat::Chunks && self.chunk_size.is_none() {
236 return Err(CamelError::Config(
237 "stream split format=Chunks requires chunk_size".into(),
238 ));
239 }
240 if self.format == StreamSplitFormat::Zip && self.chunk_size.is_some() {
245 return Err(CamelError::Config(
246 "stream split format=Zip does not support chunk_size".into(),
247 ));
248 }
249 if matches!(
250 self.format,
251 StreamSplitFormat::Tar | StreamSplitFormat::TarGz
252 ) && self.chunk_size.is_some()
253 {
254 return Err(CamelError::Config(format!(
255 "stream split format={:?} is a materialized archive format and does not support chunk_size",
256 self.format
257 )));
258 }
259 if let Some(cs) = self.chunk_size
260 && cs == 0
261 {
262 return Err(CamelError::Config(
263 "stream split chunk_size must be > 0".into(),
264 ));
265 }
266 if self.format == StreamSplitFormat::Chunks
267 && let Some(cs) = self.chunk_size
268 && cs > self.max_record_bytes
269 {
270 return Err(CamelError::Config(
271 "stream split chunk_size must be <= max_record_bytes".into(),
272 ));
273 }
274 Ok(())
275 }
276}
277
278pub const DEFAULT_TRACE_ITEM_THRESHOLD: usize = 100;
280
281pub const DEFAULT_MAX_FRAGMENTS: usize = 100_000;
283
284#[derive(Clone)]
286pub struct SplitterConfig {
287 pub expression: SplitSource,
289 pub aggregation: AggregationStrategy,
291 pub parallel: bool,
293 pub parallel_limit: Option<usize>,
295 pub stop_on_exception: bool,
301 pub max_fragments: usize,
310 pub trace_item_threshold: usize,
312}
313
314impl std::fmt::Debug for SplitterConfig {
315 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316 f.debug_struct("SplitterConfig")
317 .field("expression", &"<split-expression>")
318 .field("aggregation", &self.aggregation)
319 .field("parallel", &self.parallel)
320 .field("parallel_limit", &self.parallel_limit)
321 .field("stop_on_exception", &self.stop_on_exception)
322 .field("max_fragments", &self.max_fragments)
323 .field("trace_item_threshold", &self.trace_item_threshold)
324 .finish()
325 }
326}
327
328impl SplitterConfig {
329 pub fn new(expression: impl Into<SplitSource>) -> Self {
331 Self {
332 expression: expression.into(),
333 aggregation: AggregationStrategy::default(),
334 parallel: false,
335 parallel_limit: None,
336 stop_on_exception: true,
337 max_fragments: DEFAULT_MAX_FRAGMENTS,
338 trace_item_threshold: DEFAULT_TRACE_ITEM_THRESHOLD,
339 }
340 }
341
342 pub fn aggregation(mut self, strategy: AggregationStrategy) -> Self {
344 self.aggregation = strategy;
345 self
346 }
347
348 pub fn parallel(mut self, parallel: bool) -> Self {
350 self.parallel = parallel;
351 self
352 }
353
354 pub fn parallel_limit(mut self, limit: usize) -> Self {
356 self.parallel_limit = Some(limit);
357 self
358 }
359
360 pub fn stop_on_exception(mut self, stop: bool) -> Self {
365 self.stop_on_exception = stop;
366 self
367 }
368
369 pub fn max_fragments(mut self, max: usize) -> Self {
371 self.max_fragments = max;
372 self
373 }
374
375 pub fn trace_item_threshold(mut self, threshold: usize) -> Self {
377 self.trace_item_threshold = threshold;
378 self
379 }
380
381 pub fn validate(&self) -> Result<(), CamelError> {
386 if self.parallel && self.parallel_limit == Some(0) {
387 return Err(CamelError::Config(
388 "splitter parallel_limit must be > 0".to_string(),
389 ));
390 }
391 if self.max_fragments == 0 {
392 return Err(CamelError::Config(
393 "splitter max_fragments must be > 0".to_string(),
394 ));
395 }
396 Ok(())
397 }
398}
399
400pub fn fragment_exchange(parent: &Exchange, body: Body) -> Exchange {
425 let mut msg = Message::new(body);
426 msg.headers = parent.input.headers.clone();
427 let mut ex = Exchange::new(msg);
428 ex.properties = parent.properties.clone();
429 ex.pattern = parent.pattern;
430 ex.otel_context = parent.otel_context.clone();
432 ex.in_flight_claim = parent.in_flight_claim.as_ref().map(|claim| claim.split());
436 ex
437}
438
439pub fn split_body_lines() -> SplitExpression {
445 Arc::new(|exchange: &Exchange| {
446 let text = match &exchange.input.body {
447 Body::Text(s) => s.as_str(),
448 Body::Empty => return Ok(Vec::new()),
449 _ => {
450 return Err(CamelError::TypeConversionFailed(format!(
451 "split expression 'body_lines' requires body type text, got {received}; add an unmarshal step before split",
452 received = body_type_name(&exchange.input.body)
453 )));
454 }
455 };
456 Ok(text
457 .lines()
458 .map(|line| fragment_exchange(exchange, Body::Text(line.to_string())))
459 .collect())
460 })
461}
462
463pub fn split_body_json_array() -> SplitExpression {
474 Arc::new(|exchange: &Exchange| {
475 let arr = match &exchange.input.body {
476 Body::Json(serde_json::Value::Array(arr)) => arr,
477 Body::Empty => return Ok(Vec::new()),
478 Body::Json(_) => {
479 return Err(CamelError::TypeConversionFailed(
480 "split expression 'body_json_array' requires body type json (array), got json (non-array); add an unmarshal step before split"
481 .to_string(),
482 ))
483 }
484 _ => {
485 return Err(CamelError::TypeConversionFailed(format!(
486 "split expression 'body_json_array' requires body type json (array), got {received}; add an unmarshal step before split",
487 received = body_type_name(&exchange.input.body)
488 )))
489 }
490 };
491 Ok(arr
492 .iter()
493 .map(|val| match val {
494 serde_json::Value::String(s) => fragment_exchange(exchange, Body::Text(s.clone())),
495 other => fragment_exchange(exchange, Body::Json(other.clone())),
496 })
497 .collect())
498 })
499}
500
501pub fn split_body<F>(f: F) -> SplitExpression
506where
507 F: Fn(&Body) -> Vec<Body> + Send + Sync + 'static,
508{
509 Arc::new(move |exchange: &Exchange| {
510 Ok(f(&exchange.input.body)
511 .into_iter()
512 .map(|body| fragment_exchange(exchange, body))
513 .collect())
514 })
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::in_flight::{InFlightClaim, InFlightGauge};
521 use crate::value::Value;
522 use std::sync::Arc;
523
524 #[test]
525 fn test_split_body_lines() {
526 let mut ex = Exchange::new(Message::new("a\nb\nc"));
527 ex.input.set_header("source", Value::String("test".into()));
528 ex.set_property("trace", Value::Bool(true));
529
530 let fragments = split_body_lines()(&ex).unwrap();
531 assert_eq!(fragments.len(), 3);
532 assert_eq!(fragments[0].input.body.as_text(), Some("a"));
533 assert_eq!(fragments[1].input.body.as_text(), Some("b"));
534 assert_eq!(fragments[2].input.body.as_text(), Some("c"));
535
536 for frag in &fragments {
538 assert_eq!(
539 frag.input.header("source"),
540 Some(&Value::String("test".into()))
541 );
542 assert_eq!(frag.property("trace"), Some(&Value::Bool(true)));
543 }
544 }
545
546 #[test]
547 fn test_split_body_lines_empty() {
548 let ex = Exchange::new(Message::default()); let fragments = split_body_lines()(&ex).unwrap();
550 assert!(fragments.is_empty());
551 }
552
553 #[test]
554 fn fragment_exchange_splits_parent_claim() {
555 let gauge = Arc::new(InFlightGauge::new());
556 let mut parent = Exchange::new(Message::new(Body::Text("a,b".into())));
557 parent.in_flight_claim = Some(InFlightClaim::attach(&gauge));
558
559 let frag = fragment_exchange(&parent, Body::Text("a".into()));
560 assert_eq!(gauge.total(), 2);
561 assert!(frag.in_flight_claim.is_some());
562 assert!(parent.in_flight_claim.is_some());
563
564 drop(frag);
565 assert_eq!(gauge.total(), 1);
566 }
567
568 #[test]
569 fn test_split_body_json_array() {
570 let arr = serde_json::json!([1, 2, 3]);
571 let ex = Exchange::new(Message::new(arr));
572
573 let fragments = split_body_json_array()(&ex).unwrap();
574 assert_eq!(fragments.len(), 3);
575 assert!(matches!(&fragments[0].input.body, Body::Json(v) if *v == serde_json::json!(1)));
576 assert!(matches!(&fragments[1].input.body, Body::Json(v) if *v == serde_json::json!(2)));
577 assert!(matches!(&fragments[2].input.body, Body::Json(v) if *v == serde_json::json!(3)));
578 }
579
580 #[test]
581 fn split_body_json_array_string_elements_become_text() {
582 let ex = Exchange::new(Message::new(serde_json::json!(["", "a", "b"])));
586
587 let fragments = split_body_json_array()(&ex).unwrap();
588 assert_eq!(fragments.len(), 3);
589 assert!(
590 matches!(&fragments[0].input.body, Body::Text(s) if s.is_empty()),
591 "fragment 0 must be Body::Text(\"\"), got {:?}",
592 fragments[0].input.body
593 );
594 assert!(matches!(&fragments[1].input.body, Body::Text(s) if s == "a"));
595 assert!(matches!(&fragments[2].input.body, Body::Text(s) if s == "b"));
596 for frag in &fragments {
599 let text = match &frag.input.body {
600 Body::Text(s) => s.as_str(),
601 other => panic!("expected Body::Text fragment, got {other:?}"),
602 };
603 assert!(
604 !text.contains('"'),
605 "fragment body must not carry a quote character, got {text:?}"
606 );
607 }
608 }
609
610 #[test]
611 fn test_split_body_json_array_non_string_elements_stay_json() {
612 let ex = Exchange::new(Message::new(serde_json::json!([1, {"k": "v"}, null])));
613
614 let fragments = split_body_json_array()(&ex).unwrap();
615 assert_eq!(fragments.len(), 3);
616 assert!(matches!(&fragments[0].input.body, Body::Json(v) if *v == serde_json::json!(1)));
617 assert!(matches!(&fragments[1].input.body, Body::Json(v)
618 if *v == serde_json::json!({"k": "v"})));
619 assert!(matches!(&fragments[2].input.body, Body::Json(v) if v.is_null()));
620 }
621
622 #[test]
623 fn test_split_body_json_array_not_array() {
624 let obj = serde_json::json!({"not": "array"});
625 let ex = Exchange::new(Message::new(obj));
626
627 let err = split_body_json_array()(&ex).unwrap_err();
628 assert!(matches!(err, CamelError::TypeConversionFailed(_)));
629 assert!(err.to_string().contains("json (non-array)"));
630 }
631
632 #[test]
633 fn test_split_body_lines_wrong_type_json_errors() {
634 let ex = Exchange::new(Message::new(serde_json::json!({"a": 1})));
635
636 let err = split_body_lines()(&ex).unwrap_err();
637 let msg = err.to_string();
638 assert!(matches!(err, CamelError::TypeConversionFailed(_)));
639 for needle in [
640 "body_lines",
641 "json",
642 "text",
643 "add an unmarshal step before split",
644 ] {
645 assert!(msg.contains(needle), "message '{msg}' missing '{needle}'");
646 }
647 }
648
649 #[test]
650 fn test_split_body_json_array_wrong_type_text_errors() {
651 let ex = Exchange::new(Message::new("x"));
652
653 let err = split_body_json_array()(&ex).unwrap_err();
654 let msg = err.to_string();
655 assert!(matches!(err, CamelError::TypeConversionFailed(_)));
656 for needle in [
657 "body_json_array",
658 "text",
659 "json (array)",
660 "add an unmarshal step before split",
661 ] {
662 assert!(msg.contains(needle), "message '{msg}' missing '{needle}'");
663 }
664 }
665
666 #[test]
667 fn test_split_body_json_array_non_array_json_errors() {
668 let ex = Exchange::new(Message::new(serde_json::json!({"o": 1})));
669
670 let err = split_body_json_array()(&ex).unwrap_err();
671 let msg = err.to_string();
672 assert!(matches!(err, CamelError::TypeConversionFailed(_)));
673 assert!(msg.contains("json (non-array)"));
674 }
675
676 #[test]
677 fn test_split_body_lines_empty_body_ok() {
678 let ex = Exchange::new(Message::default()); let fragments = split_body_lines()(&ex).unwrap();
680 assert!(fragments.is_empty());
681 }
682
683 #[test]
684 fn test_split_body_json_array_empty_body_ok() {
685 let ex = Exchange::new(Message::default()); let fragments = split_body_json_array()(&ex).unwrap();
687 assert!(fragments.is_empty());
688 }
689
690 #[test]
691 fn test_split_body_json_array_empty_array_ok() {
692 let ex = Exchange::new(Message::new(serde_json::json!([])));
693 let fragments = split_body_json_array()(&ex).unwrap();
694 assert!(fragments.is_empty());
695 }
696
697 #[test]
698 fn test_split_body_lines_empty_text_ok() {
699 let ex = Exchange::new(Message::new(""));
700 let fragments = split_body_lines()(&ex).unwrap();
701 assert!(fragments.is_empty());
702 }
703
704 #[test]
705 fn test_split_error_omits_payload() {
706 let ex = Exchange::new(Message::new(serde_json::json!({
707 "secret": "SECRET-8f31a"
708 })));
709
710 let err = split_body_lines()(&ex).unwrap_err();
711 let msg = err.to_string();
712 assert!(matches!(err, CamelError::TypeConversionFailed(_)));
713 for needle in [
714 "body_lines",
715 "json",
716 "text",
717 "add an unmarshal step before split",
718 ] {
719 assert!(msg.contains(needle), "message '{msg}' missing '{needle}'");
720 }
721 assert!(
722 !msg.contains("SECRET-8f31a"),
723 "message '{msg}' leaks payload"
724 );
725 }
726
727 #[test]
728 fn test_split_body_custom() {
729 let splitter = split_body(|body: &Body| match body {
730 Body::Text(s) => s
731 .split(',')
732 .map(|part| Body::Text(part.trim().to_string()))
733 .collect(),
734 _ => Vec::new(),
735 });
736
737 let mut ex = Exchange::new(Message::new("x, y, z"));
738 ex.set_property("id", Value::from(42));
739
740 let fragments = splitter(&ex).unwrap();
741 assert_eq!(fragments.len(), 3);
742 assert_eq!(fragments[0].input.body.as_text(), Some("x"));
743 assert_eq!(fragments[1].input.body.as_text(), Some("y"));
744 assert_eq!(fragments[2].input.body.as_text(), Some("z"));
745
746 for frag in &fragments {
748 assert_eq!(frag.property("id"), Some(&Value::from(42)));
749 }
750 }
751
752 #[test]
753 fn test_splitter_config_defaults() {
754 let config = SplitterConfig::new(split_body_lines());
755 assert!(matches!(config.aggregation, AggregationStrategy::LastWins));
756 assert!(!config.parallel);
757 assert!(config.parallel_limit.is_none());
758 assert!(config.stop_on_exception);
759 }
760
761 #[test]
762 fn test_splitter_config_builder() {
763 let config = SplitterConfig::new(split_body_lines())
764 .aggregation(AggregationStrategy::CollectAll)
765 .parallel(true)
766 .parallel_limit(4)
767 .stop_on_exception(false);
768
769 assert!(matches!(
770 config.aggregation,
771 AggregationStrategy::CollectAll
772 ));
773 assert!(config.parallel);
774 assert_eq!(config.parallel_limit, Some(4));
775 assert!(!config.stop_on_exception);
776 }
777
778 #[test]
779 fn test_splitter_config_default_max_fragments() {
780 let cfg = SplitterConfig::new(Arc::new(|_: &Exchange| Ok(Vec::new())) as SplitExpression);
781 assert_eq!(cfg.max_fragments, 100_000);
782 }
783
784 #[test]
785 fn test_splitter_config_rejects_zero_max_fragments() {
786 let cfg = SplitterConfig::new(Arc::new(|_: &Exchange| Ok(Vec::new())) as SplitExpression)
787 .max_fragments(0);
788 assert!(cfg.validate().is_err());
789 }
790
791 #[test]
792 fn test_fragment_exchange_inherits_otel_context() {
793 use opentelemetry::Context;
794 use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId};
795
796 let mut parent = Exchange::new(Message::new("test"));
798 let trace_id = TraceId::from_bytes([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 123]);
799 let span_id = SpanId::from_bytes([0, 0, 0, 0, 0, 0, 1, 200]);
800 let span_context = SpanContext::new(
801 trace_id,
802 span_id,
803 TraceFlags::SAMPLED,
804 true,
805 Default::default(),
806 );
807 let expected_trace_id = span_context.trace_id();
808 parent.otel_context = Context::current().with_remote_span_context(span_context);
809
810 let fragments = split_body_lines()(&parent).unwrap();
812 assert!(!fragments.is_empty(), "Should have at least one fragment");
813
814 for fragment in &fragments {
816 let span = fragment.otel_context.span();
817 let frag_span_ctx = span.span_context();
818 assert!(
819 frag_span_ctx.is_valid(),
820 "Fragment should have valid span context"
821 );
822 assert_eq!(
823 frag_span_ctx.trace_id(),
824 expected_trace_id,
825 "Fragment should have same trace ID as parent"
826 );
827 }
828 }
829
830 #[test]
831 fn test_stream_split_config_defaults_valid() {
832 let config = StreamSplitConfig::default();
833 assert!(config.validate().is_ok());
834 }
835
836 #[test]
837 fn test_stream_split_config_batch_size_zero_rejected() {
838 let config = StreamSplitConfig {
839 batch_size: 0,
840 ..Default::default()
841 };
842 let err = config.validate().unwrap_err();
843 assert!(err.to_string().contains("batch_size"));
844 }
845
846 #[test]
847 fn test_stream_split_config_max_record_bytes_zero_rejected() {
848 let config = StreamSplitConfig {
849 max_record_bytes: 0,
850 ..Default::default()
851 };
852 let err = config.validate().unwrap_err();
853 assert!(err.to_string().contains("max_record_bytes"));
854 }
855
856 #[test]
857 fn test_stream_split_config_chunks_requires_chunk_size() {
858 let config = StreamSplitConfig {
859 format: StreamSplitFormat::Chunks,
860 chunk_size: None,
861 ..Default::default()
862 };
863 let err = config.validate().unwrap_err();
864 assert!(err.to_string().contains("Chunks requires chunk_size"));
865 }
866
867 #[test]
868 fn test_stream_split_config_chunk_size_zero_rejected() {
869 let config = StreamSplitConfig {
870 format: StreamSplitFormat::Chunks,
871 chunk_size: Some(0),
872 ..Default::default()
873 };
874 let err = config.validate().unwrap_err();
875 assert!(err.to_string().contains("chunk_size must be > 0"));
876 }
877
878 #[test]
879 fn test_stream_split_config_chunk_size_exceeds_max_record_bytes() {
880 let config = StreamSplitConfig {
881 format: StreamSplitFormat::Chunks,
882 chunk_size: Some(2000),
883 max_record_bytes: 1000,
884 ..Default::default()
885 };
886 let err = config.validate().unwrap_err();
887 assert!(
888 err.to_string()
889 .contains("chunk_size must be <= max_record_bytes")
890 );
891 }
892
893 #[test]
894 fn test_stream_split_config_zip_rejects_chunk_size() {
895 let config = StreamSplitConfig {
896 format: StreamSplitFormat::Zip,
897 chunk_size: Some(1024),
898 ..Default::default()
899 };
900 let err = config.validate().unwrap_err();
901 assert!(err.to_string().contains("Zip does not support chunk_size"));
902 }
903
904 #[test]
905 fn test_all_fragments_share_same_trace_context() {
906 use opentelemetry::Context;
907 use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId};
908
909 let mut parent = Exchange::new(Message::new("line1\nline2\nline3"));
911 let trace_id =
912 TraceId::from_bytes([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x3B, 0x9A, 0xCA, 0x09]);
913 let span_id = SpanId::from_bytes([0, 0, 0, 0, 0, 0, 0, 111]);
914 let span_context = SpanContext::new(
915 trace_id,
916 span_id,
917 TraceFlags::SAMPLED,
918 true,
919 Default::default(),
920 );
921 parent.otel_context = Context::current().with_remote_span_context(span_context);
922
923 let fragments = split_body_lines()(&parent).unwrap();
924 assert_eq!(fragments.len(), 3);
925
926 let trace_ids: Vec<_> = fragments
928 .iter()
929 .map(|f| {
930 let span = f.otel_context.span();
931 span.span_context().trace_id()
932 })
933 .collect();
934
935 assert!(
936 trace_ids.iter().all(|&id| id == trace_id),
937 "All fragments should have the same trace ID"
938 );
939 }
940
941 #[test]
942 fn trace_item_threshold_defaults_to_100() {
943 let config = SplitterConfig::new(split_body_lines());
944 assert_eq!(config.trace_item_threshold, 100);
945 }
946
947 #[test]
948 fn trace_item_threshold_builder_sets_value() {
949 let config = SplitterConfig::new(split_body_lines()).trace_item_threshold(0);
950 assert_eq!(config.trace_item_threshold, 0);
951
952 let config = SplitterConfig::new(split_body_lines()).trace_item_threshold(7);
953 assert_eq!(config.trace_item_threshold, 7);
954 }
955
956 #[test]
957 fn canonical_split_spec_carries_trace_item_threshold() {
958 use crate::runtime::{CanonicalSplitAggregationSpec, CanonicalSplitExpressionSpec};
959
960 let with_threshold = crate::runtime::CanonicalStepSpec::Split {
961 expression: CanonicalSplitExpressionSpec::BodyLines,
962 aggregation: CanonicalSplitAggregationSpec::CollectAll,
963 parallel: false,
964 parallel_limit: None,
965 stop_on_exception: true,
966 trace_item_threshold: Some(5),
967 steps: Vec::new(),
968 };
969 let without_threshold = crate::runtime::CanonicalStepSpec::Split {
970 expression: CanonicalSplitExpressionSpec::BodyLines,
971 aggregation: CanonicalSplitAggregationSpec::CollectAll,
972 parallel: false,
973 parallel_limit: None,
974 stop_on_exception: true,
975 trace_item_threshold: None,
976 steps: Vec::new(),
977 };
978
979 let crate::runtime::CanonicalStepSpec::Split {
981 trace_item_threshold,
982 ..
983 } = &with_threshold
984 else {
985 unreachable!()
986 };
987 assert_eq!(trace_item_threshold, &Some(5));
988
989 let crate::runtime::CanonicalStepSpec::Split {
990 trace_item_threshold,
991 ..
992 } = &without_threshold
993 else {
994 unreachable!()
995 };
996 assert_eq!(trace_item_threshold, &None);
997
998 assert_ne!(with_threshold, without_threshold);
999 }
1000}