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>;
9
10pub enum CorrelationStrategy {
12 HeaderName(String),
14 Expression { expr: String, language: String },
16 #[allow(clippy::type_complexity)]
18 Fn(Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync>),
19}
20
21impl std::fmt::Debug for CorrelationStrategy {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 match self {
24 CorrelationStrategy::HeaderName(h) => f.debug_tuple("HeaderName").field(h).finish(),
25 CorrelationStrategy::Expression { expr, language } => f
26 .debug_struct("Expression")
27 .field("expr", expr)
28 .field("language", language)
29 .finish(),
30 CorrelationStrategy::Fn(_) => f.write_str("Fn(..)"),
31 }
32 }
33}
34
35impl Clone for CorrelationStrategy {
36 fn clone(&self) -> Self {
37 match self {
38 CorrelationStrategy::HeaderName(h) => CorrelationStrategy::HeaderName(h.clone()),
39 CorrelationStrategy::Expression { expr, language } => CorrelationStrategy::Expression {
40 expr: expr.clone(),
41 language: language.clone(),
42 },
43 CorrelationStrategy::Fn(f) => CorrelationStrategy::Fn(Arc::clone(f)),
44 }
45 }
46}
47
48#[derive(Clone)]
50pub enum AggregationStrategy {
51 CollectAll,
53 Custom(AggregationFn),
55}
56
57impl std::fmt::Debug for AggregationStrategy {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 AggregationStrategy::CollectAll => f.write_str("CollectAll"),
61 AggregationStrategy::Custom(_) => f.write_str("Custom(..)"),
62 }
63 }
64}
65
66#[derive(Clone)]
68pub enum CompletionCondition {
69 Size(usize),
71 #[allow(clippy::type_complexity)]
73 Predicate(Arc<dyn Fn(&[Exchange]) -> bool + Send + Sync>),
74 PredicateExpr { expr: String, language: String },
80 Timeout(Duration),
82}
83
84impl std::fmt::Debug for CompletionCondition {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 CompletionCondition::Size(n) => f.debug_tuple("Size").field(n).finish(),
88 CompletionCondition::Predicate(_) => f.write_str("Predicate(..)"),
89 CompletionCondition::PredicateExpr { expr, language } => f
90 .debug_struct("PredicateExpr")
91 .field("expr", expr)
92 .field("language", language)
93 .finish(),
94 CompletionCondition::Timeout(d) => f.debug_tuple("Timeout").field(d).finish(),
95 }
96 }
97}
98
99#[derive(Clone)]
102pub enum CompletionMode {
103 Single(CompletionCondition),
104 Any(Vec<CompletionCondition>),
105}
106
107impl std::fmt::Debug for CompletionMode {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 CompletionMode::Single(c) => f.debug_tuple("Single").field(c).finish(),
111 CompletionMode::Any(conds) => f.debug_tuple("Any").field(conds).finish(),
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum CompletionReason {
118 Size,
119 Predicate,
120 Timeout,
121 Stop,
122}
123
124impl CompletionReason {
125 pub fn as_str(&self) -> &'static str {
126 match self {
127 CompletionReason::Size => "size",
128 CompletionReason::Predicate => "predicate",
129 CompletionReason::Timeout => "timeout",
130 CompletionReason::Stop => "stop",
131 }
132 }
133}
134
135#[derive(Clone)]
137pub struct AggregatorConfig {
138 pub header_name: String,
140 pub completion: CompletionMode,
142 pub correlation: CorrelationStrategy,
144 pub strategy: AggregationStrategy,
146 pub max_buckets: Option<usize>,
149 pub bucket_ttl: Option<Duration>,
152 pub force_completion_on_stop: bool,
154 pub discard_on_timeout: bool,
156 pub max_timeout_tasks: usize,
161}
162
163impl std::fmt::Debug for AggregatorConfig {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 f.debug_struct("AggregatorConfig")
166 .field("header_name", &self.header_name)
167 .field("completion", &self.completion)
168 .field("correlation", &self.correlation)
169 .field("strategy", &self.strategy)
170 .field("max_buckets", &self.max_buckets)
171 .field("bucket_ttl", &self.bucket_ttl)
172 .field("force_completion_on_stop", &self.force_completion_on_stop)
173 .field("discard_on_timeout", &self.discard_on_timeout)
174 .field("max_timeout_tasks", &self.max_timeout_tasks)
175 .finish()
176 }
177}
178
179impl AggregatorConfig {
180 pub fn correlate_by(header: impl Into<String>) -> AggregatorConfigBuilder {
182 let header_name = header.into();
183 AggregatorConfigBuilder {
184 header_name: header_name.clone(),
185 completion: None,
186 correlation: CorrelationStrategy::HeaderName(header_name),
187 strategy: AggregationStrategy::CollectAll,
188 max_buckets: Some(10_000),
194 bucket_ttl: Some(Duration::from_secs(300)),
195 force_completion_on_stop: false,
196 discard_on_timeout: false,
197 max_timeout_tasks: 1024,
199 }
200 }
201
202 pub fn validate(&self) -> Result<(), CamelError> {
215 let has_timeout = match &self.completion {
216 CompletionMode::Single(CompletionCondition::Timeout(_)) => true,
217 CompletionMode::Any(conds) => conds
218 .iter()
219 .any(|c| matches!(c, CompletionCondition::Timeout(_))),
220 _ => false,
221 };
222 let has_bound = self.max_buckets.is_some() || has_timeout || self.bucket_ttl.is_some();
223 if !has_bound {
224 return Err(CamelError::from(
225 ConfigValidationError::AggregatorMissingMemoryBound,
226 ));
227 }
228 if has_timeout && self.bucket_ttl.is_none() {
231 return Err(CamelError::from(
232 ConfigValidationError::AggregatorTimeoutRequiresTtl,
233 ));
234 }
235 Ok(())
236 }
237}
238
239pub struct AggregatorConfigBuilder {
241 header_name: String,
242 completion: Option<CompletionMode>,
243 correlation: CorrelationStrategy,
244 strategy: AggregationStrategy,
245 max_buckets: Option<usize>,
246 bucket_ttl: Option<Duration>,
247 force_completion_on_stop: bool,
248 discard_on_timeout: bool,
249 max_timeout_tasks: usize,
250}
251
252impl AggregatorConfigBuilder {
253 pub fn complete_when_size(mut self, n: usize) -> Self {
255 self.completion = Some(CompletionMode::Single(CompletionCondition::Size(n)));
256 self
257 }
258
259 pub fn complete_when<F>(mut self, predicate: F) -> Self
261 where
262 F: Fn(&[Exchange]) -> bool + Send + Sync + 'static,
263 {
264 self.completion = Some(CompletionMode::Single(CompletionCondition::Predicate(
265 Arc::new(predicate),
266 )));
267 self
268 }
269
270 pub fn complete_on_timeout(mut self, duration: Duration) -> Self {
272 self.completion = Some(CompletionMode::Single(CompletionCondition::Timeout(
273 duration,
274 )));
275 self
276 }
277
278 pub fn complete_on_size_or_timeout(mut self, size: usize, timeout: Duration) -> Self {
280 self.completion = Some(CompletionMode::Any(vec![
281 CompletionCondition::Size(size),
282 CompletionCondition::Timeout(timeout),
283 ]));
284 self
285 }
286
287 pub fn force_completion_on_stop(mut self, enabled: bool) -> Self {
289 self.force_completion_on_stop = enabled;
290 self
291 }
292
293 pub fn discard_on_timeout(mut self, enabled: bool) -> Self {
295 self.discard_on_timeout = enabled;
296 self
297 }
298
299 pub fn correlate_by(mut self, header: impl Into<String>) -> Self {
301 let header = header.into();
302 self.header_name = header.clone();
303 self.correlation = CorrelationStrategy::HeaderName(header);
304 self
305 }
306
307 pub fn strategy(mut self, strategy: AggregationStrategy) -> Self {
309 self.strategy = strategy;
310 self
311 }
312
313 pub fn max_buckets(mut self, max: usize) -> Self {
316 self.max_buckets = Some(max);
317 self
318 }
319
320 pub fn bucket_ttl(mut self, ttl: Duration) -> Self {
323 self.bucket_ttl = Some(ttl);
324 self
325 }
326
327 pub fn max_timeout_tasks(mut self, max: usize) -> Self {
329 self.max_timeout_tasks = max;
330 self
331 }
332
333 pub fn try_build(self) -> Result<AggregatorConfig, CamelError> {
334 let completion = self.completion.ok_or_else(|| {
340 CamelError::from(ConfigValidationError::AggregatorMissingCompletionBound)
341 })?;
342 Ok(AggregatorConfig {
343 header_name: self.header_name,
344 completion,
345 correlation: self.correlation,
346 strategy: self.strategy,
347 max_buckets: self.max_buckets,
348 bucket_ttl: self.bucket_ttl,
349 force_completion_on_stop: self.force_completion_on_stop,
350 discard_on_timeout: self.discard_on_timeout,
351 max_timeout_tasks: self.max_timeout_tasks,
352 })
353 }
354
355 pub fn build(self) -> Result<AggregatorConfig, CamelError> {
357 self.try_build()
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn test_aggregator_config_complete_when_size() {
367 let config = AggregatorConfig::correlate_by("orderId")
368 .complete_when_size(3)
369 .build()
370 .unwrap();
371 assert_eq!(config.header_name, "orderId");
372 assert!(matches!(
373 config.completion,
374 CompletionMode::Single(CompletionCondition::Size(3))
375 ));
376 assert!(matches!(config.strategy, AggregationStrategy::CollectAll));
377 }
378
379 #[test]
380 fn test_aggregator_config_complete_when_predicate() {
381 let config = AggregatorConfig::correlate_by("key")
382 .complete_when(|bucket| bucket.len() >= 2)
383 .build()
384 .unwrap();
385 assert!(matches!(
386 config.completion,
387 CompletionMode::Single(CompletionCondition::Predicate(_))
388 ));
389 }
390
391 #[test]
392 fn test_aggregator_config_custom_strategy() {
393 use std::sync::Arc;
394 let f: AggregationFn = Arc::new(|acc, _next| acc);
395 let config = AggregatorConfig::correlate_by("key")
396 .complete_when_size(1)
397 .strategy(AggregationStrategy::Custom(f))
398 .build()
399 .unwrap();
400 assert!(matches!(config.strategy, AggregationStrategy::Custom(_)));
401 }
402
403 #[test]
404 fn test_aggregator_config_missing_completion_returns_err() {
405 let result = AggregatorConfig::correlate_by("key").build();
406 let err = match result {
407 Err(e) => e,
408 Ok(_) => panic!("expected error, got Ok"),
409 };
410 assert!(
411 err.to_string().contains("completion"),
412 "error message should mention 'completion': {err}"
413 );
414 }
415
416 #[test]
417 fn test_complete_on_size_or_timeout() {
418 let config = AggregatorConfig::correlate_by("key")
419 .complete_on_size_or_timeout(3, Duration::from_secs(5))
420 .build()
421 .unwrap();
422 assert!(matches!(config.completion, CompletionMode::Any(v) if v.len() == 2));
423 }
424
425 #[test]
426 fn test_force_completion_on_stop_default() {
427 let config = AggregatorConfig::correlate_by("key")
428 .complete_when_size(1)
429 .build()
430 .unwrap();
431 assert!(!config.force_completion_on_stop);
432 assert!(!config.discard_on_timeout);
433 }
434
435 #[test]
436 fn test_builder_sets_timeout_and_flags_and_limits() {
437 let config = AggregatorConfig::correlate_by("key")
438 .complete_on_timeout(Duration::from_secs(2))
439 .max_buckets(7)
440 .bucket_ttl(Duration::from_secs(10))
441 .force_completion_on_stop(true)
442 .discard_on_timeout(true)
443 .build()
444 .unwrap();
445
446 assert!(matches!(
447 config.completion,
448 CompletionMode::Single(CompletionCondition::Timeout(d)) if d == Duration::from_secs(2)
449 ));
450 assert_eq!(config.max_buckets, Some(7));
451 assert_eq!(config.bucket_ttl, Some(Duration::from_secs(10)));
452 assert!(config.force_completion_on_stop);
453 assert!(config.discard_on_timeout);
454 }
455
456 #[test]
457 fn test_builder_correlate_by_overrides_header_and_strategy() {
458 let config = AggregatorConfig::correlate_by("original")
459 .correlate_by("override")
460 .complete_when_size(1)
461 .build()
462 .unwrap();
463
464 assert_eq!(config.header_name, "override");
465 assert!(matches!(
466 config.correlation,
467 CorrelationStrategy::HeaderName(ref h) if h == "override"
468 ));
469 }
470
471 #[test]
472 fn test_completion_reason_as_str_all_variants() {
473 assert_eq!(CompletionReason::Size.as_str(), "size");
474 assert_eq!(CompletionReason::Predicate.as_str(), "predicate");
475 assert_eq!(CompletionReason::Timeout.as_str(), "timeout");
476 assert_eq!(CompletionReason::Stop.as_str(), "stop");
477 }
478
479 #[test]
480 fn test_correlation_strategy_clone_and_debug() {
481 let strategy = CorrelationStrategy::Expression {
482 expr: "${header.orderId}".to_string(),
483 language: "simple".to_string(),
484 };
485 let cloned = strategy.clone();
486 assert!(matches!(
487 cloned,
488 CorrelationStrategy::Expression { ref expr, ref language }
489 if expr == "${header.orderId}" && language == "simple"
490 ));
491
492 let f = CorrelationStrategy::Fn(Arc::new(|_| Some("k".to_string())));
493 assert_eq!(format!("{:?}", f), "Fn(..)");
494 }
495
496 #[test]
497 fn completion_condition_predicate_expr_debug_and_clone() {
498 let c = CompletionCondition::PredicateExpr {
499 expr: "${body} == 'DONE'".to_string(),
500 language: "simple".to_string(),
501 };
502 let debugged = format!("{:?}", c);
503 assert!(debugged.contains("PredicateExpr"), "debug: {}", debugged);
504 assert!(debugged.contains("DONE"), "debug: {}", debugged);
505 let _cloned = c.clone();
507 }
508
509 #[test]
510 fn test_complete_on_size_or_timeout_contains_both_conditions() {
511 let config = AggregatorConfig::correlate_by("k")
512 .complete_on_size_or_timeout(4, Duration::from_millis(250))
513 .build()
514 .unwrap();
515
516 match config.completion {
517 CompletionMode::Any(conditions) => {
518 assert!(matches!(conditions[0], CompletionCondition::Size(4)));
519 assert!(matches!(
520 conditions[1],
521 CompletionCondition::Timeout(d) if d == Duration::from_millis(250)
522 ));
523 }
524 _ => panic!("expected CompletionMode::Any"),
525 }
526 }
527
528 #[test]
529 #[allow(clippy::type_complexity)]
530 fn test_correlation_strategy_fn_clone_shares_same_arc() {
531 let f: Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync> =
532 Arc::new(|_| Some("shared".to_string()));
533 let strategy = CorrelationStrategy::Fn(f.clone());
534 let cloned = strategy.clone();
535
536 match cloned {
537 CorrelationStrategy::Fn(cloned_fn) => assert!(Arc::ptr_eq(&f, &cloned_fn)),
538 _ => panic!("expected fn strategy"),
539 }
540 }
541
542 #[test]
543 fn test_builder_correlate_by_overrides_previous() {
544 let config = AggregatorConfig::correlate_by("first")
545 .correlate_by("second")
546 .complete_when_size(2)
547 .build()
548 .unwrap();
549
550 assert_eq!(config.header_name, "second");
551 assert!(
552 matches!(config.correlation, CorrelationStrategy::HeaderName(ref h) if h == "second")
553 );
554 }
555
556 #[test]
557 fn test_aggregator_try_build_missing_completion_returns_error() {
558 let result = AggregatorConfig::correlate_by("key").try_build();
559 assert!(result.is_err());
560 }
561
562 #[test]
567 fn test_default_max_buckets_is_10000() {
568 let cfg = AggregatorConfig::correlate_by("k")
569 .complete_when_size(1)
570 .build()
571 .unwrap();
572 assert_eq!(cfg.max_buckets, Some(10_000));
573 }
574
575 #[test]
578 fn test_default_bucket_ttl_is_300s() {
579 let cfg = AggregatorConfig::correlate_by("k")
580 .complete_when_size(1)
581 .build()
582 .unwrap();
583 assert_eq!(cfg.bucket_ttl, Some(Duration::from_secs(300)));
584 }
585
586 #[test]
590 fn test_explicit_max_buckets_zero_is_accepted_at_build() {
591 let cfg = AggregatorConfig::correlate_by("k")
592 .complete_when_size(1)
593 .max_buckets(0)
594 .build()
595 .unwrap();
596 assert_eq!(cfg.max_buckets, Some(0));
597 }
598
599 #[test]
603 fn test_aggregator_rejects_no_completion_bound() {
604 let err = match AggregatorConfig::correlate_by("k").try_build() {
607 Err(e) => e,
608 Ok(_) => panic!("expected error, got Ok"),
609 };
610 assert!(
611 matches!(
612 err,
613 CamelError::ConfigValidation(
614 ConfigValidationError::AggregatorMissingCompletionBound
615 )
616 ),
617 "expected ConfigValidation(AggregatorMissingCompletionBound), got: {err}"
618 );
619 }
620
621 #[test]
624 fn test_aggregator_config_rejects_no_memory_bound() {
625 let config = AggregatorConfig {
628 header_name: "k".into(),
629 completion: CompletionMode::Single(CompletionCondition::Size(2)),
630 correlation: CorrelationStrategy::HeaderName("k".into()),
631 strategy: AggregationStrategy::CollectAll,
632 max_buckets: None,
633 bucket_ttl: None,
634 force_completion_on_stop: false,
635 discard_on_timeout: false,
636 max_timeout_tasks: 1024,
637 };
638 let err = config.validate().unwrap_err();
639 assert!(
640 err.to_string().contains("max_buckets")
641 || err.to_string().contains("completionTimeout")
642 || err.to_string().contains("bucket_ttl"),
643 "error should explain the required bound: {err}"
644 );
645 }
646
647 #[test]
648 fn test_aggregator_config_accepts_size_only_with_max_buckets() {
649 let config = AggregatorConfig::correlate_by("k")
651 .complete_when_size(2)
652 .build()
653 .unwrap();
654 assert!(config.validate().is_ok());
655 }
656
657 #[test]
661 fn test_aggregator_timeout_requires_bucket_ttl() {
662 let config = AggregatorConfig {
663 header_name: "k".into(),
664 completion: CompletionMode::Single(CompletionCondition::Timeout(Duration::from_secs(
665 5,
666 ))),
667 correlation: CorrelationStrategy::HeaderName("k".into()),
668 strategy: AggregationStrategy::CollectAll,
669 max_buckets: Some(100),
670 bucket_ttl: None, force_completion_on_stop: false,
672 discard_on_timeout: false,
673 max_timeout_tasks: 1024,
674 };
675 let err = config.validate().unwrap_err();
676 assert!(
677 err.to_string().contains("bucket_ttl") || err.to_string().contains("Timeout"),
678 "error should explain the timeout-requires-ttl invariant: {err}"
679 );
680 }
681}