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.
22pub enum CorrelationStrategy {
23    /// Correlate by the value of a named header.
24    HeaderName(String),
25    /// Correlate by evaluating an expression using a language registry.
26    Expression { expr: String, language: String },
27    /// Correlate using a custom function.
28    #[allow(clippy::type_complexity)]
29    Fn(Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync>),
30}
31
32impl std::fmt::Debug for CorrelationStrategy {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            CorrelationStrategy::HeaderName(h) => f.debug_tuple("HeaderName").field(h).finish(),
36            CorrelationStrategy::Expression { expr, language } => f
37                .debug_struct("Expression")
38                .field("expr", expr)
39                .field("language", language)
40                .finish(),
41            CorrelationStrategy::Fn(_) => f.write_str("Fn(..)"),
42        }
43    }
44}
45
46impl Clone for CorrelationStrategy {
47    fn clone(&self) -> Self {
48        match self {
49            CorrelationStrategy::HeaderName(h) => CorrelationStrategy::HeaderName(h.clone()),
50            CorrelationStrategy::Expression { expr, language } => CorrelationStrategy::Expression {
51                expr: expr.clone(),
52                language: language.clone(),
53            },
54            CorrelationStrategy::Fn(f) => CorrelationStrategy::Fn(Arc::clone(f)),
55        }
56    }
57}
58
59/// How to combine collected exchanges into one.
60#[derive(Clone)]
61pub enum AggregationStrategy {
62    /// Collects all bodies into Body::Json([body1, body2, ...]).
63    CollectAll,
64    /// Left-fold: f(f(ex1, ex2), ex3), ...
65    Custom(AggregationFn),
66}
67
68impl std::fmt::Debug for AggregationStrategy {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            AggregationStrategy::CollectAll => f.write_str("CollectAll"),
72            AggregationStrategy::Custom(_) => f.write_str("Custom(..)"),
73        }
74    }
75}
76
77/// When the bucket is considered complete and should be emitted.
78#[derive(Clone)]
79pub enum CompletionCondition {
80    /// Emit when bucket reaches exactly N exchanges.
81    Size(usize),
82    /// Emit when predicate returns true for current bucket.
83    #[allow(clippy::type_complexity)]
84    Predicate(Arc<dyn Fn(&[Exchange]) -> bool + Send + Sync>),
85    /// Configuration-time counterpart to `Predicate`: carries a serialized
86    /// language expression (`expr` + `language`) resolvable via the language
87    /// registry at runtime. Unlike `Predicate` (a closure), this variant is
88    /// declarable from YAML/DSL. The serializable wire representation lives
89    /// on `CanonicalAggregateSpec.completion_predicate`.
90    PredicateExpr { expr: String, language: String },
91    /// Emit when the bucket has been inactive for the given duration.
92    Timeout(Duration),
93}
94
95impl std::fmt::Debug for CompletionCondition {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            CompletionCondition::Size(n) => f.debug_tuple("Size").field(n).finish(),
99            CompletionCondition::Predicate(_) => f.write_str("Predicate(..)"),
100            CompletionCondition::PredicateExpr { expr, language } => f
101                .debug_struct("PredicateExpr")
102                .field("expr", expr)
103                .field("language", language)
104                .finish(),
105            CompletionCondition::Timeout(d) => f.debug_tuple("Timeout").field(d).finish(),
106        }
107    }
108}
109
110/// Determines how a bucket's completion is evaluated.
111/// `Single` wraps one condition; `Any` completes when the first condition triggers.
112#[derive(Clone)]
113pub enum CompletionMode {
114    Single(CompletionCondition),
115    Any(Vec<CompletionCondition>),
116}
117
118impl std::fmt::Debug for CompletionMode {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        match self {
121            CompletionMode::Single(c) => f.debug_tuple("Single").field(c).finish(),
122            CompletionMode::Any(conds) => f.debug_tuple("Any").field(conds).finish(),
123        }
124    }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum CompletionReason {
129    Size,
130    Predicate,
131    Timeout,
132    Stop,
133}
134
135impl CompletionReason {
136    pub fn as_str(&self) -> &'static str {
137        match self {
138            CompletionReason::Size => "size",
139            CompletionReason::Predicate => "predicate",
140            CompletionReason::Timeout => "timeout",
141            CompletionReason::Stop => "stop",
142        }
143    }
144}
145
146/// Configuration for the Aggregator EIP.
147#[derive(Clone)]
148pub struct AggregatorConfig {
149    /// Name of the header used as correlation key.
150    pub header_name: String,
151    /// When to emit the aggregated exchange.
152    pub completion: CompletionMode,
153    /// Strategy for determining correlation keys.
154    pub correlation: CorrelationStrategy,
155    /// How to combine the bucket into one exchange.
156    pub strategy: AggregationStrategy,
157    /// Maximum number of correlation key buckets (memory protection).
158    /// When limit is reached, new correlation keys are rejected.
159    pub max_buckets: Option<usize>,
160    /// Time-to-live for inactive buckets (memory protection).
161    /// Buckets not updated for this duration are evicted.
162    pub bucket_ttl: Option<Duration>,
163    /// Force-complete all pending buckets when the route is stopped.
164    pub force_completion_on_stop: bool,
165    /// Discard bucket contents on timeout instead of emitting.
166    pub discard_on_timeout: bool,
167    /// Maximum number of concurrently-live per-bucket timeout tasks (DoS cap, R3-M3).
168    /// When the cap is reached, new buckets skip the dedicated timeout spawn and
169    /// rely on `bucket_ttl` eviction (graceful degradation under a key flood).
170    /// Default 1024.
171    pub max_timeout_tasks: usize,
172}
173
174impl std::fmt::Debug for AggregatorConfig {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        f.debug_struct("AggregatorConfig")
177            .field("header_name", &self.header_name)
178            .field("completion", &self.completion)
179            .field("correlation", &self.correlation)
180            .field("strategy", &self.strategy)
181            .field("max_buckets", &self.max_buckets)
182            .field("bucket_ttl", &self.bucket_ttl)
183            .field("force_completion_on_stop", &self.force_completion_on_stop)
184            .field("discard_on_timeout", &self.discard_on_timeout)
185            .field("max_timeout_tasks", &self.max_timeout_tasks)
186            .finish()
187    }
188}
189
190impl AggregatorConfig {
191    /// Start building config with correlation key extracted from the named header.
192    pub fn correlate_by(header: impl Into<String>) -> AggregatorConfigBuilder {
193        let header_name = header.into();
194        AggregatorConfigBuilder {
195            header_name: header_name.clone(),
196            completion: None,
197            correlation: CorrelationStrategy::HeaderName(header_name),
198            strategy: AggregationStrategy::CollectAll,
199            // R3-C1 Batch 1: bounded defaults — the correlation map MUST NOT
200            // grow without a cap. A flood of unique correlation keys is the
201            // remote-OOM vector; the default cap (10_000) is a sane production
202            // ceiling the operator may still override. The TTL (5 minutes) is
203            // the inline-retain + background-sweep eviction window.
204            max_buckets: Some(10_000),
205            bucket_ttl: Some(Duration::from_secs(300)),
206            force_completion_on_stop: false,
207            discard_on_timeout: false,
208            // R3-M3: cap concurrently-live per-bucket timeout tasks.
209            max_timeout_tasks: 1024,
210        }
211    }
212
213    /// Validate that at least one memory-release bound is configured (R3-M2).
214    ///
215    /// At least one of `max_buckets`, a `Timeout` completion condition, or
216    /// `bucket_ttl` MUST be set, otherwise a flood of unique correlation keys
217    /// grows the bucket map without limit (remote-OOM vector).
218    ///
219    /// Additionally, when a `Timeout` completion condition is present,
220    /// `bucket_ttl` MUST also be set. The R3-M3 timeout-task cap may skip
221    /// spawning a dedicated timeout task under flood; without `bucket_ttl`
222    /// there is no fallback eviction path and the bucket leaks until shutdown.
223    /// Requiring `bucket_ttl` whenever Timeout is present makes the cap-skip
224    /// degradation safe by construction.
225    pub fn validate(&self) -> Result<(), CamelError> {
226        let has_timeout = match &self.completion {
227            CompletionMode::Single(CompletionCondition::Timeout(_)) => true,
228            CompletionMode::Any(conds) => conds
229                .iter()
230                .any(|c| matches!(c, CompletionCondition::Timeout(_))),
231            _ => false,
232        };
233        let has_bound = self.max_buckets.is_some() || has_timeout || self.bucket_ttl.is_some();
234        if !has_bound {
235            return Err(CamelError::from(
236                ConfigValidationError::AggregatorMissingMemoryBound,
237            ));
238        }
239        // R3-M3: Timeout completion requires bucket_ttl so the cap-skip
240        // degradation always has an eviction path.
241        if has_timeout && self.bucket_ttl.is_none() {
242            return Err(CamelError::from(
243                ConfigValidationError::AggregatorTimeoutRequiresTtl,
244            ));
245        }
246        Ok(())
247    }
248}
249
250/// Builder for `AggregatorConfig`.
251pub struct AggregatorConfigBuilder {
252    header_name: String,
253    completion: Option<CompletionMode>,
254    correlation: CorrelationStrategy,
255    strategy: AggregationStrategy,
256    max_buckets: Option<usize>,
257    bucket_ttl: Option<Duration>,
258    force_completion_on_stop: bool,
259    discard_on_timeout: bool,
260    max_timeout_tasks: usize,
261}
262
263impl AggregatorConfigBuilder {
264    /// Emit when bucket has N exchanges.
265    pub fn complete_when_size(mut self, n: usize) -> Self {
266        self.completion = Some(CompletionMode::Single(CompletionCondition::Size(n)));
267        self
268    }
269
270    /// Emit when predicate returns true for the current bucket.
271    pub fn complete_when<F>(mut self, predicate: F) -> Self
272    where
273        F: Fn(&[Exchange]) -> bool + Send + Sync + 'static,
274    {
275        self.completion = Some(CompletionMode::Single(CompletionCondition::Predicate(
276            Arc::new(predicate),
277        )));
278        self
279    }
280
281    /// Emit when the bucket has been inactive for the given duration.
282    pub fn complete_on_timeout(mut self, duration: Duration) -> Self {
283        self.completion = Some(CompletionMode::Single(CompletionCondition::Timeout(
284            duration,
285        )));
286        self
287    }
288
289    /// Emit when the bucket reaches `size` OR has been inactive for `timeout`.
290    pub fn complete_on_size_or_timeout(mut self, size: usize, timeout: Duration) -> Self {
291        self.completion = Some(CompletionMode::Any(vec![
292            CompletionCondition::Size(size),
293            CompletionCondition::Timeout(timeout),
294        ]));
295        self
296    }
297
298    /// Enable force-completion of pending buckets when the route is stopped.
299    pub fn force_completion_on_stop(mut self, enabled: bool) -> Self {
300        self.force_completion_on_stop = enabled;
301        self
302    }
303
304    /// Discard bucket contents on timeout instead of emitting the aggregated exchange.
305    pub fn discard_on_timeout(mut self, enabled: bool) -> Self {
306        self.discard_on_timeout = enabled;
307        self
308    }
309
310    /// Override the correlation strategy with a header-based key.
311    pub fn correlate_by(mut self, header: impl Into<String>) -> Self {
312        let header = header.into();
313        self.header_name = header.clone();
314        self.correlation = CorrelationStrategy::HeaderName(header);
315        self
316    }
317
318    /// Override the default `CollectAll` aggregation strategy.
319    pub fn strategy(mut self, strategy: AggregationStrategy) -> Self {
320        self.strategy = strategy;
321        self
322    }
323
324    /// Set the maximum number of correlation key buckets.
325    /// When the limit is reached, new correlation keys are rejected with an error.
326    pub fn max_buckets(mut self, max: usize) -> Self {
327        self.max_buckets = Some(max);
328        self
329    }
330
331    /// Set the time-to-live for inactive buckets.
332    /// Buckets that haven't been updated for this duration will be evicted.
333    pub fn bucket_ttl(mut self, ttl: Duration) -> Self {
334        self.bucket_ttl = Some(ttl);
335        self
336    }
337
338    /// Override the maximum number of concurrently-live per-bucket timeout tasks.
339    pub fn max_timeout_tasks(mut self, max: usize) -> Self {
340        self.max_timeout_tasks = max;
341        self
342    }
343
344    pub fn try_build(self) -> Result<AggregatorConfig, CamelError> {
345        // R3-C1 Batch 1: a completion-bound is mandatory. A config with no
346        // completion bound lets a bucket live forever — combined with a
347        // unique-key flood, that is the remote-OOM vector. Typed
348        // ConfigValidationError (ADR-0033) — operators can match on the
349        // `AggregatorMissingCompletionBound` variant.
350        let completion = self.completion.ok_or_else(|| {
351            CamelError::from(ConfigValidationError::AggregatorMissingCompletionBound)
352        })?;
353        Ok(AggregatorConfig {
354            header_name: self.header_name,
355            completion,
356            correlation: self.correlation,
357            strategy: self.strategy,
358            max_buckets: self.max_buckets,
359            bucket_ttl: self.bucket_ttl,
360            force_completion_on_stop: self.force_completion_on_stop,
361            discard_on_timeout: self.discard_on_timeout,
362            max_timeout_tasks: self.max_timeout_tasks,
363        })
364    }
365
366    /// Build the config. Returns an error if no completion condition was set.
367    pub fn build(self) -> Result<AggregatorConfig, CamelError> {
368        self.try_build()
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_aggregator_config_complete_when_size() {
378        let config = AggregatorConfig::correlate_by("orderId")
379            .complete_when_size(3)
380            .build()
381            .unwrap();
382        assert_eq!(config.header_name, "orderId");
383        assert!(matches!(
384            config.completion,
385            CompletionMode::Single(CompletionCondition::Size(3))
386        ));
387        assert!(matches!(config.strategy, AggregationStrategy::CollectAll));
388    }
389
390    #[test]
391    fn test_aggregator_config_complete_when_predicate() {
392        let config = AggregatorConfig::correlate_by("key")
393            .complete_when(|bucket| bucket.len() >= 2)
394            .build()
395            .unwrap();
396        assert!(matches!(
397            config.completion,
398            CompletionMode::Single(CompletionCondition::Predicate(_))
399        ));
400    }
401
402    #[test]
403    fn test_aggregator_config_custom_strategy() {
404        use std::sync::Arc;
405        let f: AggregationFn = Arc::new(|acc, _next| acc);
406        let config = AggregatorConfig::correlate_by("key")
407            .complete_when_size(1)
408            .strategy(AggregationStrategy::Custom(f))
409            .build()
410            .unwrap();
411        assert!(matches!(config.strategy, AggregationStrategy::Custom(_)));
412    }
413
414    #[test]
415    fn test_aggregator_config_missing_completion_returns_err() {
416        let result = AggregatorConfig::correlate_by("key").build();
417        let err = match result {
418            Err(e) => e,
419            Ok(_) => panic!("expected error, got Ok"),
420        };
421        assert!(
422            err.to_string().contains("completion"),
423            "error message should mention 'completion': {err}"
424        );
425    }
426
427    #[test]
428    fn test_complete_on_size_or_timeout() {
429        let config = AggregatorConfig::correlate_by("key")
430            .complete_on_size_or_timeout(3, Duration::from_secs(5))
431            .build()
432            .unwrap();
433        assert!(matches!(config.completion, CompletionMode::Any(v) if v.len() == 2));
434    }
435
436    #[test]
437    fn test_force_completion_on_stop_default() {
438        let config = AggregatorConfig::correlate_by("key")
439            .complete_when_size(1)
440            .build()
441            .unwrap();
442        assert!(!config.force_completion_on_stop);
443        assert!(!config.discard_on_timeout);
444    }
445
446    #[test]
447    fn test_builder_sets_timeout_and_flags_and_limits() {
448        let config = AggregatorConfig::correlate_by("key")
449            .complete_on_timeout(Duration::from_secs(2))
450            .max_buckets(7)
451            .bucket_ttl(Duration::from_secs(10))
452            .force_completion_on_stop(true)
453            .discard_on_timeout(true)
454            .build()
455            .unwrap();
456
457        assert!(matches!(
458            config.completion,
459            CompletionMode::Single(CompletionCondition::Timeout(d)) if d == Duration::from_secs(2)
460        ));
461        assert_eq!(config.max_buckets, Some(7));
462        assert_eq!(config.bucket_ttl, Some(Duration::from_secs(10)));
463        assert!(config.force_completion_on_stop);
464        assert!(config.discard_on_timeout);
465    }
466
467    #[test]
468    fn test_builder_correlate_by_overrides_header_and_strategy() {
469        let config = AggregatorConfig::correlate_by("original")
470            .correlate_by("override")
471            .complete_when_size(1)
472            .build()
473            .unwrap();
474
475        assert_eq!(config.header_name, "override");
476        assert!(matches!(
477            config.correlation,
478            CorrelationStrategy::HeaderName(ref h) if h == "override"
479        ));
480    }
481
482    #[test]
483    fn test_completion_reason_as_str_all_variants() {
484        assert_eq!(CompletionReason::Size.as_str(), "size");
485        assert_eq!(CompletionReason::Predicate.as_str(), "predicate");
486        assert_eq!(CompletionReason::Timeout.as_str(), "timeout");
487        assert_eq!(CompletionReason::Stop.as_str(), "stop");
488    }
489
490    #[test]
491    fn test_correlation_strategy_clone_and_debug() {
492        let strategy = CorrelationStrategy::Expression {
493            expr: "${header.orderId}".to_string(),
494            language: "simple".to_string(),
495        };
496        let cloned = strategy.clone();
497        assert!(matches!(
498            cloned,
499            CorrelationStrategy::Expression { ref expr, ref language }
500                if expr == "${header.orderId}" && language == "simple"
501        ));
502
503        let f = CorrelationStrategy::Fn(Arc::new(|_| Some("k".to_string())));
504        assert_eq!(format!("{:?}", f), "Fn(..)");
505    }
506
507    #[test]
508    fn completion_condition_predicate_expr_debug_and_clone() {
509        let c = CompletionCondition::PredicateExpr {
510            expr: "${body} == 'DONE'".to_string(),
511            language: "simple".to_string(),
512        };
513        let debugged = format!("{:?}", c);
514        assert!(debugged.contains("PredicateExpr"), "debug: {}", debugged);
515        assert!(debugged.contains("DONE"), "debug: {}", debugged);
516        // Clone must compile (the enum derives Clone).
517        let _cloned = c.clone();
518    }
519
520    #[test]
521    fn test_complete_on_size_or_timeout_contains_both_conditions() {
522        let config = AggregatorConfig::correlate_by("k")
523            .complete_on_size_or_timeout(4, Duration::from_millis(250))
524            .build()
525            .unwrap();
526
527        match config.completion {
528            CompletionMode::Any(conditions) => {
529                assert!(matches!(conditions[0], CompletionCondition::Size(4)));
530                assert!(matches!(
531                    conditions[1],
532                    CompletionCondition::Timeout(d) if d == Duration::from_millis(250)
533                ));
534            }
535            _ => panic!("expected CompletionMode::Any"),
536        }
537    }
538
539    #[test]
540    #[allow(clippy::type_complexity)]
541    fn test_correlation_strategy_fn_clone_shares_same_arc() {
542        let f: Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync> =
543            Arc::new(|_| Some("shared".to_string()));
544        let strategy = CorrelationStrategy::Fn(f.clone());
545        let cloned = strategy.clone();
546
547        match cloned {
548            CorrelationStrategy::Fn(cloned_fn) => assert!(Arc::ptr_eq(&f, &cloned_fn)),
549            _ => panic!("expected fn strategy"),
550        }
551    }
552
553    #[test]
554    fn test_builder_correlate_by_overrides_previous() {
555        let config = AggregatorConfig::correlate_by("first")
556            .correlate_by("second")
557            .complete_when_size(2)
558            .build()
559            .unwrap();
560
561        assert_eq!(config.header_name, "second");
562        assert!(
563            matches!(config.correlation, CorrelationStrategy::HeaderName(ref h) if h == "second")
564        );
565    }
566
567    #[test]
568    fn test_aggregator_try_build_missing_completion_returns_error() {
569        let result = AggregatorConfig::correlate_by("key").try_build();
570        assert!(result.is_err());
571    }
572
573    // ── R3-C1 Batch 1: DoS caps + completion-bound validation ────────
574
575    /// The builder default for `max_buckets` is `Some(10_000)`. The spec fixes
576    /// this as the bounded default; the operator may still override.
577    #[test]
578    fn test_default_max_buckets_is_10000() {
579        let cfg = AggregatorConfig::correlate_by("k")
580            .complete_when_size(1)
581            .build()
582            .unwrap();
583        assert_eq!(cfg.max_buckets, Some(10_000));
584    }
585
586    /// The builder default for `bucket_ttl` is `Some(Duration::from_secs(300))`.
587    /// Inline retain + background sweep both use this TTL.
588    #[test]
589    fn test_default_bucket_ttl_is_300s() {
590        let cfg = AggregatorConfig::correlate_by("k")
591            .complete_when_size(1)
592            .build()
593            .unwrap();
594        assert_eq!(cfg.bucket_ttl, Some(Duration::from_secs(300)));
595    }
596
597    /// Configs that override `max_buckets(0)` still build (the operator chose it).
598    /// The cap may be set to 1 by the operator; the bound itself is not validated.
599    /// The completion-bound check below is what Batch 1 enforces.
600    #[test]
601    fn test_explicit_max_buckets_zero_is_accepted_at_build() {
602        let cfg = AggregatorConfig::correlate_by("k")
603            .complete_when_size(1)
604            .max_buckets(0)
605            .build()
606            .unwrap();
607        assert_eq!(cfg.max_buckets, Some(0));
608    }
609
610    /// A config with no completion bound — neither size, nor timeout, nor predicate —
611    /// is rejected at `try_build` with `AggregatorMissingCompletionBound`. Spec §11
612    /// RESOLVED: at least one completion bound is mandatory.
613    #[test]
614    fn test_aggregator_rejects_no_completion_bound() {
615        // Builder has no `complete_*` call → try_build returns Err.
616        // Use match (not unwrap_err) because AggregatorConfig is not Debug.
617        let err = match AggregatorConfig::correlate_by("k").try_build() {
618            Err(e) => e,
619            Ok(_) => panic!("expected error, got Ok"),
620        };
621        assert!(
622            matches!(
623                err,
624                CamelError::ConfigValidation(
625                    ConfigValidationError::AggregatorMissingCompletionBound
626                )
627            ),
628            "expected ConfigValidation(AggregatorMissingCompletionBound), got: {err}"
629        );
630    }
631
632    // ── R3-M2: memory-bound validation ────────────────────────────────
633
634    #[test]
635    fn test_aggregator_config_rejects_no_memory_bound() {
636        // Direct construction bypassing the builder defaults — simulate a config
637        // with no max_buckets, no timeout, no ttl.
638        let config = AggregatorConfig {
639            header_name: "k".into(),
640            completion: CompletionMode::Single(CompletionCondition::Size(2)),
641            correlation: CorrelationStrategy::HeaderName("k".into()),
642            strategy: AggregationStrategy::CollectAll,
643            max_buckets: None,
644            bucket_ttl: None,
645            force_completion_on_stop: false,
646            discard_on_timeout: false,
647            max_timeout_tasks: 1024,
648        };
649        let err = config.validate().unwrap_err();
650        assert!(
651            err.to_string().contains("max_buckets")
652                || err.to_string().contains("completionTimeout")
653                || err.to_string().contains("bucket_ttl"),
654            "error should explain the required bound: {err}"
655        );
656    }
657
658    /// D-A5: typed-variant pin — the exact `AggregatorMissingMemoryBound`
659    /// variant, not a substring match. ADR-0033 contract: operators may match
660    /// on the typed variant for structured error handling.
661    #[test]
662    fn test_da5_validate_returns_typed_missing_memory_bound_variant() {
663        let config = AggregatorConfig {
664            header_name: "k".into(),
665            completion: CompletionMode::Single(CompletionCondition::Size(2)),
666            correlation: CorrelationStrategy::HeaderName("k".into()),
667            strategy: AggregationStrategy::CollectAll,
668            max_buckets: None,
669            bucket_ttl: None,
670            force_completion_on_stop: false,
671            discard_on_timeout: false,
672            max_timeout_tasks: 1024,
673        };
674        let err = config.validate().unwrap_err();
675        assert!(
676            matches!(
677                err,
678                CamelError::ConfigValidation(ConfigValidationError::AggregatorMissingMemoryBound)
679            ),
680            "expected ConfigValidation(AggregatorMissingMemoryBound), got: {err}"
681        );
682    }
683
684    #[test]
685    fn test_aggregator_config_accepts_size_only_with_max_buckets() {
686        // Builder path defaults max_buckets + bucket_ttl — must validate OK.
687        let config = AggregatorConfig::correlate_by("k")
688            .complete_when_size(2)
689            .build()
690            .unwrap();
691        assert!(config.validate().is_ok());
692    }
693
694    /// R3-M3: Timeout completion requires bucket_ttl so the cap-skip
695    /// degradation always has an eviction path. A config with Timeout but
696    /// no bucket_ttl is rejected by validate().
697    #[test]
698    fn test_aggregator_timeout_requires_bucket_ttl() {
699        let config = AggregatorConfig {
700            header_name: "k".into(),
701            completion: CompletionMode::Single(CompletionCondition::Timeout(Duration::from_secs(
702                5,
703            ))),
704            correlation: CorrelationStrategy::HeaderName("k".into()),
705            strategy: AggregationStrategy::CollectAll,
706            max_buckets: Some(100),
707            bucket_ttl: None, // <-- missing ttl fallback
708            force_completion_on_stop: false,
709            discard_on_timeout: false,
710            max_timeout_tasks: 1024,
711        };
712        let err = config.validate().unwrap_err();
713        assert!(
714            err.to_string().contains("bucket_ttl") || err.to_string().contains("Timeout"),
715            "error should explain the timeout-requires-ttl invariant: {err}"
716        );
717    }
718}