1use std::sync::Arc;
2use std::time::Duration;
3
4use crate::error::{CamelError, ConfigValidationError};
5use crate::exchange::Exchange;
6
7pub type AggregationFn = Arc<dyn Fn(Exchange, Exchange) -> Exchange + Send + Sync>;
20
21#[non_exhaustive]
23pub enum CorrelationStrategy {
24 HeaderName(String),
26 Expression { expr: String, language: String },
28 #[allow(clippy::type_complexity)]
30 Fn(Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync>),
31}
32
33impl std::fmt::Debug for CorrelationStrategy {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 CorrelationStrategy::HeaderName(h) => f.debug_tuple("HeaderName").field(h).finish(),
37 CorrelationStrategy::Expression { expr, language } => f
38 .debug_struct("Expression")
39 .field("expr", expr)
40 .field("language", language)
41 .finish(),
42 CorrelationStrategy::Fn(_) => f.write_str("Fn(..)"),
43 }
44 }
45}
46
47impl Clone for CorrelationStrategy {
48 fn clone(&self) -> Self {
49 match self {
50 CorrelationStrategy::HeaderName(h) => CorrelationStrategy::HeaderName(h.clone()),
51 CorrelationStrategy::Expression { expr, language } => CorrelationStrategy::Expression {
52 expr: expr.clone(),
53 language: language.clone(),
54 },
55 CorrelationStrategy::Fn(f) => CorrelationStrategy::Fn(Arc::clone(f)),
56 }
57 }
58}
59
60#[derive(Clone)]
62#[non_exhaustive]
63pub enum AggregationStrategy {
64 CollectAll,
66 Custom(AggregationFn),
68}
69
70impl std::fmt::Debug for AggregationStrategy {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 AggregationStrategy::CollectAll => f.write_str("CollectAll"),
74 AggregationStrategy::Custom(_) => f.write_str("Custom(..)"),
75 }
76 }
77}
78
79#[derive(Clone)]
81#[non_exhaustive]
82pub enum CompletionCondition {
83 Size(usize),
85 #[allow(clippy::type_complexity)]
87 Predicate(Arc<dyn Fn(&[Exchange]) -> bool + Send + Sync>),
88 PredicateExpr { expr: String, language: String },
94 Timeout(Duration),
96}
97
98impl std::fmt::Debug for CompletionCondition {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 match self {
101 CompletionCondition::Size(n) => f.debug_tuple("Size").field(n).finish(),
102 CompletionCondition::Predicate(_) => f.write_str("Predicate(..)"),
103 CompletionCondition::PredicateExpr { expr, language } => f
104 .debug_struct("PredicateExpr")
105 .field("expr", expr)
106 .field("language", language)
107 .finish(),
108 CompletionCondition::Timeout(d) => f.debug_tuple("Timeout").field(d).finish(),
109 }
110 }
111}
112
113#[derive(Clone)]
116#[non_exhaustive]
117pub enum CompletionMode {
118 Single(CompletionCondition),
119 Any(Vec<CompletionCondition>),
120}
121
122impl std::fmt::Debug for CompletionMode {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 match self {
125 CompletionMode::Single(c) => f.debug_tuple("Single").field(c).finish(),
126 CompletionMode::Any(conds) => f.debug_tuple("Any").field(conds).finish(),
127 }
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132#[non_exhaustive]
133pub enum CompletionReason {
134 Size,
135 Predicate,
136 Timeout,
137 Stop,
138}
139
140impl CompletionReason {
141 pub fn as_str(&self) -> &'static str {
142 match self {
143 CompletionReason::Size => "size",
144 CompletionReason::Predicate => "predicate",
145 CompletionReason::Timeout => "timeout",
146 CompletionReason::Stop => "stop",
147 }
148 }
149}
150
151#[derive(Clone)]
153pub struct AggregatorConfig {
154 pub header_name: String,
156 pub completion: CompletionMode,
158 pub correlation: CorrelationStrategy,
160 pub strategy: AggregationStrategy,
162 pub max_buckets: Option<usize>,
165 pub max_bucket_size: Option<usize>,
173 pub bucket_ttl: Option<Duration>,
176 pub force_completion_on_stop: bool,
178 pub discard_on_timeout: bool,
180 pub max_timeout_tasks: usize,
185}
186
187impl std::fmt::Debug for AggregatorConfig {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 f.debug_struct("AggregatorConfig")
190 .field("header_name", &self.header_name)
191 .field("completion", &self.completion)
192 .field("correlation", &self.correlation)
193 .field("strategy", &self.strategy)
194 .field("max_buckets", &self.max_buckets)
195 .field("max_bucket_size", &self.max_bucket_size)
196 .field("bucket_ttl", &self.bucket_ttl)
197 .field("force_completion_on_stop", &self.force_completion_on_stop)
198 .field("discard_on_timeout", &self.discard_on_timeout)
199 .field("max_timeout_tasks", &self.max_timeout_tasks)
200 .finish()
201 }
202}
203
204impl AggregatorConfig {
205 pub fn correlate_by(header: impl Into<String>) -> AggregatorConfigBuilder {
207 let header_name = header.into();
208 AggregatorConfigBuilder {
209 header_name: header_name.clone(),
210 completion: None,
211 correlation: CorrelationStrategy::HeaderName(header_name),
212 strategy: AggregationStrategy::CollectAll,
213 max_buckets: Some(10_000),
219 max_bucket_size: Some(10_000),
221 bucket_ttl: Some(Duration::from_secs(300)),
222 force_completion_on_stop: false,
223 discard_on_timeout: false,
224 max_timeout_tasks: 1024,
226 }
227 }
228
229 pub fn validate(&self) -> Result<(), CamelError> {
242 let has_timeout = match &self.completion {
243 CompletionMode::Single(CompletionCondition::Timeout(_)) => true,
244 CompletionMode::Any(conds) => conds
245 .iter()
246 .any(|c| matches!(c, CompletionCondition::Timeout(_))),
247 _ => false,
248 };
249 let has_bound = self.max_buckets.is_some() || has_timeout || self.bucket_ttl.is_some();
250 if !has_bound {
251 return Err(CamelError::from(
252 ConfigValidationError::AggregatorMissingMemoryBound,
253 ));
254 }
255 if has_timeout && self.bucket_ttl.is_none() {
258 return Err(CamelError::from(
259 ConfigValidationError::AggregatorTimeoutRequiresTtl,
260 ));
261 }
262 Ok(())
263 }
264}
265
266pub struct AggregatorConfigBuilder {
268 header_name: String,
269 completion: Option<CompletionMode>,
270 correlation: CorrelationStrategy,
271 strategy: AggregationStrategy,
272 max_buckets: Option<usize>,
273 max_bucket_size: Option<usize>,
274 bucket_ttl: Option<Duration>,
275 force_completion_on_stop: bool,
276 discard_on_timeout: bool,
277 max_timeout_tasks: usize,
278}
279
280impl AggregatorConfigBuilder {
281 pub fn complete_when_size(mut self, n: usize) -> Self {
283 self.completion = Some(CompletionMode::Single(CompletionCondition::Size(n)));
284 self
285 }
286
287 pub fn complete_when<F>(mut self, predicate: F) -> Self
289 where
290 F: Fn(&[Exchange]) -> bool + Send + Sync + 'static,
291 {
292 self.completion = Some(CompletionMode::Single(CompletionCondition::Predicate(
293 Arc::new(predicate),
294 )));
295 self
296 }
297
298 pub fn complete_on_timeout(mut self, duration: Duration) -> Self {
300 self.completion = Some(CompletionMode::Single(CompletionCondition::Timeout(
301 duration,
302 )));
303 self
304 }
305
306 pub fn complete_on_size_or_timeout(mut self, size: usize, timeout: Duration) -> Self {
308 self.completion = Some(CompletionMode::Any(vec![
309 CompletionCondition::Size(size),
310 CompletionCondition::Timeout(timeout),
311 ]));
312 self
313 }
314
315 pub fn force_completion_on_stop(mut self, enabled: bool) -> Self {
317 self.force_completion_on_stop = enabled;
318 self
319 }
320
321 pub fn discard_on_timeout(mut self, enabled: bool) -> Self {
323 self.discard_on_timeout = enabled;
324 self
325 }
326
327 pub fn correlate_by(mut self, header: impl Into<String>) -> Self {
329 let header = header.into();
330 self.header_name = header.clone();
331 self.correlation = CorrelationStrategy::HeaderName(header);
332 self
333 }
334
335 pub fn correlate_by_expr(
339 mut self,
340 expr: impl Into<String>,
341 language: impl Into<String>,
342 ) -> Self {
343 self.correlation = CorrelationStrategy::Expression {
344 expr: expr.into(),
345 language: language.into(),
346 };
347 self
348 }
349
350 pub fn strategy(mut self, strategy: AggregationStrategy) -> Self {
352 self.strategy = strategy;
353 self
354 }
355
356 pub fn max_buckets(mut self, max: usize) -> Self {
359 self.max_buckets = Some(max);
360 self
361 }
362
363 pub fn max_bucket_size(mut self, max: usize) -> Self {
367 self.max_bucket_size = Some(max);
368 self
369 }
370
371 pub fn bucket_ttl(mut self, ttl: Duration) -> Self {
374 self.bucket_ttl = Some(ttl);
375 self
376 }
377
378 pub fn max_timeout_tasks(mut self, max: usize) -> Self {
380 self.max_timeout_tasks = max;
381 self
382 }
383
384 pub fn try_build(self) -> Result<AggregatorConfig, CamelError> {
385 let completion = self.completion.ok_or_else(|| {
391 CamelError::from(ConfigValidationError::AggregatorMissingCompletionBound)
392 })?;
393 Ok(AggregatorConfig {
394 header_name: self.header_name,
395 completion,
396 correlation: self.correlation,
397 strategy: self.strategy,
398 max_buckets: self.max_buckets,
399 max_bucket_size: self.max_bucket_size,
400 bucket_ttl: self.bucket_ttl,
401 force_completion_on_stop: self.force_completion_on_stop,
402 discard_on_timeout: self.discard_on_timeout,
403 max_timeout_tasks: self.max_timeout_tasks,
404 })
405 }
406
407 pub fn build(self) -> Result<AggregatorConfig, CamelError> {
409 self.try_build()
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn test_aggregator_config_complete_when_size() {
419 let config = AggregatorConfig::correlate_by("orderId")
420 .complete_when_size(3)
421 .build()
422 .unwrap();
423 assert_eq!(config.header_name, "orderId");
424 assert!(matches!(
425 config.completion,
426 CompletionMode::Single(CompletionCondition::Size(3))
427 ));
428 assert!(matches!(config.strategy, AggregationStrategy::CollectAll));
429 }
430
431 #[test]
432 fn test_aggregator_config_complete_when_predicate() {
433 let config = AggregatorConfig::correlate_by("key")
434 .complete_when(|bucket| bucket.len() >= 2)
435 .build()
436 .unwrap();
437 assert!(matches!(
438 config.completion,
439 CompletionMode::Single(CompletionCondition::Predicate(_))
440 ));
441 }
442
443 #[test]
444 fn test_aggregator_config_custom_strategy() {
445 use std::sync::Arc;
446 let f: AggregationFn = Arc::new(|acc, _next| acc);
447 let config = AggregatorConfig::correlate_by("key")
448 .complete_when_size(1)
449 .strategy(AggregationStrategy::Custom(f))
450 .build()
451 .unwrap();
452 assert!(matches!(config.strategy, AggregationStrategy::Custom(_)));
453 }
454
455 #[test]
456 fn test_aggregator_config_missing_completion_returns_err() {
457 let result = AggregatorConfig::correlate_by("key").build();
458 let err = match result {
459 Err(e) => e,
460 Ok(_) => panic!("expected error, got Ok"),
461 };
462 assert!(
463 err.to_string().contains("completion"),
464 "error message should mention 'completion': {err}"
465 );
466 }
467
468 #[test]
469 fn test_complete_on_size_or_timeout() {
470 let config = AggregatorConfig::correlate_by("key")
471 .complete_on_size_or_timeout(3, Duration::from_secs(5))
472 .build()
473 .unwrap();
474 assert!(matches!(config.completion, CompletionMode::Any(v) if v.len() == 2));
475 }
476
477 #[test]
478 fn test_force_completion_on_stop_default() {
479 let config = AggregatorConfig::correlate_by("key")
480 .complete_when_size(1)
481 .build()
482 .unwrap();
483 assert!(!config.force_completion_on_stop);
484 assert!(!config.discard_on_timeout);
485 }
486
487 #[test]
488 fn test_builder_sets_timeout_and_flags_and_limits() {
489 let config = AggregatorConfig::correlate_by("key")
490 .complete_on_timeout(Duration::from_secs(2))
491 .max_buckets(7)
492 .bucket_ttl(Duration::from_secs(10))
493 .force_completion_on_stop(true)
494 .discard_on_timeout(true)
495 .build()
496 .unwrap();
497
498 assert!(matches!(
499 config.completion,
500 CompletionMode::Single(CompletionCondition::Timeout(d)) if d == Duration::from_secs(2)
501 ));
502 assert_eq!(config.max_buckets, Some(7));
503 assert_eq!(config.bucket_ttl, Some(Duration::from_secs(10)));
504 assert!(config.force_completion_on_stop);
505 assert!(config.discard_on_timeout);
506 }
507
508 #[test]
509 fn test_builder_correlate_by_overrides_header_and_strategy() {
510 let config = AggregatorConfig::correlate_by("original")
511 .correlate_by("override")
512 .complete_when_size(1)
513 .build()
514 .unwrap();
515
516 assert_eq!(config.header_name, "override");
517 assert!(matches!(
518 config.correlation,
519 CorrelationStrategy::HeaderName(ref h) if h == "override"
520 ));
521 }
522
523 #[test]
524 fn test_completion_reason_as_str_all_variants() {
525 assert_eq!(CompletionReason::Size.as_str(), "size");
526 assert_eq!(CompletionReason::Predicate.as_str(), "predicate");
527 assert_eq!(CompletionReason::Timeout.as_str(), "timeout");
528 assert_eq!(CompletionReason::Stop.as_str(), "stop");
529 }
530
531 #[test]
532 fn test_correlation_strategy_clone_and_debug() {
533 let strategy = CorrelationStrategy::Expression {
534 expr: "${header.orderId}".to_string(),
535 language: "simple".to_string(),
536 };
537 let cloned = strategy.clone();
538 assert!(matches!(
539 cloned,
540 CorrelationStrategy::Expression { ref expr, ref language }
541 if expr == "${header.orderId}" && language == "simple"
542 ));
543
544 let f = CorrelationStrategy::Fn(Arc::new(|_| Some("k".to_string())));
545 assert_eq!(format!("{:?}", f), "Fn(..)");
546 }
547
548 #[test]
549 fn completion_condition_predicate_expr_debug_and_clone() {
550 let c = CompletionCondition::PredicateExpr {
551 expr: "${body} == 'DONE'".to_string(),
552 language: "simple".to_string(),
553 };
554 let debugged = format!("{:?}", c);
555 assert!(debugged.contains("PredicateExpr"), "debug: {}", debugged);
556 assert!(debugged.contains("DONE"), "debug: {}", debugged);
557 let _cloned = c.clone();
559 }
560
561 #[test]
562 fn test_complete_on_size_or_timeout_contains_both_conditions() {
563 let config = AggregatorConfig::correlate_by("k")
564 .complete_on_size_or_timeout(4, Duration::from_millis(250))
565 .build()
566 .unwrap();
567
568 match config.completion {
569 CompletionMode::Any(conditions) => {
570 assert!(matches!(conditions[0], CompletionCondition::Size(4)));
571 assert!(matches!(
572 conditions[1],
573 CompletionCondition::Timeout(d) if d == Duration::from_millis(250)
574 ));
575 }
576 _ => panic!("expected CompletionMode::Any"),
577 }
578 }
579
580 #[test]
581 #[allow(clippy::type_complexity)]
582 fn test_correlation_strategy_fn_clone_shares_same_arc() {
583 let f: Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync> =
584 Arc::new(|_| Some("shared".to_string()));
585 let strategy = CorrelationStrategy::Fn(f.clone());
586 let cloned = strategy.clone();
587
588 match cloned {
589 CorrelationStrategy::Fn(cloned_fn) => assert!(Arc::ptr_eq(&f, &cloned_fn)),
590 _ => panic!("expected fn strategy"),
591 }
592 }
593
594 #[test]
595 fn test_builder_correlate_by_overrides_previous() {
596 let config = AggregatorConfig::correlate_by("first")
597 .correlate_by("second")
598 .complete_when_size(2)
599 .build()
600 .unwrap();
601
602 assert_eq!(config.header_name, "second");
603 assert!(
604 matches!(config.correlation, CorrelationStrategy::HeaderName(ref h) if h == "second")
605 );
606 }
607
608 #[test]
609 fn correlate_by_expr_overrides_strategy_and_keeps_header_name() {
610 let config = AggregatorConfig::correlate_by("orderId")
611 .correlate_by_expr("${header.orderId}", "simple")
612 .complete_when_size(2)
613 .build()
614 .unwrap();
615 assert!(matches!(
616 config.correlation,
617 CorrelationStrategy::Expression { ref expr, ref language }
618 if expr == "${header.orderId}" && language == "simple"
619 ));
620 assert_eq!(config.header_name, "orderId");
621 }
622
623 #[test]
624 fn test_aggregator_try_build_missing_completion_returns_error() {
625 let result = AggregatorConfig::correlate_by("key").try_build();
626 assert!(result.is_err());
627 }
628
629 #[test]
634 fn test_default_max_buckets_is_10000() {
635 let cfg = AggregatorConfig::correlate_by("k")
636 .complete_when_size(1)
637 .build()
638 .unwrap();
639 assert_eq!(cfg.max_buckets, Some(10_000));
640 }
641
642 #[test]
645 fn test_default_bucket_ttl_is_300s() {
646 let cfg = AggregatorConfig::correlate_by("k")
647 .complete_when_size(1)
648 .build()
649 .unwrap();
650 assert_eq!(cfg.bucket_ttl, Some(Duration::from_secs(300)));
651 }
652
653 #[test]
657 fn test_explicit_max_buckets_zero_is_accepted_at_build() {
658 let cfg = AggregatorConfig::correlate_by("k")
659 .complete_when_size(1)
660 .max_buckets(0)
661 .build()
662 .unwrap();
663 assert_eq!(cfg.max_buckets, Some(0));
664 }
665
666 #[test]
670 fn test_aggregator_rejects_no_completion_bound() {
671 let err = match AggregatorConfig::correlate_by("k").try_build() {
674 Err(e) => e,
675 Ok(_) => panic!("expected error, got Ok"),
676 };
677 assert!(
678 matches!(
679 err,
680 CamelError::ConfigValidation(
681 ConfigValidationError::AggregatorMissingCompletionBound
682 )
683 ),
684 "expected ConfigValidation(AggregatorMissingCompletionBound), got: {err}"
685 );
686 }
687
688 #[test]
691 fn test_aggregator_config_rejects_no_memory_bound() {
692 let config = AggregatorConfig {
695 header_name: "k".into(),
696 completion: CompletionMode::Single(CompletionCondition::Size(2)),
697 correlation: CorrelationStrategy::HeaderName("k".into()),
698 strategy: AggregationStrategy::CollectAll,
699 max_buckets: None,
700 max_bucket_size: None,
701 bucket_ttl: None,
702 force_completion_on_stop: false,
703 discard_on_timeout: false,
704 max_timeout_tasks: 1024,
705 };
706 let err = config.validate().unwrap_err();
707 assert!(
708 err.to_string().contains("max_buckets")
709 || err.to_string().contains("completionTimeout")
710 || err.to_string().contains("bucket_ttl"),
711 "error should explain the required bound: {err}"
712 );
713 }
714
715 #[test]
719 fn test_da5_validate_returns_typed_missing_memory_bound_variant() {
720 let config = AggregatorConfig {
721 header_name: "k".into(),
722 completion: CompletionMode::Single(CompletionCondition::Size(2)),
723 correlation: CorrelationStrategy::HeaderName("k".into()),
724 strategy: AggregationStrategy::CollectAll,
725 max_buckets: None,
726 max_bucket_size: None,
727 bucket_ttl: None,
728 force_completion_on_stop: false,
729 discard_on_timeout: false,
730 max_timeout_tasks: 1024,
731 };
732 let err = config.validate().unwrap_err();
733 assert!(
734 matches!(
735 err,
736 CamelError::ConfigValidation(ConfigValidationError::AggregatorMissingMemoryBound)
737 ),
738 "expected ConfigValidation(AggregatorMissingMemoryBound), got: {err}"
739 );
740 }
741
742 #[test]
743 fn test_aggregator_config_accepts_size_only_with_max_buckets() {
744 let config = AggregatorConfig::correlate_by("k")
746 .complete_when_size(2)
747 .build()
748 .unwrap();
749 assert!(config.validate().is_ok());
750 }
751
752 #[test]
756 fn test_aggregator_timeout_requires_bucket_ttl() {
757 let config = AggregatorConfig {
758 header_name: "k".into(),
759 completion: CompletionMode::Single(CompletionCondition::Timeout(Duration::from_secs(
760 5,
761 ))),
762 correlation: CorrelationStrategy::HeaderName("k".into()),
763 strategy: AggregationStrategy::CollectAll,
764 max_buckets: Some(100),
765 max_bucket_size: None,
766 bucket_ttl: None, force_completion_on_stop: false,
768 discard_on_timeout: false,
769 max_timeout_tasks: 1024,
770 };
771 let err = config.validate().unwrap_err();
772 assert!(
773 err.to_string().contains("bucket_ttl") || err.to_string().contains("Timeout"),
774 "error should explain the timeout-requires-ttl invariant: {err}"
775 );
776 }
777}