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 bucket_ttl: Option<Duration>,
168 pub force_completion_on_stop: bool,
170 pub discard_on_timeout: bool,
172 pub max_timeout_tasks: usize,
177}
178
179impl std::fmt::Debug for AggregatorConfig {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 f.debug_struct("AggregatorConfig")
182 .field("header_name", &self.header_name)
183 .field("completion", &self.completion)
184 .field("correlation", &self.correlation)
185 .field("strategy", &self.strategy)
186 .field("max_buckets", &self.max_buckets)
187 .field("bucket_ttl", &self.bucket_ttl)
188 .field("force_completion_on_stop", &self.force_completion_on_stop)
189 .field("discard_on_timeout", &self.discard_on_timeout)
190 .field("max_timeout_tasks", &self.max_timeout_tasks)
191 .finish()
192 }
193}
194
195impl AggregatorConfig {
196 pub fn correlate_by(header: impl Into<String>) -> AggregatorConfigBuilder {
198 let header_name = header.into();
199 AggregatorConfigBuilder {
200 header_name: header_name.clone(),
201 completion: None,
202 correlation: CorrelationStrategy::HeaderName(header_name),
203 strategy: AggregationStrategy::CollectAll,
204 max_buckets: Some(10_000),
210 bucket_ttl: Some(Duration::from_secs(300)),
211 force_completion_on_stop: false,
212 discard_on_timeout: false,
213 max_timeout_tasks: 1024,
215 }
216 }
217
218 pub fn validate(&self) -> Result<(), CamelError> {
231 let has_timeout = match &self.completion {
232 CompletionMode::Single(CompletionCondition::Timeout(_)) => true,
233 CompletionMode::Any(conds) => conds
234 .iter()
235 .any(|c| matches!(c, CompletionCondition::Timeout(_))),
236 _ => false,
237 };
238 let has_bound = self.max_buckets.is_some() || has_timeout || self.bucket_ttl.is_some();
239 if !has_bound {
240 return Err(CamelError::from(
241 ConfigValidationError::AggregatorMissingMemoryBound,
242 ));
243 }
244 if has_timeout && self.bucket_ttl.is_none() {
247 return Err(CamelError::from(
248 ConfigValidationError::AggregatorTimeoutRequiresTtl,
249 ));
250 }
251 Ok(())
252 }
253}
254
255pub struct AggregatorConfigBuilder {
257 header_name: String,
258 completion: Option<CompletionMode>,
259 correlation: CorrelationStrategy,
260 strategy: AggregationStrategy,
261 max_buckets: Option<usize>,
262 bucket_ttl: Option<Duration>,
263 force_completion_on_stop: bool,
264 discard_on_timeout: bool,
265 max_timeout_tasks: usize,
266}
267
268impl AggregatorConfigBuilder {
269 pub fn complete_when_size(mut self, n: usize) -> Self {
271 self.completion = Some(CompletionMode::Single(CompletionCondition::Size(n)));
272 self
273 }
274
275 pub fn complete_when<F>(mut self, predicate: F) -> Self
277 where
278 F: Fn(&[Exchange]) -> bool + Send + Sync + 'static,
279 {
280 self.completion = Some(CompletionMode::Single(CompletionCondition::Predicate(
281 Arc::new(predicate),
282 )));
283 self
284 }
285
286 pub fn complete_on_timeout(mut self, duration: Duration) -> Self {
288 self.completion = Some(CompletionMode::Single(CompletionCondition::Timeout(
289 duration,
290 )));
291 self
292 }
293
294 pub fn complete_on_size_or_timeout(mut self, size: usize, timeout: Duration) -> Self {
296 self.completion = Some(CompletionMode::Any(vec![
297 CompletionCondition::Size(size),
298 CompletionCondition::Timeout(timeout),
299 ]));
300 self
301 }
302
303 pub fn force_completion_on_stop(mut self, enabled: bool) -> Self {
305 self.force_completion_on_stop = enabled;
306 self
307 }
308
309 pub fn discard_on_timeout(mut self, enabled: bool) -> Self {
311 self.discard_on_timeout = enabled;
312 self
313 }
314
315 pub fn correlate_by(mut self, header: impl Into<String>) -> Self {
317 let header = header.into();
318 self.header_name = header.clone();
319 self.correlation = CorrelationStrategy::HeaderName(header);
320 self
321 }
322
323 pub fn strategy(mut self, strategy: AggregationStrategy) -> Self {
325 self.strategy = strategy;
326 self
327 }
328
329 pub fn max_buckets(mut self, max: usize) -> Self {
332 self.max_buckets = Some(max);
333 self
334 }
335
336 pub fn bucket_ttl(mut self, ttl: Duration) -> Self {
339 self.bucket_ttl = Some(ttl);
340 self
341 }
342
343 pub fn max_timeout_tasks(mut self, max: usize) -> Self {
345 self.max_timeout_tasks = max;
346 self
347 }
348
349 pub fn try_build(self) -> Result<AggregatorConfig, CamelError> {
350 let completion = self.completion.ok_or_else(|| {
356 CamelError::from(ConfigValidationError::AggregatorMissingCompletionBound)
357 })?;
358 Ok(AggregatorConfig {
359 header_name: self.header_name,
360 completion,
361 correlation: self.correlation,
362 strategy: self.strategy,
363 max_buckets: self.max_buckets,
364 bucket_ttl: self.bucket_ttl,
365 force_completion_on_stop: self.force_completion_on_stop,
366 discard_on_timeout: self.discard_on_timeout,
367 max_timeout_tasks: self.max_timeout_tasks,
368 })
369 }
370
371 pub fn build(self) -> Result<AggregatorConfig, CamelError> {
373 self.try_build()
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn test_aggregator_config_complete_when_size() {
383 let config = AggregatorConfig::correlate_by("orderId")
384 .complete_when_size(3)
385 .build()
386 .unwrap();
387 assert_eq!(config.header_name, "orderId");
388 assert!(matches!(
389 config.completion,
390 CompletionMode::Single(CompletionCondition::Size(3))
391 ));
392 assert!(matches!(config.strategy, AggregationStrategy::CollectAll));
393 }
394
395 #[test]
396 fn test_aggregator_config_complete_when_predicate() {
397 let config = AggregatorConfig::correlate_by("key")
398 .complete_when(|bucket| bucket.len() >= 2)
399 .build()
400 .unwrap();
401 assert!(matches!(
402 config.completion,
403 CompletionMode::Single(CompletionCondition::Predicate(_))
404 ));
405 }
406
407 #[test]
408 fn test_aggregator_config_custom_strategy() {
409 use std::sync::Arc;
410 let f: AggregationFn = Arc::new(|acc, _next| acc);
411 let config = AggregatorConfig::correlate_by("key")
412 .complete_when_size(1)
413 .strategy(AggregationStrategy::Custom(f))
414 .build()
415 .unwrap();
416 assert!(matches!(config.strategy, AggregationStrategy::Custom(_)));
417 }
418
419 #[test]
420 fn test_aggregator_config_missing_completion_returns_err() {
421 let result = AggregatorConfig::correlate_by("key").build();
422 let err = match result {
423 Err(e) => e,
424 Ok(_) => panic!("expected error, got Ok"),
425 };
426 assert!(
427 err.to_string().contains("completion"),
428 "error message should mention 'completion': {err}"
429 );
430 }
431
432 #[test]
433 fn test_complete_on_size_or_timeout() {
434 let config = AggregatorConfig::correlate_by("key")
435 .complete_on_size_or_timeout(3, Duration::from_secs(5))
436 .build()
437 .unwrap();
438 assert!(matches!(config.completion, CompletionMode::Any(v) if v.len() == 2));
439 }
440
441 #[test]
442 fn test_force_completion_on_stop_default() {
443 let config = AggregatorConfig::correlate_by("key")
444 .complete_when_size(1)
445 .build()
446 .unwrap();
447 assert!(!config.force_completion_on_stop);
448 assert!(!config.discard_on_timeout);
449 }
450
451 #[test]
452 fn test_builder_sets_timeout_and_flags_and_limits() {
453 let config = AggregatorConfig::correlate_by("key")
454 .complete_on_timeout(Duration::from_secs(2))
455 .max_buckets(7)
456 .bucket_ttl(Duration::from_secs(10))
457 .force_completion_on_stop(true)
458 .discard_on_timeout(true)
459 .build()
460 .unwrap();
461
462 assert!(matches!(
463 config.completion,
464 CompletionMode::Single(CompletionCondition::Timeout(d)) if d == Duration::from_secs(2)
465 ));
466 assert_eq!(config.max_buckets, Some(7));
467 assert_eq!(config.bucket_ttl, Some(Duration::from_secs(10)));
468 assert!(config.force_completion_on_stop);
469 assert!(config.discard_on_timeout);
470 }
471
472 #[test]
473 fn test_builder_correlate_by_overrides_header_and_strategy() {
474 let config = AggregatorConfig::correlate_by("original")
475 .correlate_by("override")
476 .complete_when_size(1)
477 .build()
478 .unwrap();
479
480 assert_eq!(config.header_name, "override");
481 assert!(matches!(
482 config.correlation,
483 CorrelationStrategy::HeaderName(ref h) if h == "override"
484 ));
485 }
486
487 #[test]
488 fn test_completion_reason_as_str_all_variants() {
489 assert_eq!(CompletionReason::Size.as_str(), "size");
490 assert_eq!(CompletionReason::Predicate.as_str(), "predicate");
491 assert_eq!(CompletionReason::Timeout.as_str(), "timeout");
492 assert_eq!(CompletionReason::Stop.as_str(), "stop");
493 }
494
495 #[test]
496 fn test_correlation_strategy_clone_and_debug() {
497 let strategy = CorrelationStrategy::Expression {
498 expr: "${header.orderId}".to_string(),
499 language: "simple".to_string(),
500 };
501 let cloned = strategy.clone();
502 assert!(matches!(
503 cloned,
504 CorrelationStrategy::Expression { ref expr, ref language }
505 if expr == "${header.orderId}" && language == "simple"
506 ));
507
508 let f = CorrelationStrategy::Fn(Arc::new(|_| Some("k".to_string())));
509 assert_eq!(format!("{:?}", f), "Fn(..)");
510 }
511
512 #[test]
513 fn completion_condition_predicate_expr_debug_and_clone() {
514 let c = CompletionCondition::PredicateExpr {
515 expr: "${body} == 'DONE'".to_string(),
516 language: "simple".to_string(),
517 };
518 let debugged = format!("{:?}", c);
519 assert!(debugged.contains("PredicateExpr"), "debug: {}", debugged);
520 assert!(debugged.contains("DONE"), "debug: {}", debugged);
521 let _cloned = c.clone();
523 }
524
525 #[test]
526 fn test_complete_on_size_or_timeout_contains_both_conditions() {
527 let config = AggregatorConfig::correlate_by("k")
528 .complete_on_size_or_timeout(4, Duration::from_millis(250))
529 .build()
530 .unwrap();
531
532 match config.completion {
533 CompletionMode::Any(conditions) => {
534 assert!(matches!(conditions[0], CompletionCondition::Size(4)));
535 assert!(matches!(
536 conditions[1],
537 CompletionCondition::Timeout(d) if d == Duration::from_millis(250)
538 ));
539 }
540 _ => panic!("expected CompletionMode::Any"),
541 }
542 }
543
544 #[test]
545 #[allow(clippy::type_complexity)]
546 fn test_correlation_strategy_fn_clone_shares_same_arc() {
547 let f: Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync> =
548 Arc::new(|_| Some("shared".to_string()));
549 let strategy = CorrelationStrategy::Fn(f.clone());
550 let cloned = strategy.clone();
551
552 match cloned {
553 CorrelationStrategy::Fn(cloned_fn) => assert!(Arc::ptr_eq(&f, &cloned_fn)),
554 _ => panic!("expected fn strategy"),
555 }
556 }
557
558 #[test]
559 fn test_builder_correlate_by_overrides_previous() {
560 let config = AggregatorConfig::correlate_by("first")
561 .correlate_by("second")
562 .complete_when_size(2)
563 .build()
564 .unwrap();
565
566 assert_eq!(config.header_name, "second");
567 assert!(
568 matches!(config.correlation, CorrelationStrategy::HeaderName(ref h) if h == "second")
569 );
570 }
571
572 #[test]
573 fn test_aggregator_try_build_missing_completion_returns_error() {
574 let result = AggregatorConfig::correlate_by("key").try_build();
575 assert!(result.is_err());
576 }
577
578 #[test]
583 fn test_default_max_buckets_is_10000() {
584 let cfg = AggregatorConfig::correlate_by("k")
585 .complete_when_size(1)
586 .build()
587 .unwrap();
588 assert_eq!(cfg.max_buckets, Some(10_000));
589 }
590
591 #[test]
594 fn test_default_bucket_ttl_is_300s() {
595 let cfg = AggregatorConfig::correlate_by("k")
596 .complete_when_size(1)
597 .build()
598 .unwrap();
599 assert_eq!(cfg.bucket_ttl, Some(Duration::from_secs(300)));
600 }
601
602 #[test]
606 fn test_explicit_max_buckets_zero_is_accepted_at_build() {
607 let cfg = AggregatorConfig::correlate_by("k")
608 .complete_when_size(1)
609 .max_buckets(0)
610 .build()
611 .unwrap();
612 assert_eq!(cfg.max_buckets, Some(0));
613 }
614
615 #[test]
619 fn test_aggregator_rejects_no_completion_bound() {
620 let err = match AggregatorConfig::correlate_by("k").try_build() {
623 Err(e) => e,
624 Ok(_) => panic!("expected error, got Ok"),
625 };
626 assert!(
627 matches!(
628 err,
629 CamelError::ConfigValidation(
630 ConfigValidationError::AggregatorMissingCompletionBound
631 )
632 ),
633 "expected ConfigValidation(AggregatorMissingCompletionBound), got: {err}"
634 );
635 }
636
637 #[test]
640 fn test_aggregator_config_rejects_no_memory_bound() {
641 let config = AggregatorConfig {
644 header_name: "k".into(),
645 completion: CompletionMode::Single(CompletionCondition::Size(2)),
646 correlation: CorrelationStrategy::HeaderName("k".into()),
647 strategy: AggregationStrategy::CollectAll,
648 max_buckets: None,
649 bucket_ttl: None,
650 force_completion_on_stop: false,
651 discard_on_timeout: false,
652 max_timeout_tasks: 1024,
653 };
654 let err = config.validate().unwrap_err();
655 assert!(
656 err.to_string().contains("max_buckets")
657 || err.to_string().contains("completionTimeout")
658 || err.to_string().contains("bucket_ttl"),
659 "error should explain the required bound: {err}"
660 );
661 }
662
663 #[test]
667 fn test_da5_validate_returns_typed_missing_memory_bound_variant() {
668 let config = AggregatorConfig {
669 header_name: "k".into(),
670 completion: CompletionMode::Single(CompletionCondition::Size(2)),
671 correlation: CorrelationStrategy::HeaderName("k".into()),
672 strategy: AggregationStrategy::CollectAll,
673 max_buckets: None,
674 bucket_ttl: None,
675 force_completion_on_stop: false,
676 discard_on_timeout: false,
677 max_timeout_tasks: 1024,
678 };
679 let err = config.validate().unwrap_err();
680 assert!(
681 matches!(
682 err,
683 CamelError::ConfigValidation(ConfigValidationError::AggregatorMissingMemoryBound)
684 ),
685 "expected ConfigValidation(AggregatorMissingMemoryBound), got: {err}"
686 );
687 }
688
689 #[test]
690 fn test_aggregator_config_accepts_size_only_with_max_buckets() {
691 let config = AggregatorConfig::correlate_by("k")
693 .complete_when_size(2)
694 .build()
695 .unwrap();
696 assert!(config.validate().is_ok());
697 }
698
699 #[test]
703 fn test_aggregator_timeout_requires_bucket_ttl() {
704 let config = AggregatorConfig {
705 header_name: "k".into(),
706 completion: CompletionMode::Single(CompletionCondition::Timeout(Duration::from_secs(
707 5,
708 ))),
709 correlation: CorrelationStrategy::HeaderName("k".into()),
710 strategy: AggregationStrategy::CollectAll,
711 max_buckets: Some(100),
712 bucket_ttl: None, force_completion_on_stop: false,
714 discard_on_timeout: false,
715 max_timeout_tasks: 1024,
716 };
717 let err = config.validate().unwrap_err();
718 assert!(
719 err.to_string().contains("bucket_ttl") || err.to_string().contains("Timeout"),
720 "error should explain the timeout-requires-ttl invariant: {err}"
721 );
722 }
723}