Skip to main content

camel_api/
aggregator.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use crate::error::{CamelError, ConfigValidationError};
5use crate::exchange::Exchange;
6
7/// Aggregation function — left-fold binary: (accumulated, next) -> merged.
8///
9/// ```compile_fail
10/// use std::sync::Arc;
11/// use camel_api::aggregator::AggregationFn;
12/// use camel_api::{Exchange, CamelError};
13/// // A strategy that attempts to signal failure via Result does NOT type-check
14/// // as AggregationFn (whose Fn returns Exchange, not Result<Exchange, CamelError>).
15/// let _: AggregationFn = Arc::new(
16///     |_old: Exchange, _new: Exchange| -> Result<Exchange, CamelError> { unreachable!() }
17/// );
18/// ```
19pub type AggregationFn = Arc<dyn Fn(Exchange, Exchange) -> Exchange + Send + Sync>;
20
21/// Strategy for correlating exchanges into aggregation buckets.
22#[non_exhaustive]
23pub enum CorrelationStrategy {
24    /// Correlate by the value of a named header.
25    HeaderName(String),
26    /// Correlate by evaluating an expression using a language registry.
27    Expression { expr: String, language: String },
28    /// Correlate using a custom function.
29    #[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/// How to combine collected exchanges into one.
61#[derive(Clone)]
62#[non_exhaustive]
63pub enum AggregationStrategy {
64    /// Collects all bodies into Body::Json([body1, body2, ...]).
65    CollectAll,
66    /// Left-fold: f(f(ex1, ex2), ex3), ...
67    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/// When the bucket is considered complete and should be emitted.
80#[derive(Clone)]
81#[non_exhaustive]
82pub enum CompletionCondition {
83    /// Emit when bucket reaches exactly N exchanges.
84    Size(usize),
85    /// Emit when predicate returns true for current bucket.
86    #[allow(clippy::type_complexity)]
87    Predicate(Arc<dyn Fn(&[Exchange]) -> bool + Send + Sync>),
88    /// Configuration-time counterpart to `Predicate`: carries a serialized
89    /// language expression (`expr` + `language`) resolvable via the language
90    /// registry at runtime. Unlike `Predicate` (a closure), this variant is
91    /// declarable from YAML/DSL. The serializable wire representation lives
92    /// on `CanonicalAggregateSpec.completion_predicate`.
93    PredicateExpr { expr: String, language: String },
94    /// Emit when the bucket has been inactive for the given duration.
95    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/// Determines how a bucket's completion is evaluated.
114/// `Single` wraps one condition; `Any` completes when the first condition triggers.
115#[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/// Configuration for the Aggregator EIP.
152#[derive(Clone)]
153pub struct AggregatorConfig {
154    /// Name of the header used as correlation key.
155    pub header_name: String,
156    /// When to emit the aggregated exchange.
157    pub completion: CompletionMode,
158    /// Strategy for determining correlation keys.
159    pub correlation: CorrelationStrategy,
160    /// How to combine the bucket into one exchange.
161    pub strategy: AggregationStrategy,
162    /// Maximum number of correlation key buckets (memory protection).
163    /// When limit is reached, new correlation keys are rejected.
164    pub max_buckets: Option<usize>,
165    /// Maximum number of exchanges buffered inside ONE bucket (memory
166    /// protection, audit 2026-08-31 F6-2). `max_buckets` caps the bucket
167    /// COUNT only; without a per-bucket cap, a single hot correlation key
168    /// buffers unboundedly until completion fires. When the limit is
169    /// reached, the incoming exchange is rejected with an error.
170    /// `None` disables the cap (explicit opt-out, e.g. size-complete
171    /// configs where completion fires before the cap matters).
172    pub max_bucket_size: Option<usize>,
173    /// Time-to-live for inactive buckets (memory protection).
174    /// Buckets not updated for this duration are evicted.
175    pub bucket_ttl: Option<Duration>,
176    /// Force-complete all pending buckets when the route is stopped.
177    pub force_completion_on_stop: bool,
178    /// Discard bucket contents on timeout instead of emitting.
179    pub discard_on_timeout: bool,
180    /// Maximum number of concurrently-live per-bucket timeout tasks (DoS cap, R3-M3).
181    /// When the cap is reached, new buckets skip the dedicated timeout spawn and
182    /// rely on `bucket_ttl` eviction (graceful degradation under a key flood).
183    /// Default 1024.
184    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    /// Start building config with correlation key extracted from the named header.
206    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            // R3-C1 Batch 1: bounded defaults — the correlation map MUST NOT
214            // grow without a cap. A flood of unique correlation keys is the
215            // remote-OOM vector; the default cap (10_000) is a sane production
216            // ceiling the operator may still override. The TTL (5 minutes) is
217            // the inline-retain + background-sweep eviction window.
218            max_buckets: Some(10_000),
219            // F6-2: per-bucket accumulation bound (hot-key OOM vector).
220            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            // R3-M3: cap concurrently-live per-bucket timeout tasks.
225            max_timeout_tasks: 1024,
226        }
227    }
228
229    /// Validate that at least one memory-release bound is configured (R3-M2).
230    ///
231    /// At least one of `max_buckets`, a `Timeout` completion condition, or
232    /// `bucket_ttl` MUST be set, otherwise a flood of unique correlation keys
233    /// grows the bucket map without limit (remote-OOM vector).
234    ///
235    /// Additionally, when a `Timeout` completion condition is present,
236    /// `bucket_ttl` MUST also be set. The R3-M3 timeout-task cap may skip
237    /// spawning a dedicated timeout task under flood; without `bucket_ttl`
238    /// there is no fallback eviction path and the bucket leaks until shutdown.
239    /// Requiring `bucket_ttl` whenever Timeout is present makes the cap-skip
240    /// degradation safe by construction.
241    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        // R3-M3: Timeout completion requires bucket_ttl so the cap-skip
256        // degradation always has an eviction path.
257        if has_timeout && self.bucket_ttl.is_none() {
258            return Err(CamelError::from(
259                ConfigValidationError::AggregatorTimeoutRequiresTtl,
260            ));
261        }
262        Ok(())
263    }
264}
265
266/// Builder for `AggregatorConfig`.
267pub 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    /// Emit when bucket has N exchanges.
282    pub fn complete_when_size(mut self, n: usize) -> Self {
283        self.completion = Some(CompletionMode::Single(CompletionCondition::Size(n)));
284        self
285    }
286
287    /// Emit when predicate returns true for the current bucket.
288    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    /// Emit when the bucket has been inactive for the given duration.
299    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    /// Emit when the bucket reaches `size` OR has been inactive for `timeout`.
307    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    /// Enable force-completion of pending buckets when the route is stopped.
316    pub fn force_completion_on_stop(mut self, enabled: bool) -> Self {
317        self.force_completion_on_stop = enabled;
318        self
319    }
320
321    /// Discard bucket contents on timeout instead of emitting the aggregated exchange.
322    pub fn discard_on_timeout(mut self, enabled: bool) -> Self {
323        self.discard_on_timeout = enabled;
324        self
325    }
326
327    /// Override the correlation strategy with a header-based key.
328    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    /// Overrides the correlation strategy with an expression-based key
336    /// (leaving `header_name` untouched); runtime correlation reads
337    /// `config.correlation`.
338    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    /// Override the default `CollectAll` aggregation strategy.
351    pub fn strategy(mut self, strategy: AggregationStrategy) -> Self {
352        self.strategy = strategy;
353        self
354    }
355
356    /// Set the maximum number of correlation key buckets.
357    /// When the limit is reached, new correlation keys are rejected with an error.
358    pub fn max_buckets(mut self, max: usize) -> Self {
359        self.max_buckets = Some(max);
360        self
361    }
362
363    /// Set the maximum number of exchanges buffered inside one bucket
364    /// (F6-2). Pass a value; the incoming exchange past the limit is
365    /// rejected with an error.
366    pub fn max_bucket_size(mut self, max: usize) -> Self {
367        self.max_bucket_size = Some(max);
368        self
369    }
370
371    /// Set the time-to-live for inactive buckets.
372    /// Buckets that haven't been updated for this duration will be evicted.
373    pub fn bucket_ttl(mut self, ttl: Duration) -> Self {
374        self.bucket_ttl = Some(ttl);
375        self
376    }
377
378    /// Override the maximum number of concurrently-live per-bucket timeout tasks.
379    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        // R3-C1 Batch 1: a completion-bound is mandatory. A config with no
386        // completion bound lets a bucket live forever — combined with a
387        // unique-key flood, that is the remote-OOM vector. Typed
388        // ConfigValidationError (ADR-0033) — operators can match on the
389        // `AggregatorMissingCompletionBound` variant.
390        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    /// Build the config. Returns an error if no completion condition was set.
408    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        // Clone must compile (the enum derives Clone).
558        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    // ── R3-C1 Batch 1: DoS caps + completion-bound validation ────────
630
631    /// The builder default for `max_buckets` is `Some(10_000)`. The spec fixes
632    /// this as the bounded default; the operator may still override.
633    #[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    /// The builder default for `bucket_ttl` is `Some(Duration::from_secs(300))`.
643    /// Inline retain + background sweep both use this TTL.
644    #[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    /// Configs that override `max_buckets(0)` still build (the operator chose it).
654    /// The cap may be set to 1 by the operator; the bound itself is not validated.
655    /// The completion-bound check below is what Batch 1 enforces.
656    #[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    /// A config with no completion bound — neither size, nor timeout, nor predicate —
667    /// is rejected at `try_build` with `AggregatorMissingCompletionBound`. Spec §11
668    /// RESOLVED: at least one completion bound is mandatory.
669    #[test]
670    fn test_aggregator_rejects_no_completion_bound() {
671        // Builder has no `complete_*` call → try_build returns Err.
672        // Use match (not unwrap_err) because AggregatorConfig is not Debug.
673        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    // ── R3-M2: memory-bound validation ────────────────────────────────
689
690    #[test]
691    fn test_aggregator_config_rejects_no_memory_bound() {
692        // Direct construction bypassing the builder defaults — simulate a config
693        // with no max_buckets, no timeout, no ttl.
694        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    /// D-A5: typed-variant pin — the exact `AggregatorMissingMemoryBound`
716    /// variant, not a substring match. ADR-0033 contract: operators may match
717    /// on the typed variant for structured error handling.
718    #[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        // Builder path defaults max_buckets + bucket_ttl — must validate OK.
745        let config = AggregatorConfig::correlate_by("k")
746            .complete_when_size(2)
747            .build()
748            .unwrap();
749        assert!(config.validate().is_ok());
750    }
751
752    /// R3-M3: Timeout completion requires bucket_ttl so the cap-skip
753    /// degradation always has an eviction path. A config with Timeout but
754    /// no bucket_ttl is rejected by validate().
755    #[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, // <-- missing ttl fallback
767            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}