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 Timeout(Duration),
76}
77
78impl std::fmt::Debug for CompletionCondition {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 CompletionCondition::Size(n) => f.debug_tuple("Size").field(n).finish(),
82 CompletionCondition::Predicate(_) => f.write_str("Predicate(..)"),
83 CompletionCondition::Timeout(d) => f.debug_tuple("Timeout").field(d).finish(),
84 }
85 }
86}
87
88#[derive(Clone)]
91pub enum CompletionMode {
92 Single(CompletionCondition),
93 Any(Vec<CompletionCondition>),
94}
95
96impl std::fmt::Debug for CompletionMode {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 CompletionMode::Single(c) => f.debug_tuple("Single").field(c).finish(),
100 CompletionMode::Any(conds) => f.debug_tuple("Any").field(conds).finish(),
101 }
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum CompletionReason {
107 Size,
108 Predicate,
109 Timeout,
110 Stop,
111}
112
113impl CompletionReason {
114 pub fn as_str(&self) -> &'static str {
115 match self {
116 CompletionReason::Size => "size",
117 CompletionReason::Predicate => "predicate",
118 CompletionReason::Timeout => "timeout",
119 CompletionReason::Stop => "stop",
120 }
121 }
122}
123
124#[derive(Clone)]
126pub struct AggregatorConfig {
127 pub header_name: String,
129 pub completion: CompletionMode,
131 pub correlation: CorrelationStrategy,
133 pub strategy: AggregationStrategy,
135 pub max_buckets: Option<usize>,
138 pub bucket_ttl: Option<Duration>,
141 pub force_completion_on_stop: bool,
143 pub discard_on_timeout: bool,
145 pub max_timeout_tasks: usize,
150}
151
152impl std::fmt::Debug for AggregatorConfig {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.debug_struct("AggregatorConfig")
155 .field("header_name", &self.header_name)
156 .field("completion", &self.completion)
157 .field("correlation", &self.correlation)
158 .field("strategy", &self.strategy)
159 .field("max_buckets", &self.max_buckets)
160 .field("bucket_ttl", &self.bucket_ttl)
161 .field("force_completion_on_stop", &self.force_completion_on_stop)
162 .field("discard_on_timeout", &self.discard_on_timeout)
163 .field("max_timeout_tasks", &self.max_timeout_tasks)
164 .finish()
165 }
166}
167
168impl AggregatorConfig {
169 pub fn correlate_by(header: impl Into<String>) -> AggregatorConfigBuilder {
171 let header_name = header.into();
172 AggregatorConfigBuilder {
173 header_name: header_name.clone(),
174 completion: None,
175 correlation: CorrelationStrategy::HeaderName(header_name),
176 strategy: AggregationStrategy::CollectAll,
177 max_buckets: Some(10_000),
183 bucket_ttl: Some(Duration::from_secs(300)),
184 force_completion_on_stop: false,
185 discard_on_timeout: false,
186 max_timeout_tasks: 1024,
188 }
189 }
190
191 pub fn validate(&self) -> Result<(), CamelError> {
204 let has_timeout = match &self.completion {
205 CompletionMode::Single(CompletionCondition::Timeout(_)) => true,
206 CompletionMode::Any(conds) => conds
207 .iter()
208 .any(|c| matches!(c, CompletionCondition::Timeout(_))),
209 _ => false,
210 };
211 let has_bound = self.max_buckets.is_some() || has_timeout || self.bucket_ttl.is_some();
212 if !has_bound {
213 return Err(CamelError::from(
214 ConfigValidationError::AggregatorMissingMemoryBound,
215 ));
216 }
217 if has_timeout && self.bucket_ttl.is_none() {
220 return Err(CamelError::from(
221 ConfigValidationError::AggregatorTimeoutRequiresTtl,
222 ));
223 }
224 Ok(())
225 }
226}
227
228pub struct AggregatorConfigBuilder {
230 header_name: String,
231 completion: Option<CompletionMode>,
232 correlation: CorrelationStrategy,
233 strategy: AggregationStrategy,
234 max_buckets: Option<usize>,
235 bucket_ttl: Option<Duration>,
236 force_completion_on_stop: bool,
237 discard_on_timeout: bool,
238 max_timeout_tasks: usize,
239}
240
241impl AggregatorConfigBuilder {
242 pub fn complete_when_size(mut self, n: usize) -> Self {
244 self.completion = Some(CompletionMode::Single(CompletionCondition::Size(n)));
245 self
246 }
247
248 pub fn complete_when<F>(mut self, predicate: F) -> Self
250 where
251 F: Fn(&[Exchange]) -> bool + Send + Sync + 'static,
252 {
253 self.completion = Some(CompletionMode::Single(CompletionCondition::Predicate(
254 Arc::new(predicate),
255 )));
256 self
257 }
258
259 pub fn complete_on_timeout(mut self, duration: Duration) -> Self {
261 self.completion = Some(CompletionMode::Single(CompletionCondition::Timeout(
262 duration,
263 )));
264 self
265 }
266
267 pub fn complete_on_size_or_timeout(mut self, size: usize, timeout: Duration) -> Self {
269 self.completion = Some(CompletionMode::Any(vec![
270 CompletionCondition::Size(size),
271 CompletionCondition::Timeout(timeout),
272 ]));
273 self
274 }
275
276 pub fn force_completion_on_stop(mut self, enabled: bool) -> Self {
278 self.force_completion_on_stop = enabled;
279 self
280 }
281
282 pub fn discard_on_timeout(mut self, enabled: bool) -> Self {
284 self.discard_on_timeout = enabled;
285 self
286 }
287
288 pub fn correlate_by(mut self, header: impl Into<String>) -> Self {
290 let header = header.into();
291 self.header_name = header.clone();
292 self.correlation = CorrelationStrategy::HeaderName(header);
293 self
294 }
295
296 pub fn strategy(mut self, strategy: AggregationStrategy) -> Self {
298 self.strategy = strategy;
299 self
300 }
301
302 pub fn max_buckets(mut self, max: usize) -> Self {
305 self.max_buckets = Some(max);
306 self
307 }
308
309 pub fn bucket_ttl(mut self, ttl: Duration) -> Self {
312 self.bucket_ttl = Some(ttl);
313 self
314 }
315
316 pub fn max_timeout_tasks(mut self, max: usize) -> Self {
318 self.max_timeout_tasks = max;
319 self
320 }
321
322 pub fn try_build(self) -> Result<AggregatorConfig, CamelError> {
323 let completion = self.completion.ok_or_else(|| {
329 CamelError::from(ConfigValidationError::AggregatorMissingCompletionBound)
330 })?;
331 Ok(AggregatorConfig {
332 header_name: self.header_name,
333 completion,
334 correlation: self.correlation,
335 strategy: self.strategy,
336 max_buckets: self.max_buckets,
337 bucket_ttl: self.bucket_ttl,
338 force_completion_on_stop: self.force_completion_on_stop,
339 discard_on_timeout: self.discard_on_timeout,
340 max_timeout_tasks: self.max_timeout_tasks,
341 })
342 }
343
344 pub fn build(self) -> Result<AggregatorConfig, CamelError> {
346 self.try_build()
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn test_aggregator_config_complete_when_size() {
356 let config = AggregatorConfig::correlate_by("orderId")
357 .complete_when_size(3)
358 .build()
359 .unwrap();
360 assert_eq!(config.header_name, "orderId");
361 assert!(matches!(
362 config.completion,
363 CompletionMode::Single(CompletionCondition::Size(3))
364 ));
365 assert!(matches!(config.strategy, AggregationStrategy::CollectAll));
366 }
367
368 #[test]
369 fn test_aggregator_config_complete_when_predicate() {
370 let config = AggregatorConfig::correlate_by("key")
371 .complete_when(|bucket| bucket.len() >= 2)
372 .build()
373 .unwrap();
374 assert!(matches!(
375 config.completion,
376 CompletionMode::Single(CompletionCondition::Predicate(_))
377 ));
378 }
379
380 #[test]
381 fn test_aggregator_config_custom_strategy() {
382 use std::sync::Arc;
383 let f: AggregationFn = Arc::new(|acc, _next| acc);
384 let config = AggregatorConfig::correlate_by("key")
385 .complete_when_size(1)
386 .strategy(AggregationStrategy::Custom(f))
387 .build()
388 .unwrap();
389 assert!(matches!(config.strategy, AggregationStrategy::Custom(_)));
390 }
391
392 #[test]
393 fn test_aggregator_config_missing_completion_returns_err() {
394 let result = AggregatorConfig::correlate_by("key").build();
395 let err = match result {
396 Err(e) => e,
397 Ok(_) => panic!("expected error, got Ok"),
398 };
399 assert!(
400 err.to_string().contains("completion"),
401 "error message should mention 'completion': {err}"
402 );
403 }
404
405 #[test]
406 fn test_complete_on_size_or_timeout() {
407 let config = AggregatorConfig::correlate_by("key")
408 .complete_on_size_or_timeout(3, Duration::from_secs(5))
409 .build()
410 .unwrap();
411 assert!(matches!(config.completion, CompletionMode::Any(v) if v.len() == 2));
412 }
413
414 #[test]
415 fn test_force_completion_on_stop_default() {
416 let config = AggregatorConfig::correlate_by("key")
417 .complete_when_size(1)
418 .build()
419 .unwrap();
420 assert!(!config.force_completion_on_stop);
421 assert!(!config.discard_on_timeout);
422 }
423
424 #[test]
425 fn test_builder_sets_timeout_and_flags_and_limits() {
426 let config = AggregatorConfig::correlate_by("key")
427 .complete_on_timeout(Duration::from_secs(2))
428 .max_buckets(7)
429 .bucket_ttl(Duration::from_secs(10))
430 .force_completion_on_stop(true)
431 .discard_on_timeout(true)
432 .build()
433 .unwrap();
434
435 assert!(matches!(
436 config.completion,
437 CompletionMode::Single(CompletionCondition::Timeout(d)) if d == Duration::from_secs(2)
438 ));
439 assert_eq!(config.max_buckets, Some(7));
440 assert_eq!(config.bucket_ttl, Some(Duration::from_secs(10)));
441 assert!(config.force_completion_on_stop);
442 assert!(config.discard_on_timeout);
443 }
444
445 #[test]
446 fn test_builder_correlate_by_overrides_header_and_strategy() {
447 let config = AggregatorConfig::correlate_by("original")
448 .correlate_by("override")
449 .complete_when_size(1)
450 .build()
451 .unwrap();
452
453 assert_eq!(config.header_name, "override");
454 assert!(matches!(
455 config.correlation,
456 CorrelationStrategy::HeaderName(ref h) if h == "override"
457 ));
458 }
459
460 #[test]
461 fn test_completion_reason_as_str_all_variants() {
462 assert_eq!(CompletionReason::Size.as_str(), "size");
463 assert_eq!(CompletionReason::Predicate.as_str(), "predicate");
464 assert_eq!(CompletionReason::Timeout.as_str(), "timeout");
465 assert_eq!(CompletionReason::Stop.as_str(), "stop");
466 }
467
468 #[test]
469 fn test_correlation_strategy_clone_and_debug() {
470 let strategy = CorrelationStrategy::Expression {
471 expr: "${header.orderId}".to_string(),
472 language: "simple".to_string(),
473 };
474 let cloned = strategy.clone();
475 assert!(matches!(
476 cloned,
477 CorrelationStrategy::Expression { ref expr, ref language }
478 if expr == "${header.orderId}" && language == "simple"
479 ));
480
481 let f = CorrelationStrategy::Fn(Arc::new(|_| Some("k".to_string())));
482 assert_eq!(format!("{:?}", f), "Fn(..)");
483 }
484
485 #[test]
486 fn test_complete_on_size_or_timeout_contains_both_conditions() {
487 let config = AggregatorConfig::correlate_by("k")
488 .complete_on_size_or_timeout(4, Duration::from_millis(250))
489 .build()
490 .unwrap();
491
492 match config.completion {
493 CompletionMode::Any(conditions) => {
494 assert!(matches!(conditions[0], CompletionCondition::Size(4)));
495 assert!(matches!(
496 conditions[1],
497 CompletionCondition::Timeout(d) if d == Duration::from_millis(250)
498 ));
499 }
500 _ => panic!("expected CompletionMode::Any"),
501 }
502 }
503
504 #[test]
505 #[allow(clippy::type_complexity)]
506 fn test_correlation_strategy_fn_clone_shares_same_arc() {
507 let f: Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync> =
508 Arc::new(|_| Some("shared".to_string()));
509 let strategy = CorrelationStrategy::Fn(f.clone());
510 let cloned = strategy.clone();
511
512 match cloned {
513 CorrelationStrategy::Fn(cloned_fn) => assert!(Arc::ptr_eq(&f, &cloned_fn)),
514 _ => panic!("expected fn strategy"),
515 }
516 }
517
518 #[test]
519 fn test_builder_correlate_by_overrides_previous() {
520 let config = AggregatorConfig::correlate_by("first")
521 .correlate_by("second")
522 .complete_when_size(2)
523 .build()
524 .unwrap();
525
526 assert_eq!(config.header_name, "second");
527 assert!(
528 matches!(config.correlation, CorrelationStrategy::HeaderName(ref h) if h == "second")
529 );
530 }
531
532 #[test]
533 fn test_aggregator_try_build_missing_completion_returns_error() {
534 let result = AggregatorConfig::correlate_by("key").try_build();
535 assert!(result.is_err());
536 }
537
538 #[test]
543 fn test_default_max_buckets_is_10000() {
544 let cfg = AggregatorConfig::correlate_by("k")
545 .complete_when_size(1)
546 .build()
547 .unwrap();
548 assert_eq!(cfg.max_buckets, Some(10_000));
549 }
550
551 #[test]
554 fn test_default_bucket_ttl_is_300s() {
555 let cfg = AggregatorConfig::correlate_by("k")
556 .complete_when_size(1)
557 .build()
558 .unwrap();
559 assert_eq!(cfg.bucket_ttl, Some(Duration::from_secs(300)));
560 }
561
562 #[test]
566 fn test_explicit_max_buckets_zero_is_accepted_at_build() {
567 let cfg = AggregatorConfig::correlate_by("k")
568 .complete_when_size(1)
569 .max_buckets(0)
570 .build()
571 .unwrap();
572 assert_eq!(cfg.max_buckets, Some(0));
573 }
574
575 #[test]
579 fn test_aggregator_rejects_no_completion_bound() {
580 let err = match AggregatorConfig::correlate_by("k").try_build() {
583 Err(e) => e,
584 Ok(_) => panic!("expected error, got Ok"),
585 };
586 assert!(
587 matches!(
588 err,
589 CamelError::ConfigValidation(
590 ConfigValidationError::AggregatorMissingCompletionBound
591 )
592 ),
593 "expected ConfigValidation(AggregatorMissingCompletionBound), got: {err}"
594 );
595 }
596
597 #[test]
600 fn test_aggregator_config_rejects_no_memory_bound() {
601 let config = AggregatorConfig {
604 header_name: "k".into(),
605 completion: CompletionMode::Single(CompletionCondition::Size(2)),
606 correlation: CorrelationStrategy::HeaderName("k".into()),
607 strategy: AggregationStrategy::CollectAll,
608 max_buckets: None,
609 bucket_ttl: None,
610 force_completion_on_stop: false,
611 discard_on_timeout: false,
612 max_timeout_tasks: 1024,
613 };
614 let err = config.validate().unwrap_err();
615 assert!(
616 err.to_string().contains("max_buckets")
617 || err.to_string().contains("completionTimeout")
618 || err.to_string().contains("bucket_ttl"),
619 "error should explain the required bound: {err}"
620 );
621 }
622
623 #[test]
624 fn test_aggregator_config_accepts_size_only_with_max_buckets() {
625 let config = AggregatorConfig::correlate_by("k")
627 .complete_when_size(2)
628 .build()
629 .unwrap();
630 assert!(config.validate().is_ok());
631 }
632
633 #[test]
637 fn test_aggregator_timeout_requires_bucket_ttl() {
638 let config = AggregatorConfig {
639 header_name: "k".into(),
640 completion: CompletionMode::Single(CompletionCondition::Timeout(Duration::from_secs(
641 5,
642 ))),
643 correlation: CorrelationStrategy::HeaderName("k".into()),
644 strategy: AggregationStrategy::CollectAll,
645 max_buckets: Some(100),
646 bucket_ttl: None, force_completion_on_stop: false,
648 discard_on_timeout: false,
649 max_timeout_tasks: 1024,
650 };
651 let err = config.validate().unwrap_err();
652 assert!(
653 err.to_string().contains("bucket_ttl") || err.to_string().contains("Timeout"),
654 "error should explain the timeout-requires-ttl invariant: {err}"
655 );
656 }
657}