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.
8pub type AggregationFn = Arc<dyn Fn(Exchange, Exchange) -> Exchange + Send + Sync>;
9
10/// Strategy for correlating exchanges into aggregation buckets.
11pub enum CorrelationStrategy {
12    /// Correlate by the value of a named header.
13    HeaderName(String),
14    /// Correlate by evaluating an expression using a language registry.
15    Expression { expr: String, language: String },
16    /// Correlate using a custom function.
17    #[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/// How to combine collected exchanges into one.
49#[derive(Clone)]
50pub enum AggregationStrategy {
51    /// Collects all bodies into Body::Json([body1, body2, ...]).
52    CollectAll,
53    /// Left-fold: f(f(ex1, ex2), ex3), ...
54    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/// When the bucket is considered complete and should be emitted.
67#[derive(Clone)]
68pub enum CompletionCondition {
69    /// Emit when bucket reaches exactly N exchanges.
70    Size(usize),
71    /// Emit when predicate returns true for current bucket.
72    #[allow(clippy::type_complexity)]
73    Predicate(Arc<dyn Fn(&[Exchange]) -> bool + Send + Sync>),
74    /// Configuration-time counterpart to `Predicate`: carries a serialized
75    /// language expression (`expr` + `language`) resolvable via the language
76    /// registry at runtime. Unlike `Predicate` (a closure), this variant is
77    /// declarable from YAML/DSL. The serializable wire representation lives
78    /// on `CanonicalAggregateSpec.completion_predicate`.
79    PredicateExpr { expr: String, language: String },
80    /// Emit when the bucket has been inactive for the given duration.
81    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/// Determines how a bucket's completion is evaluated.
100/// `Single` wraps one condition; `Any` completes when the first condition triggers.
101#[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/// Configuration for the Aggregator EIP.
136#[derive(Clone)]
137pub struct AggregatorConfig {
138    /// Name of the header used as correlation key.
139    pub header_name: String,
140    /// When to emit the aggregated exchange.
141    pub completion: CompletionMode,
142    /// Strategy for determining correlation keys.
143    pub correlation: CorrelationStrategy,
144    /// How to combine the bucket into one exchange.
145    pub strategy: AggregationStrategy,
146    /// Maximum number of correlation key buckets (memory protection).
147    /// When limit is reached, new correlation keys are rejected.
148    pub max_buckets: Option<usize>,
149    /// Time-to-live for inactive buckets (memory protection).
150    /// Buckets not updated for this duration are evicted.
151    pub bucket_ttl: Option<Duration>,
152    /// Force-complete all pending buckets when the route is stopped.
153    pub force_completion_on_stop: bool,
154    /// Discard bucket contents on timeout instead of emitting.
155    pub discard_on_timeout: bool,
156    /// Maximum number of concurrently-live per-bucket timeout tasks (DoS cap, R3-M3).
157    /// When the cap is reached, new buckets skip the dedicated timeout spawn and
158    /// rely on `bucket_ttl` eviction (graceful degradation under a key flood).
159    /// Default 1024.
160    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    /// Start building config with correlation key extracted from the named header.
181    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            // R3-C1 Batch 1: bounded defaults — the correlation map MUST NOT
189            // grow without a cap. A flood of unique correlation keys is the
190            // remote-OOM vector; the default cap (10_000) is a sane production
191            // ceiling the operator may still override. The TTL (5 minutes) is
192            // the inline-retain + background-sweep eviction window.
193            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            // R3-M3: cap concurrently-live per-bucket timeout tasks.
198            max_timeout_tasks: 1024,
199        }
200    }
201
202    /// Validate that at least one memory-release bound is configured (R3-M2).
203    ///
204    /// At least one of `max_buckets`, a `Timeout` completion condition, or
205    /// `bucket_ttl` MUST be set, otherwise a flood of unique correlation keys
206    /// grows the bucket map without limit (remote-OOM vector).
207    ///
208    /// Additionally, when a `Timeout` completion condition is present,
209    /// `bucket_ttl` MUST also be set. The R3-M3 timeout-task cap may skip
210    /// spawning a dedicated timeout task under flood; without `bucket_ttl`
211    /// there is no fallback eviction path and the bucket leaks until shutdown.
212    /// Requiring `bucket_ttl` whenever Timeout is present makes the cap-skip
213    /// degradation safe by construction.
214    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        // R3-M3: Timeout completion requires bucket_ttl so the cap-skip
229        // degradation always has an eviction path.
230        if has_timeout && self.bucket_ttl.is_none() {
231            return Err(CamelError::from(
232                ConfigValidationError::AggregatorTimeoutRequiresTtl,
233            ));
234        }
235        Ok(())
236    }
237}
238
239/// Builder for `AggregatorConfig`.
240pub 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    /// Emit when bucket has N exchanges.
254    pub fn complete_when_size(mut self, n: usize) -> Self {
255        self.completion = Some(CompletionMode::Single(CompletionCondition::Size(n)));
256        self
257    }
258
259    /// Emit when predicate returns true for the current bucket.
260    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    /// Emit when the bucket has been inactive for the given duration.
271    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    /// Emit when the bucket reaches `size` OR has been inactive for `timeout`.
279    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    /// Enable force-completion of pending buckets when the route is stopped.
288    pub fn force_completion_on_stop(mut self, enabled: bool) -> Self {
289        self.force_completion_on_stop = enabled;
290        self
291    }
292
293    /// Discard bucket contents on timeout instead of emitting the aggregated exchange.
294    pub fn discard_on_timeout(mut self, enabled: bool) -> Self {
295        self.discard_on_timeout = enabled;
296        self
297    }
298
299    /// Override the correlation strategy with a header-based key.
300    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    /// Override the default `CollectAll` aggregation strategy.
308    pub fn strategy(mut self, strategy: AggregationStrategy) -> Self {
309        self.strategy = strategy;
310        self
311    }
312
313    /// Set the maximum number of correlation key buckets.
314    /// When the limit is reached, new correlation keys are rejected with an error.
315    pub fn max_buckets(mut self, max: usize) -> Self {
316        self.max_buckets = Some(max);
317        self
318    }
319
320    /// Set the time-to-live for inactive buckets.
321    /// Buckets that haven't been updated for this duration will be evicted.
322    pub fn bucket_ttl(mut self, ttl: Duration) -> Self {
323        self.bucket_ttl = Some(ttl);
324        self
325    }
326
327    /// Override the maximum number of concurrently-live per-bucket timeout tasks.
328    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        // R3-C1 Batch 1: a completion-bound is mandatory. A config with no
335        // completion bound lets a bucket live forever — combined with a
336        // unique-key flood, that is the remote-OOM vector. Typed
337        // ConfigValidationError (ADR-0033) — operators can match on the
338        // `AggregatorMissingCompletionBound` variant.
339        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    /// Build the config. Returns an error if no completion condition was set.
356    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        // Clone must compile (the enum derives Clone).
506        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    // ── R3-C1 Batch 1: DoS caps + completion-bound validation ────────
563
564    /// The builder default for `max_buckets` is `Some(10_000)`. The spec fixes
565    /// this as the bounded default; the operator may still override.
566    #[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    /// The builder default for `bucket_ttl` is `Some(Duration::from_secs(300))`.
576    /// Inline retain + background sweep both use this TTL.
577    #[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    /// Configs that override `max_buckets(0)` still build (the operator chose it).
587    /// The cap may be set to 1 by the operator; the bound itself is not validated.
588    /// The completion-bound check below is what Batch 1 enforces.
589    #[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    /// A config with no completion bound — neither size, nor timeout, nor predicate —
600    /// is rejected at `try_build` with `AggregatorMissingCompletionBound`. Spec §11
601    /// RESOLVED: at least one completion bound is mandatory.
602    #[test]
603    fn test_aggregator_rejects_no_completion_bound() {
604        // Builder has no `complete_*` call → try_build returns Err.
605        // Use match (not unwrap_err) because AggregatorConfig is not Debug.
606        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    // ── R3-M2: memory-bound validation ────────────────────────────────
622
623    #[test]
624    fn test_aggregator_config_rejects_no_memory_bound() {
625        // Direct construction bypassing the builder defaults — simulate a config
626        // with no max_buckets, no timeout, no ttl.
627        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        // Builder path defaults max_buckets + bucket_ttl — must validate OK.
650        let config = AggregatorConfig::correlate_by("k")
651            .complete_when_size(2)
652            .build()
653            .unwrap();
654        assert!(config.validate().is_ok());
655    }
656
657    /// R3-M3: Timeout completion requires bucket_ttl so the cap-skip
658    /// degradation always has an eviction path. A config with Timeout but
659    /// no bucket_ttl is rejected by validate().
660    #[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, // <-- missing ttl fallback
671            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}