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    /// Emit when the bucket has been inactive for the given duration.
75    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/// Determines how a bucket's completion is evaluated.
89/// `Single` wraps one condition; `Any` completes when the first condition triggers.
90#[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/// Configuration for the Aggregator EIP.
125#[derive(Clone)]
126pub struct AggregatorConfig {
127    /// Name of the header used as correlation key.
128    pub header_name: String,
129    /// When to emit the aggregated exchange.
130    pub completion: CompletionMode,
131    /// Strategy for determining correlation keys.
132    pub correlation: CorrelationStrategy,
133    /// How to combine the bucket into one exchange.
134    pub strategy: AggregationStrategy,
135    /// Maximum number of correlation key buckets (memory protection).
136    /// When limit is reached, new correlation keys are rejected.
137    pub max_buckets: Option<usize>,
138    /// Time-to-live for inactive buckets (memory protection).
139    /// Buckets not updated for this duration are evicted.
140    pub bucket_ttl: Option<Duration>,
141    /// Force-complete all pending buckets when the route is stopped.
142    pub force_completion_on_stop: bool,
143    /// Discard bucket contents on timeout instead of emitting.
144    pub discard_on_timeout: bool,
145    /// Maximum number of concurrently-live per-bucket timeout tasks (DoS cap, R3-M3).
146    /// When the cap is reached, new buckets skip the dedicated timeout spawn and
147    /// rely on `bucket_ttl` eviction (graceful degradation under a key flood).
148    /// Default 1024.
149    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    /// Start building config with correlation key extracted from the named header.
170    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            // R3-C1 Batch 1: bounded defaults — the correlation map MUST NOT
178            // grow without a cap. A flood of unique correlation keys is the
179            // remote-OOM vector; the default cap (10_000) is a sane production
180            // ceiling the operator may still override. The TTL (5 minutes) is
181            // the inline-retain + background-sweep eviction window.
182            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            // R3-M3: cap concurrently-live per-bucket timeout tasks.
187            max_timeout_tasks: 1024,
188        }
189    }
190
191    /// Validate that at least one memory-release bound is configured (R3-M2).
192    ///
193    /// At least one of `max_buckets`, a `Timeout` completion condition, or
194    /// `bucket_ttl` MUST be set, otherwise a flood of unique correlation keys
195    /// grows the bucket map without limit (remote-OOM vector).
196    ///
197    /// Additionally, when a `Timeout` completion condition is present,
198    /// `bucket_ttl` MUST also be set. The R3-M3 timeout-task cap may skip
199    /// spawning a dedicated timeout task under flood; without `bucket_ttl`
200    /// there is no fallback eviction path and the bucket leaks until shutdown.
201    /// Requiring `bucket_ttl` whenever Timeout is present makes the cap-skip
202    /// degradation safe by construction.
203    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        // R3-M3: Timeout completion requires bucket_ttl so the cap-skip
218        // degradation always has an eviction path.
219        if has_timeout && self.bucket_ttl.is_none() {
220            return Err(CamelError::from(
221                ConfigValidationError::AggregatorTimeoutRequiresTtl,
222            ));
223        }
224        Ok(())
225    }
226}
227
228/// Builder for `AggregatorConfig`.
229pub 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    /// Emit when bucket has N exchanges.
243    pub fn complete_when_size(mut self, n: usize) -> Self {
244        self.completion = Some(CompletionMode::Single(CompletionCondition::Size(n)));
245        self
246    }
247
248    /// Emit when predicate returns true for the current bucket.
249    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    /// Emit when the bucket has been inactive for the given duration.
260    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    /// Emit when the bucket reaches `size` OR has been inactive for `timeout`.
268    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    /// Enable force-completion of pending buckets when the route is stopped.
277    pub fn force_completion_on_stop(mut self, enabled: bool) -> Self {
278        self.force_completion_on_stop = enabled;
279        self
280    }
281
282    /// Discard bucket contents on timeout instead of emitting the aggregated exchange.
283    pub fn discard_on_timeout(mut self, enabled: bool) -> Self {
284        self.discard_on_timeout = enabled;
285        self
286    }
287
288    /// Override the correlation strategy with a header-based key.
289    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    /// Override the default `CollectAll` aggregation strategy.
297    pub fn strategy(mut self, strategy: AggregationStrategy) -> Self {
298        self.strategy = strategy;
299        self
300    }
301
302    /// Set the maximum number of correlation key buckets.
303    /// When the limit is reached, new correlation keys are rejected with an error.
304    pub fn max_buckets(mut self, max: usize) -> Self {
305        self.max_buckets = Some(max);
306        self
307    }
308
309    /// Set the time-to-live for inactive buckets.
310    /// Buckets that haven't been updated for this duration will be evicted.
311    pub fn bucket_ttl(mut self, ttl: Duration) -> Self {
312        self.bucket_ttl = Some(ttl);
313        self
314    }
315
316    /// Override the maximum number of concurrently-live per-bucket timeout tasks.
317    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        // R3-C1 Batch 1: a completion-bound is mandatory. A config with no
324        // completion bound lets a bucket live forever — combined with a
325        // unique-key flood, that is the remote-OOM vector. Typed
326        // ConfigValidationError (ADR-0033) — operators can match on the
327        // `AggregatorMissingCompletionBound` variant.
328        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    /// Build the config. Returns an error if no completion condition was set.
345    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    // ── R3-C1 Batch 1: DoS caps + completion-bound validation ────────
539
540    /// The builder default for `max_buckets` is `Some(10_000)`. The spec fixes
541    /// this as the bounded default; the operator may still override.
542    #[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    /// The builder default for `bucket_ttl` is `Some(Duration::from_secs(300))`.
552    /// Inline retain + background sweep both use this TTL.
553    #[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    /// Configs that override `max_buckets(0)` still build (the operator chose it).
563    /// The cap may be set to 1 by the operator; the bound itself is not validated.
564    /// The completion-bound check below is what Batch 1 enforces.
565    #[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    /// A config with no completion bound — neither size, nor timeout, nor predicate —
576    /// is rejected at `try_build` with `AggregatorMissingCompletionBound`. Spec §11
577    /// RESOLVED: at least one completion bound is mandatory.
578    #[test]
579    fn test_aggregator_rejects_no_completion_bound() {
580        // Builder has no `complete_*` call → try_build returns Err.
581        // Use match (not unwrap_err) because AggregatorConfig is not Debug.
582        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    // ── R3-M2: memory-bound validation ────────────────────────────────
598
599    #[test]
600    fn test_aggregator_config_rejects_no_memory_bound() {
601        // Direct construction bypassing the builder defaults — simulate a config
602        // with no max_buckets, no timeout, no ttl.
603        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        // Builder path defaults max_buckets + bucket_ttl — must validate OK.
626        let config = AggregatorConfig::correlate_by("k")
627            .complete_when_size(2)
628            .build()
629            .unwrap();
630        assert!(config.validate().is_ok());
631    }
632
633    /// R3-M3: Timeout completion requires bucket_ttl so the cap-skip
634    /// degradation always has an eviction path. A config with Timeout but
635    /// no bucket_ttl is rejected by validate().
636    #[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, // <-- missing ttl fallback
647            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}