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
21pub enum CorrelationStrategy {
23 HeaderName(String),
25 Expression { expr: String, language: String },
27 #[allow(clippy::type_complexity)]
29 Fn(Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync>),
30}
31
32impl std::fmt::Debug for CorrelationStrategy {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 CorrelationStrategy::HeaderName(h) => f.debug_tuple("HeaderName").field(h).finish(),
36 CorrelationStrategy::Expression { expr, language } => f
37 .debug_struct("Expression")
38 .field("expr", expr)
39 .field("language", language)
40 .finish(),
41 CorrelationStrategy::Fn(_) => f.write_str("Fn(..)"),
42 }
43 }
44}
45
46impl Clone for CorrelationStrategy {
47 fn clone(&self) -> Self {
48 match self {
49 CorrelationStrategy::HeaderName(h) => CorrelationStrategy::HeaderName(h.clone()),
50 CorrelationStrategy::Expression { expr, language } => CorrelationStrategy::Expression {
51 expr: expr.clone(),
52 language: language.clone(),
53 },
54 CorrelationStrategy::Fn(f) => CorrelationStrategy::Fn(Arc::clone(f)),
55 }
56 }
57}
58
59#[derive(Clone)]
61pub enum AggregationStrategy {
62 CollectAll,
64 Custom(AggregationFn),
66}
67
68impl std::fmt::Debug for AggregationStrategy {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 AggregationStrategy::CollectAll => f.write_str("CollectAll"),
72 AggregationStrategy::Custom(_) => f.write_str("Custom(..)"),
73 }
74 }
75}
76
77#[derive(Clone)]
79pub enum CompletionCondition {
80 Size(usize),
82 #[allow(clippy::type_complexity)]
84 Predicate(Arc<dyn Fn(&[Exchange]) -> bool + Send + Sync>),
85 PredicateExpr { expr: String, language: String },
91 Timeout(Duration),
93}
94
95impl std::fmt::Debug for CompletionCondition {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 CompletionCondition::Size(n) => f.debug_tuple("Size").field(n).finish(),
99 CompletionCondition::Predicate(_) => f.write_str("Predicate(..)"),
100 CompletionCondition::PredicateExpr { expr, language } => f
101 .debug_struct("PredicateExpr")
102 .field("expr", expr)
103 .field("language", language)
104 .finish(),
105 CompletionCondition::Timeout(d) => f.debug_tuple("Timeout").field(d).finish(),
106 }
107 }
108}
109
110#[derive(Clone)]
113pub enum CompletionMode {
114 Single(CompletionCondition),
115 Any(Vec<CompletionCondition>),
116}
117
118impl std::fmt::Debug for CompletionMode {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 match self {
121 CompletionMode::Single(c) => f.debug_tuple("Single").field(c).finish(),
122 CompletionMode::Any(conds) => f.debug_tuple("Any").field(conds).finish(),
123 }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum CompletionReason {
129 Size,
130 Predicate,
131 Timeout,
132 Stop,
133}
134
135impl CompletionReason {
136 pub fn as_str(&self) -> &'static str {
137 match self {
138 CompletionReason::Size => "size",
139 CompletionReason::Predicate => "predicate",
140 CompletionReason::Timeout => "timeout",
141 CompletionReason::Stop => "stop",
142 }
143 }
144}
145
146#[derive(Clone)]
148pub struct AggregatorConfig {
149 pub header_name: String,
151 pub completion: CompletionMode,
153 pub correlation: CorrelationStrategy,
155 pub strategy: AggregationStrategy,
157 pub max_buckets: Option<usize>,
160 pub bucket_ttl: Option<Duration>,
163 pub force_completion_on_stop: bool,
165 pub discard_on_timeout: bool,
167 pub max_timeout_tasks: usize,
172}
173
174impl std::fmt::Debug for AggregatorConfig {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 f.debug_struct("AggregatorConfig")
177 .field("header_name", &self.header_name)
178 .field("completion", &self.completion)
179 .field("correlation", &self.correlation)
180 .field("strategy", &self.strategy)
181 .field("max_buckets", &self.max_buckets)
182 .field("bucket_ttl", &self.bucket_ttl)
183 .field("force_completion_on_stop", &self.force_completion_on_stop)
184 .field("discard_on_timeout", &self.discard_on_timeout)
185 .field("max_timeout_tasks", &self.max_timeout_tasks)
186 .finish()
187 }
188}
189
190impl AggregatorConfig {
191 pub fn correlate_by(header: impl Into<String>) -> AggregatorConfigBuilder {
193 let header_name = header.into();
194 AggregatorConfigBuilder {
195 header_name: header_name.clone(),
196 completion: None,
197 correlation: CorrelationStrategy::HeaderName(header_name),
198 strategy: AggregationStrategy::CollectAll,
199 max_buckets: Some(10_000),
205 bucket_ttl: Some(Duration::from_secs(300)),
206 force_completion_on_stop: false,
207 discard_on_timeout: false,
208 max_timeout_tasks: 1024,
210 }
211 }
212
213 pub fn validate(&self) -> Result<(), CamelError> {
226 let has_timeout = match &self.completion {
227 CompletionMode::Single(CompletionCondition::Timeout(_)) => true,
228 CompletionMode::Any(conds) => conds
229 .iter()
230 .any(|c| matches!(c, CompletionCondition::Timeout(_))),
231 _ => false,
232 };
233 let has_bound = self.max_buckets.is_some() || has_timeout || self.bucket_ttl.is_some();
234 if !has_bound {
235 return Err(CamelError::from(
236 ConfigValidationError::AggregatorMissingMemoryBound,
237 ));
238 }
239 if has_timeout && self.bucket_ttl.is_none() {
242 return Err(CamelError::from(
243 ConfigValidationError::AggregatorTimeoutRequiresTtl,
244 ));
245 }
246 Ok(())
247 }
248}
249
250pub struct AggregatorConfigBuilder {
252 header_name: String,
253 completion: Option<CompletionMode>,
254 correlation: CorrelationStrategy,
255 strategy: AggregationStrategy,
256 max_buckets: Option<usize>,
257 bucket_ttl: Option<Duration>,
258 force_completion_on_stop: bool,
259 discard_on_timeout: bool,
260 max_timeout_tasks: usize,
261}
262
263impl AggregatorConfigBuilder {
264 pub fn complete_when_size(mut self, n: usize) -> Self {
266 self.completion = Some(CompletionMode::Single(CompletionCondition::Size(n)));
267 self
268 }
269
270 pub fn complete_when<F>(mut self, predicate: F) -> Self
272 where
273 F: Fn(&[Exchange]) -> bool + Send + Sync + 'static,
274 {
275 self.completion = Some(CompletionMode::Single(CompletionCondition::Predicate(
276 Arc::new(predicate),
277 )));
278 self
279 }
280
281 pub fn complete_on_timeout(mut self, duration: Duration) -> Self {
283 self.completion = Some(CompletionMode::Single(CompletionCondition::Timeout(
284 duration,
285 )));
286 self
287 }
288
289 pub fn complete_on_size_or_timeout(mut self, size: usize, timeout: Duration) -> Self {
291 self.completion = Some(CompletionMode::Any(vec![
292 CompletionCondition::Size(size),
293 CompletionCondition::Timeout(timeout),
294 ]));
295 self
296 }
297
298 pub fn force_completion_on_stop(mut self, enabled: bool) -> Self {
300 self.force_completion_on_stop = enabled;
301 self
302 }
303
304 pub fn discard_on_timeout(mut self, enabled: bool) -> Self {
306 self.discard_on_timeout = enabled;
307 self
308 }
309
310 pub fn correlate_by(mut self, header: impl Into<String>) -> Self {
312 let header = header.into();
313 self.header_name = header.clone();
314 self.correlation = CorrelationStrategy::HeaderName(header);
315 self
316 }
317
318 pub fn strategy(mut self, strategy: AggregationStrategy) -> Self {
320 self.strategy = strategy;
321 self
322 }
323
324 pub fn max_buckets(mut self, max: usize) -> Self {
327 self.max_buckets = Some(max);
328 self
329 }
330
331 pub fn bucket_ttl(mut self, ttl: Duration) -> Self {
334 self.bucket_ttl = Some(ttl);
335 self
336 }
337
338 pub fn max_timeout_tasks(mut self, max: usize) -> Self {
340 self.max_timeout_tasks = max;
341 self
342 }
343
344 pub fn try_build(self) -> Result<AggregatorConfig, CamelError> {
345 let completion = self.completion.ok_or_else(|| {
351 CamelError::from(ConfigValidationError::AggregatorMissingCompletionBound)
352 })?;
353 Ok(AggregatorConfig {
354 header_name: self.header_name,
355 completion,
356 correlation: self.correlation,
357 strategy: self.strategy,
358 max_buckets: self.max_buckets,
359 bucket_ttl: self.bucket_ttl,
360 force_completion_on_stop: self.force_completion_on_stop,
361 discard_on_timeout: self.discard_on_timeout,
362 max_timeout_tasks: self.max_timeout_tasks,
363 })
364 }
365
366 pub fn build(self) -> Result<AggregatorConfig, CamelError> {
368 self.try_build()
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn test_aggregator_config_complete_when_size() {
378 let config = AggregatorConfig::correlate_by("orderId")
379 .complete_when_size(3)
380 .build()
381 .unwrap();
382 assert_eq!(config.header_name, "orderId");
383 assert!(matches!(
384 config.completion,
385 CompletionMode::Single(CompletionCondition::Size(3))
386 ));
387 assert!(matches!(config.strategy, AggregationStrategy::CollectAll));
388 }
389
390 #[test]
391 fn test_aggregator_config_complete_when_predicate() {
392 let config = AggregatorConfig::correlate_by("key")
393 .complete_when(|bucket| bucket.len() >= 2)
394 .build()
395 .unwrap();
396 assert!(matches!(
397 config.completion,
398 CompletionMode::Single(CompletionCondition::Predicate(_))
399 ));
400 }
401
402 #[test]
403 fn test_aggregator_config_custom_strategy() {
404 use std::sync::Arc;
405 let f: AggregationFn = Arc::new(|acc, _next| acc);
406 let config = AggregatorConfig::correlate_by("key")
407 .complete_when_size(1)
408 .strategy(AggregationStrategy::Custom(f))
409 .build()
410 .unwrap();
411 assert!(matches!(config.strategy, AggregationStrategy::Custom(_)));
412 }
413
414 #[test]
415 fn test_aggregator_config_missing_completion_returns_err() {
416 let result = AggregatorConfig::correlate_by("key").build();
417 let err = match result {
418 Err(e) => e,
419 Ok(_) => panic!("expected error, got Ok"),
420 };
421 assert!(
422 err.to_string().contains("completion"),
423 "error message should mention 'completion': {err}"
424 );
425 }
426
427 #[test]
428 fn test_complete_on_size_or_timeout() {
429 let config = AggregatorConfig::correlate_by("key")
430 .complete_on_size_or_timeout(3, Duration::from_secs(5))
431 .build()
432 .unwrap();
433 assert!(matches!(config.completion, CompletionMode::Any(v) if v.len() == 2));
434 }
435
436 #[test]
437 fn test_force_completion_on_stop_default() {
438 let config = AggregatorConfig::correlate_by("key")
439 .complete_when_size(1)
440 .build()
441 .unwrap();
442 assert!(!config.force_completion_on_stop);
443 assert!(!config.discard_on_timeout);
444 }
445
446 #[test]
447 fn test_builder_sets_timeout_and_flags_and_limits() {
448 let config = AggregatorConfig::correlate_by("key")
449 .complete_on_timeout(Duration::from_secs(2))
450 .max_buckets(7)
451 .bucket_ttl(Duration::from_secs(10))
452 .force_completion_on_stop(true)
453 .discard_on_timeout(true)
454 .build()
455 .unwrap();
456
457 assert!(matches!(
458 config.completion,
459 CompletionMode::Single(CompletionCondition::Timeout(d)) if d == Duration::from_secs(2)
460 ));
461 assert_eq!(config.max_buckets, Some(7));
462 assert_eq!(config.bucket_ttl, Some(Duration::from_secs(10)));
463 assert!(config.force_completion_on_stop);
464 assert!(config.discard_on_timeout);
465 }
466
467 #[test]
468 fn test_builder_correlate_by_overrides_header_and_strategy() {
469 let config = AggregatorConfig::correlate_by("original")
470 .correlate_by("override")
471 .complete_when_size(1)
472 .build()
473 .unwrap();
474
475 assert_eq!(config.header_name, "override");
476 assert!(matches!(
477 config.correlation,
478 CorrelationStrategy::HeaderName(ref h) if h == "override"
479 ));
480 }
481
482 #[test]
483 fn test_completion_reason_as_str_all_variants() {
484 assert_eq!(CompletionReason::Size.as_str(), "size");
485 assert_eq!(CompletionReason::Predicate.as_str(), "predicate");
486 assert_eq!(CompletionReason::Timeout.as_str(), "timeout");
487 assert_eq!(CompletionReason::Stop.as_str(), "stop");
488 }
489
490 #[test]
491 fn test_correlation_strategy_clone_and_debug() {
492 let strategy = CorrelationStrategy::Expression {
493 expr: "${header.orderId}".to_string(),
494 language: "simple".to_string(),
495 };
496 let cloned = strategy.clone();
497 assert!(matches!(
498 cloned,
499 CorrelationStrategy::Expression { ref expr, ref language }
500 if expr == "${header.orderId}" && language == "simple"
501 ));
502
503 let f = CorrelationStrategy::Fn(Arc::new(|_| Some("k".to_string())));
504 assert_eq!(format!("{:?}", f), "Fn(..)");
505 }
506
507 #[test]
508 fn completion_condition_predicate_expr_debug_and_clone() {
509 let c = CompletionCondition::PredicateExpr {
510 expr: "${body} == 'DONE'".to_string(),
511 language: "simple".to_string(),
512 };
513 let debugged = format!("{:?}", c);
514 assert!(debugged.contains("PredicateExpr"), "debug: {}", debugged);
515 assert!(debugged.contains("DONE"), "debug: {}", debugged);
516 let _cloned = c.clone();
518 }
519
520 #[test]
521 fn test_complete_on_size_or_timeout_contains_both_conditions() {
522 let config = AggregatorConfig::correlate_by("k")
523 .complete_on_size_or_timeout(4, Duration::from_millis(250))
524 .build()
525 .unwrap();
526
527 match config.completion {
528 CompletionMode::Any(conditions) => {
529 assert!(matches!(conditions[0], CompletionCondition::Size(4)));
530 assert!(matches!(
531 conditions[1],
532 CompletionCondition::Timeout(d) if d == Duration::from_millis(250)
533 ));
534 }
535 _ => panic!("expected CompletionMode::Any"),
536 }
537 }
538
539 #[test]
540 #[allow(clippy::type_complexity)]
541 fn test_correlation_strategy_fn_clone_shares_same_arc() {
542 let f: Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync> =
543 Arc::new(|_| Some("shared".to_string()));
544 let strategy = CorrelationStrategy::Fn(f.clone());
545 let cloned = strategy.clone();
546
547 match cloned {
548 CorrelationStrategy::Fn(cloned_fn) => assert!(Arc::ptr_eq(&f, &cloned_fn)),
549 _ => panic!("expected fn strategy"),
550 }
551 }
552
553 #[test]
554 fn test_builder_correlate_by_overrides_previous() {
555 let config = AggregatorConfig::correlate_by("first")
556 .correlate_by("second")
557 .complete_when_size(2)
558 .build()
559 .unwrap();
560
561 assert_eq!(config.header_name, "second");
562 assert!(
563 matches!(config.correlation, CorrelationStrategy::HeaderName(ref h) if h == "second")
564 );
565 }
566
567 #[test]
568 fn test_aggregator_try_build_missing_completion_returns_error() {
569 let result = AggregatorConfig::correlate_by("key").try_build();
570 assert!(result.is_err());
571 }
572
573 #[test]
578 fn test_default_max_buckets_is_10000() {
579 let cfg = AggregatorConfig::correlate_by("k")
580 .complete_when_size(1)
581 .build()
582 .unwrap();
583 assert_eq!(cfg.max_buckets, Some(10_000));
584 }
585
586 #[test]
589 fn test_default_bucket_ttl_is_300s() {
590 let cfg = AggregatorConfig::correlate_by("k")
591 .complete_when_size(1)
592 .build()
593 .unwrap();
594 assert_eq!(cfg.bucket_ttl, Some(Duration::from_secs(300)));
595 }
596
597 #[test]
601 fn test_explicit_max_buckets_zero_is_accepted_at_build() {
602 let cfg = AggregatorConfig::correlate_by("k")
603 .complete_when_size(1)
604 .max_buckets(0)
605 .build()
606 .unwrap();
607 assert_eq!(cfg.max_buckets, Some(0));
608 }
609
610 #[test]
614 fn test_aggregator_rejects_no_completion_bound() {
615 let err = match AggregatorConfig::correlate_by("k").try_build() {
618 Err(e) => e,
619 Ok(_) => panic!("expected error, got Ok"),
620 };
621 assert!(
622 matches!(
623 err,
624 CamelError::ConfigValidation(
625 ConfigValidationError::AggregatorMissingCompletionBound
626 )
627 ),
628 "expected ConfigValidation(AggregatorMissingCompletionBound), got: {err}"
629 );
630 }
631
632 #[test]
635 fn test_aggregator_config_rejects_no_memory_bound() {
636 let config = AggregatorConfig {
639 header_name: "k".into(),
640 completion: CompletionMode::Single(CompletionCondition::Size(2)),
641 correlation: CorrelationStrategy::HeaderName("k".into()),
642 strategy: AggregationStrategy::CollectAll,
643 max_buckets: None,
644 bucket_ttl: None,
645 force_completion_on_stop: false,
646 discard_on_timeout: false,
647 max_timeout_tasks: 1024,
648 };
649 let err = config.validate().unwrap_err();
650 assert!(
651 err.to_string().contains("max_buckets")
652 || err.to_string().contains("completionTimeout")
653 || err.to_string().contains("bucket_ttl"),
654 "error should explain the required bound: {err}"
655 );
656 }
657
658 #[test]
662 fn test_da5_validate_returns_typed_missing_memory_bound_variant() {
663 let config = AggregatorConfig {
664 header_name: "k".into(),
665 completion: CompletionMode::Single(CompletionCondition::Size(2)),
666 correlation: CorrelationStrategy::HeaderName("k".into()),
667 strategy: AggregationStrategy::CollectAll,
668 max_buckets: None,
669 bucket_ttl: None,
670 force_completion_on_stop: false,
671 discard_on_timeout: false,
672 max_timeout_tasks: 1024,
673 };
674 let err = config.validate().unwrap_err();
675 assert!(
676 matches!(
677 err,
678 CamelError::ConfigValidation(ConfigValidationError::AggregatorMissingMemoryBound)
679 ),
680 "expected ConfigValidation(AggregatorMissingMemoryBound), got: {err}"
681 );
682 }
683
684 #[test]
685 fn test_aggregator_config_accepts_size_only_with_max_buckets() {
686 let config = AggregatorConfig::correlate_by("k")
688 .complete_when_size(2)
689 .build()
690 .unwrap();
691 assert!(config.validate().is_ok());
692 }
693
694 #[test]
698 fn test_aggregator_timeout_requires_bucket_ttl() {
699 let config = AggregatorConfig {
700 header_name: "k".into(),
701 completion: CompletionMode::Single(CompletionCondition::Timeout(Duration::from_secs(
702 5,
703 ))),
704 correlation: CorrelationStrategy::HeaderName("k".into()),
705 strategy: AggregationStrategy::CollectAll,
706 max_buckets: Some(100),
707 bucket_ttl: None, force_completion_on_stop: false,
709 discard_on_timeout: false,
710 max_timeout_tasks: 1024,
711 };
712 let err = config.validate().unwrap_err();
713 assert!(
714 err.to_string().contains("bucket_ttl") || err.to_string().contains("Timeout"),
715 "error should explain the timeout-requires-ttl invariant: {err}"
716 );
717 }
718}