Skip to main content

camel_processor/
aggregator.rs

1use std::collections::HashMap;
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::{Arc, Mutex};
5use std::task::{Context, Poll};
6use std::time::{Duration, Instant};
7
8use tokio::sync::mpsc;
9use tokio::task::JoinHandle;
10use tokio_util::sync::CancellationToken;
11use tower::Service;
12
13use async_trait::async_trait;
14use camel_api::{
15    CamelError, MetricsCollector, StepLifecycle, StepShutdownReason,
16    aggregator::{
17        AggregationStrategy, AggregatorConfig, CompletionCondition, CompletionMode,
18        CompletionReason, CorrelationStrategy,
19    },
20    body::Body,
21    exchange::Exchange,
22    message::Message,
23};
24use camel_language_api::Language;
25
26pub type SharedLanguageRegistry = Arc<std::sync::Mutex<HashMap<String, Arc<dyn Language>>>>;
27
28/// Sampling cadence for the metrics-only sweep (no `bucket_ttl` configured):
29/// matches the 250ms cadence of the other dashboard-observability T3.3
30/// queue-depth samplers.
31const QUEUE_DEPTH_SAMPLE_INTERVAL: Duration = Duration::from_millis(250);
32
33pub const CAMEL_AGGREGATOR_PENDING: &str = "CamelAggregatorPending";
34pub const CAMEL_AGGREGATED_SIZE: &str = "CamelAggregatedSize";
35pub const CAMEL_AGGREGATED_KEY: &str = "CamelAggregatedKey";
36pub const CAMEL_AGGREGATED_COMPLETION_REASON: &str = "CamelAggregatedCompletionReason";
37
38/// Internal bucket structure with timestamp tracking for TTL eviction.
39struct Bucket {
40    exchanges: Vec<Exchange>,
41    last_updated: Instant,
42}
43
44impl Bucket {
45    fn new() -> Self {
46        Self {
47            exchanges: Vec::new(),
48            last_updated: Instant::now(),
49        }
50    }
51
52    fn push(&mut self, exchange: Exchange) {
53        self.exchanges.push(exchange);
54        self.last_updated = Instant::now();
55    }
56
57    fn is_expired(&self, ttl: Duration) -> bool {
58        Instant::now().duration_since(self.last_updated) >= ttl
59    }
60}
61
62#[derive(Clone)]
63pub struct AggregatorService {
64    config: AggregatorConfig,
65    buckets: Arc<Mutex<HashMap<String, Bucket>>>,
66    timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
67    timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
68    late_tx: mpsc::Sender<Exchange>,
69    language_registry: SharedLanguageRegistry,
70    /// Swappable cell holding the cancellation token for the background
71    /// maintenance sweep task. `StepLifecycle::start` replaces this with a fresh token
72    /// so the sweep respawns on the next `poll_ready`; `shutdown` cancels the
73    /// current token to terminate the task. The value is initially seeded from
74    /// the route token — cancelling the route token cancels the sweep (the
75    /// primary shutdown path). This replaces the plain `route_cancel` field.
76    sweep_cancel: Arc<Mutex<CancellationToken>>,
77    /// Handle to the background maintenance sweep task. `None` until the
78    /// first `poll_ready`. The sweep exists when `bucket_ttl` OR
79    /// `queue_metrics` is configured (TTL eviction and/or queue-depth
80    /// sampling); with neither set there is nothing to sweep and no task is
81    /// spawned. The task binds to the current `sweep_cancel` token via
82    /// `select!`; `StepLifecycle::start` clears this to `None` (aborting the
83    /// old task) so `poll_ready` respawns with the fresh token.
84    sweep_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
85    /// Queue-depth reporting (collector + `aggregator:<route>` label). When
86    /// set, the background maintenance sweep publishes the buffered group
87    /// count as `camel_queue_depth{queue}` — with or without a
88    /// `bucket_ttl`. `None` when no collector was injected at compile time.
89    queue_metrics: Option<(Arc<dyn MetricsCollector>, String)>,
90    /// Strong-count cell backing the last-owner check in [`Drop`]: transient
91    /// clones (the pipeline clones the service per `poll_ready`) must not
92    /// abort the shared sweep task; only the final owner's drop may.
93    clone_guard: Arc<()>,
94}
95
96impl std::fmt::Debug for AggregatorService {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("AggregatorService").finish_non_exhaustive()
99    }
100}
101
102impl Drop for AggregatorService {
103    fn drop(&mut self) {
104        // Defense-in-depth: abort the background sweep on drop so it cannot
105        // leak even if the route owner forgets to call `shutdown`. The
106        // primary shutdown path is now `StepLifecycle::shutdown` which
107        // cancels `sweep_cancel` and aborts `sweep_handle`. `abort()` on an
108        // already-finished task is a no-op.
109        //
110        // Last-owner guard: `Clone` shares this cell, so a transient clone
111        // dropping (the pipeline clones the service on every `poll_ready`)
112        // does NOT abort the sweep — only the final owner's drop does.
113        if Arc::strong_count(&self.clone_guard) > 1 {
114            return;
115        }
116        if let Some(handle) = self
117            .sweep_handle
118            .lock()
119            .unwrap_or_else(|e| e.into_inner())
120            .take()
121        {
122            handle.abort();
123        }
124    }
125}
126
127impl AggregatorService {
128    /// Lifecycle invariant: `sweep_cancel` is initially seeded from the
129    /// route's cancellation token and wrapped in a swappable `Arc<Mutex<...>>`
130    /// cell. `StepLifecycle::start` replaces it with a fresh token so the
131    /// sweep respawns after a restart. Construction is runtime-free (no
132    /// `tokio::spawn` here). The maintenance sweep task (TTL eviction when
133    /// `bucket_ttl` is set, queue-depth sampling when `queue_metrics` is
134    /// set) is spawned LAZILY on the first `poll_ready` and bound to the
135    /// current `sweep_cancel` token via `select!`.
136    /// The route owner MUST call `shutdown` to cancel it;
137    /// `Drop` also aborts it as defense-in-depth.
138    pub fn new(
139        config: AggregatorConfig,
140        late_tx: mpsc::Sender<Exchange>,
141        language_registry: SharedLanguageRegistry,
142        route_cancel: CancellationToken,
143    ) -> Self {
144        // R3-M2: at least one memory-release bound is mandatory.
145        config.validate().expect(
146            // allow-unwrap
147            // fail-closed startup invariant (ADR-0033); a config without a bound is a programmer/operator error.
148            "AggregatorService::new: config failed validation \
149             (need max_buckets, completionTimeout, or bucket_ttl)",
150        );
151
152        // R3-M2 advisory: Size/Predicate-only completion (no Timeout) with no
153        // bucket_ttl means buckets accumulate until max_buckets is hit. Bounded
154        // by max_buckets (validated above) but worth surfacing to operators.
155        let has_timeout = has_timeout_condition(&config.completion);
156        if !has_timeout && config.bucket_ttl.is_none() {
157            tracing::warn!(
158                "Aggregator configured with Size/Predicate completion and no \
159                 bucket_ttl: buckets accumulate until max_buckets is reached"
160            );
161        }
162
163        // Build the shared buckets map up front so the sweep task can
164        // share it via Arc::clone.
165        let buckets: Arc<Mutex<HashMap<String, Bucket>>> = Arc::new(Mutex::new(HashMap::new()));
166
167        // R3-C1 Batch 1: the TTL-sweep is NOT spawned here — construction
168        // must stay runtime-free so callers that build an AggregatorService
169        // outside a tokio runtime (route-spec tests) do not panic on
170        // `tokio::spawn`. The sweep is spawned lazily on the first
171        // `poll_ready`. The inline `guard.retain(...)` per `call` evicts
172        // expired buckets regardless, so the cap + TTL invariants hold
173        // whether or not the sweep has started yet.
174
175        Self {
176            config,
177            buckets,
178            timeout_tasks: Arc::new(Mutex::new(HashMap::new())),
179            timeout_handles: Arc::new(Mutex::new(HashMap::new())),
180            late_tx,
181            language_registry,
182            sweep_cancel: Arc::new(Mutex::new(route_cancel)),
183            sweep_handle: Arc::new(Mutex::new(None)),
184            queue_metrics: None,
185            clone_guard: Arc::new(()),
186        }
187    }
188
189    /// Inject queue-depth reporting: the TTL-sweep maintenance pass publishes
190    /// the buffered group count (open correlation buckets) as
191    /// `camel_queue_depth{queue = label}`. `label` is the route-scoped
192    /// closed-set identifier (`aggregator:<route>`).
193    pub fn with_queue_metrics(
194        mut self,
195        metrics: Arc<dyn MetricsCollector>,
196        label: impl Into<String>,
197    ) -> Self {
198        self.queue_metrics = Some((metrics, label.into()));
199        self
200    }
201
202    pub fn config(&self) -> &AggregatorConfig {
203        &self.config
204    }
205
206    pub fn has_timeout(&self) -> bool {
207        has_timeout_condition(&self.config.completion)
208    }
209
210    pub fn force_complete_all(&self) {
211        let mut buckets_guard = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
212        let keys: Vec<String> = buckets_guard.keys().cloned().collect();
213
214        for key in keys {
215            if let Some(bucket) = buckets_guard.remove(&key) {
216                if self.config.force_completion_on_stop {
217                    cancel_timeout_task_with_handle(
218                        &key,
219                        &self.timeout_tasks,
220                        &self.timeout_handles,
221                    );
222                    match aggregate(bucket.exchanges, &self.config.strategy) {
223                        Ok(mut result) => {
224                            result.set_property(
225                                CAMEL_AGGREGATED_COMPLETION_REASON,
226                                serde_json::json!(CompletionReason::Stop.as_str()),
227                            );
228                            if self.late_tx.try_send(result).is_err() {
229                                tracing::warn!(
230                                    key = %key,
231                                    "aggregator force-complete emit dropped: late channel full"
232                                );
233                            }
234                        }
235                        Err(e) => {
236                            // log-policy: handler-owned
237                            tracing::warn!(
238                                key = %key,
239                                error = %e,
240                                "aggregation failed in force_complete_all"
241                            );
242                        }
243                    }
244                } else {
245                    cancel_timeout_task_with_handle(
246                        &key,
247                        &self.timeout_tasks,
248                        &self.timeout_handles,
249                    );
250                }
251            }
252        }
253    }
254
255    /// Graceful shutdown: cancel all outstanding timeout tasks and await their
256    /// JoinHandles (with a 5s deadline) so that no tasks are leaked.
257    pub(crate) async fn shutdown_inner(&self) {
258        // Cancel all timeout cancellation tokens.
259        {
260            let mut guard = self.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
261            for token in guard.values() {
262                token.cancel();
263            }
264            guard.clear();
265        };
266
267        // Remove and collect all JoinHandles.
268        let handles: Vec<JoinHandle<()>> = {
269            let mut guard = self
270                .timeout_handles
271                .lock()
272                .unwrap_or_else(|e| e.into_inner());
273            guard.drain().map(|(_, handle)| handle).collect()
274        };
275
276        if handles.is_empty() {
277            return;
278        }
279
280        // Await all handles with a deadline.
281        let _ = tokio::time::timeout(Duration::from_secs(5), async {
282            for handle in handles {
283                let _ = handle.await;
284            }
285        })
286        .await;
287    }
288}
289
290#[async_trait]
291impl StepLifecycle for AggregatorService {
292    fn name(&self) -> &'static str {
293        "aggregator"
294    }
295
296    async fn shutdown(&self, reason: StepShutdownReason) -> Result<(), CamelError> {
297        tracing::debug!(reason = ?reason, "Aggregator shutdown via StepLifecycle");
298
299        // Cancel the sweep token so the background task's `select!` cancel
300        // branch fires and the task exits.
301        self.sweep_cancel
302            .lock()
303            .unwrap_or_else(|e| e.into_inner())
304            .cancel();
305
306        // Abort any running sweep handle (defense-in-depth alongside the
307        // token cancel above).
308        if let Some(handle) = self
309            .sweep_handle
310            .lock()
311            .unwrap_or_else(|e| e.into_inner())
312            .take()
313        {
314            handle.abort();
315        }
316
317        self.shutdown_inner().await;
318        Ok(())
319    }
320
321    /// Resets the sweep for a fresh lifecycle: replaces the cancellation token
322    /// with a new one and aborts any existing sweep handle so the next
323    /// `poll_ready` respawns the sweep bound to the new token.
324    async fn start(&self) -> Result<(), CamelError> {
325        *self.sweep_cancel.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
326
327        if let Some(handle) = self
328            .sweep_handle
329            .lock()
330            .unwrap_or_else(|e| e.into_inner())
331            .take()
332        {
333            handle.abort();
334        }
335
336        Ok(())
337    }
338}
339
340pub fn has_timeout_condition(mode: &CompletionMode) -> bool {
341    match mode {
342        CompletionMode::Single(CompletionCondition::Timeout(_)) => true,
343        CompletionMode::Any(conditions) => conditions
344            .iter()
345            .any(|c| matches!(c, CompletionCondition::Timeout(_))),
346        _ => false,
347    }
348}
349
350impl Service<Exchange> for AggregatorService {
351    type Response = Exchange;
352    type Error = CamelError;
353    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
354
355    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
356        // R3-C1: lazily spawn the maintenance sweep on the first readiness
357        // poll. A tokio runtime is guaranteed here (the runtime driving the
358        // service), unlike at construction. Single-spawn via the lock +
359        // is_none check. The sweep exists when EITHER release mechanism is
360        // configured: `bucket_ttl` drives eviction, `queue_metrics` drives
361        // queue-depth sampling — a size-only aggregator (bucket_ttl = None)
362        // still samples (T3.3 review F2); eviction itself stays gated on
363        // the TTL.
364        let ttl = self.config.bucket_ttl;
365        if ttl.is_none() && self.queue_metrics.is_none() {
366            return Poll::Ready(Ok(()));
367        }
368        let mut g = self.sweep_handle.lock().unwrap_or_else(|e| e.into_inner());
369        if g.is_none() {
370            let interval = match ttl {
371                Some(ttl) => std::cmp::max(ttl / 2, Duration::from_millis(50)),
372                None => QUEUE_DEPTH_SAMPLE_INTERVAL,
373            };
374            let buckets = Arc::clone(&self.buckets);
375            let cancel = self
376                .sweep_cancel
377                .lock()
378                .unwrap_or_else(|e| e.into_inner())
379                .clone();
380            let queue_metrics = self.queue_metrics.clone();
381            *g = Some(tokio::spawn(async move {
382                loop {
383                    tokio::select! {
384                        _ = cancel.cancelled() => break,
385                        _ = tokio::time::sleep(interval) => {
386                            let mut guard =
387                                buckets.lock().unwrap_or_else(|e| e.into_inner());
388                            if let Some(ttl) = ttl {
389                                guard.retain(|_, b| !b.is_expired(ttl));
390                            }
391                            // Maintenance-pass sampling: publish the
392                            // buffered group count after eviction.
393                            if let Some((metrics, label)) = queue_metrics.as_ref() {
394                                metrics.set_queue_depth(label, guard.len());
395                            }
396                        }
397                    }
398                }
399            }));
400        }
401        Poll::Ready(Ok(()))
402    }
403
404    fn call(&mut self, exchange: Exchange) -> Self::Future {
405        let config = self.config.clone();
406        let buckets = Arc::clone(&self.buckets);
407        let timeout_tasks = Arc::clone(&self.timeout_tasks);
408        let timeout_handles = Arc::clone(&self.timeout_handles);
409        let late_tx = self.late_tx.clone();
410        let language_registry = Arc::clone(&self.language_registry);
411
412        Box::pin(async move {
413            let key_value =
414                extract_correlation_key(&exchange, &config.correlation, &language_registry).await?;
415
416            let key_str = serde_json::to_string(&key_value)
417                .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
418
419            // Pre-evaluate the completion predicate (if any) BEFORE taking the
420            // buckets lock — expression evaluation is async + uses the language
421            // registry, which cannot run under the !Send buckets MutexGuard.
422            let predicate_satisfied =
423                evaluate_completion_predicate(&config.completion, &exchange, &language_registry)
424                    .await?;
425
426            let completed_bucket = {
427                let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
428
429                if let Some(ttl) = config.bucket_ttl {
430                    guard.retain(|_, bucket| !bucket.is_expired(ttl));
431                }
432
433                if let Some(max) = config.max_buckets
434                    && !guard.contains_key(&key_str)
435                    && guard.len() >= max
436                {
437                    tracing::warn!(
438                        max_buckets = max,
439                        correlation_key = %key_str,
440                        "Aggregator reached max buckets limit, rejecting new correlation key"
441                    );
442                    return Err(CamelError::ProcessorError(format!(
443                        "Aggregator reached maximum {} buckets",
444                        max
445                    )));
446                }
447
448                // F6-2 (audit 2026-08-31): per-bucket accumulation bound. A
449                // single hot correlation key must not buffer unboundedly while
450                // waiting for predicate/timeout completion.
451                if let Some(max_size) = config.max_bucket_size
452                    && let Some(existing) = guard.get(&key_str)
453                    && existing.exchanges.len() >= max_size
454                {
455                    tracing::warn!(
456                        max_bucket_size = max_size,
457                        correlation_key = %key_str,
458                        "Aggregator reached per-bucket size limit, rejecting exchange"
459                    );
460                    return Err(CamelError::ProcessorError(format!(
461                        "Aggregator bucket reached maximum {max_size} exchanges"
462                    )));
463                }
464
465                let bucket = guard.entry(key_str.clone()).or_insert_with(Bucket::new);
466                bucket.push(exchange);
467
468                let (is_complete, reason) = check_sync_completion(
469                    &config.completion,
470                    &bucket.exchanges,
471                    predicate_satisfied,
472                );
473
474                if is_complete {
475                    let exchanges = guard.remove(&key_str).map(|b| b.exchanges);
476                    (exchanges, reason)
477                } else {
478                    (None, CompletionReason::Size) // placeholder; reason unused when None
479                }
480            };
481
482            if completed_bucket.0.is_none() && has_timeout_condition(&config.completion) {
483                let timeout_dur = extract_timeout_duration(&config.completion);
484                if let Some(timeout) = timeout_dur {
485                    // R3-M3: bound the number of concurrently-live per-bucket
486                    // timeout tasks. When the cap is reached, skip the dedicated
487                    // spawn — the bucket relies on bucket_ttl eviction (graceful
488                    // degradation). max_buckets already caps total buckets, so
489                    // memory stays bounded regardless.
490                    let live_count = timeout_handles
491                        .lock()
492                        .unwrap_or_else(|e| e.into_inner())
493                        .len();
494                    if live_count >= config.max_timeout_tasks {
495                        tracing::warn!(
496                            live_timeout_tasks = live_count,
497                            max_timeout_tasks = config.max_timeout_tasks,
498                            correlation_key = %key_str,
499                            "Aggregator timeout-task cap reached; bucket will rely on \
500                             bucket_ttl eviction instead of a dedicated timeout task"
501                        );
502                    } else {
503                        // Cancel old token for this key (if any).
504                        {
505                            let tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
506                            if let Some(existing) = tt_guard.get(&key_str) {
507                                existing.cancel();
508                            }
509                        }
510                        // Remove old handle if present.
511                        {
512                            let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
513                            if let Some(old) = hh.remove(&key_str) {
514                                old.abort();
515                            }
516                        }
517                        let cancel = CancellationToken::new();
518                        timeout_tasks
519                            .lock()
520                            .unwrap_or_else(|e| e.into_inner())
521                            .insert(key_str.clone(), cancel.clone());
522                        let handle = spawn_timeout_task(
523                            key_str.clone(),
524                            timeout,
525                            cancel,
526                            buckets.clone(),
527                            timeout_tasks.clone(),
528                            timeout_handles.clone(),
529                            late_tx,
530                            config.strategy.clone(),
531                            config.discard_on_timeout,
532                        );
533                        timeout_handles
534                            .lock()
535                            .unwrap_or_else(|e| e.into_inner())
536                            .insert(key_str.clone(), handle);
537                    }
538                }
539            }
540
541            if let Some(exchanges) = completed_bucket.0 {
542                cancel_timeout_task_with_handle(&key_str, &timeout_tasks, &timeout_handles);
543                let reason = completed_bucket.1;
544                let size = exchanges.len();
545                let mut result = aggregate(exchanges, &config.strategy)?;
546                result.set_property(CAMEL_AGGREGATED_SIZE, serde_json::json!(size as u64));
547                result.set_property(CAMEL_AGGREGATED_KEY, key_value);
548                result.set_property(
549                    CAMEL_AGGREGATED_COMPLETION_REASON,
550                    serde_json::json!(reason.as_str()),
551                );
552                Ok(result)
553            } else {
554                let mut pending = Exchange::new(Message {
555                    headers: Default::default(),
556                    body: Body::Empty,
557                });
558                pending.set_property(CAMEL_AGGREGATOR_PENDING, serde_json::json!(true));
559                Ok(pending)
560            }
561        })
562    }
563}
564
565async fn extract_correlation_key(
566    exchange: &Exchange,
567    strategy: &CorrelationStrategy,
568    registry: &SharedLanguageRegistry,
569) -> Result<serde_json::Value, CamelError> {
570    match strategy {
571        CorrelationStrategy::HeaderName(h) => {
572            exchange.input.headers.get(h).cloned().ok_or_else(|| {
573                CamelError::ProcessorError(format!(
574                    "Aggregator: missing correlation key header '{}'",
575                    h
576                ))
577            })
578        }
579        CorrelationStrategy::Expression { expr, language } => {
580            let expression = {
581                let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
582                let lang = reg.get(language).ok_or_else(|| {
583                    CamelError::ProcessorError(format!(
584                        "Aggregator: language '{}' not found in registry",
585                        language
586                    ))
587                })?;
588                lang.create_expression(expr)
589                    .map_err(|e| CamelError::ProcessorError(e.to_string()))?
590            };
591            let value = expression
592                .evaluate(exchange)
593                .await
594                .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
595            if value.is_null() {
596                return Err(CamelError::ProcessorError(format!(
597                    "Aggregator: correlation expression '{}' evaluated to null",
598                    expr
599                )));
600            }
601            Ok(value)
602        }
603        CorrelationStrategy::Fn(f) => f(exchange).map(serde_json::Value::String).ok_or_else(|| {
604            CamelError::ProcessorError("Aggregator: correlation function returned None".to_string())
605        }),
606        // Future correlation strategies are unsupported here.
607        _ => Err(CamelError::ProcessorError(
608            "Aggregator: unsupported correlation strategy".to_string(),
609        )),
610    }
611}
612
613/// Yield `(&expr, &language)` for every `PredicateExpr` condition in `mode`.
614fn iter_predicate_exprs(mode: &CompletionMode) -> Vec<(&String, &String)> {
615    let mut out = Vec::new();
616    match mode {
617        CompletionMode::Single(CompletionCondition::PredicateExpr { expr, language }) => {
618            out.push((expr, language));
619        }
620        CompletionMode::Any(conds) => {
621            for c in conds {
622                if let CompletionCondition::PredicateExpr { expr, language } = c {
623                    out.push((expr, language));
624                }
625            }
626        }
627        _ => {}
628    }
629    out
630}
631
632/// Pre-evaluate ALL `PredicateExpr` conditions against the incoming exchange,
633/// OR-combining (matches `CompletionMode::Any` semantics). MUST be called BEFORE
634/// the `buckets` lock — it awaits the language registry and cannot run under the
635/// `!Send` `buckets` MutexGuard. Mirrors `extract_correlation_key`.
636async fn evaluate_completion_predicate(
637    mode: &CompletionMode,
638    incoming: &Exchange,
639    registry: &SharedLanguageRegistry,
640) -> Result<bool, CamelError> {
641    let mut satisfied = false;
642    for (expr, language) in iter_predicate_exprs(mode) {
643        let expression = {
644            let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
645            let lang = reg.get(language).ok_or_else(|| {
646                CamelError::ProcessorError(format!(
647                    "Aggregator: language '{}' not found in registry",
648                    language
649                ))
650            })?;
651            lang.create_expression(expr)
652                .map_err(|e| CamelError::ProcessorError(e.to_string()))?
653        }; // registry guard dropped here — safe to await below
654        let value = expression
655            .evaluate(incoming)
656            .await
657            .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
658        if value.as_bool().unwrap_or(false) {
659            satisfied = true;
660        }
661    }
662    Ok(satisfied)
663}
664
665fn check_sync_completion(
666    mode: &CompletionMode,
667    exchanges: &[Exchange],
668    predicate_satisfied: bool,
669) -> (bool, CompletionReason) {
670    match mode {
671        CompletionMode::Single(cond) => check_single(cond, exchanges, predicate_satisfied),
672        CompletionMode::Any(conditions) => {
673            for cond in conditions {
674                if let CompletionCondition::Timeout(_) = cond {
675                    continue;
676                }
677                let (done, reason) = check_single(cond, exchanges, predicate_satisfied);
678                if done {
679                    return (true, reason);
680                }
681            }
682            (false, CompletionReason::Size)
683        }
684        // Future completion modes are not synchronously complete.
685        _ => (false, CompletionReason::Size),
686    }
687}
688
689fn check_single(
690    cond: &CompletionCondition,
691    exchanges: &[Exchange],
692    predicate_satisfied: bool,
693) -> (bool, CompletionReason) {
694    match cond {
695        CompletionCondition::Size(n) => (exchanges.len() >= *n, CompletionReason::Size),
696        CompletionCondition::Predicate(pred) => (pred(exchanges), CompletionReason::Predicate),
697        CompletionCondition::PredicateExpr { .. } => {
698            (predicate_satisfied, CompletionReason::Predicate)
699        }
700        CompletionCondition::Timeout(_) => (false, CompletionReason::Timeout),
701        // Future condition types cannot be evaluated synchronously.
702        _ => (false, CompletionReason::Size),
703    }
704}
705
706fn extract_timeout_duration(mode: &CompletionMode) -> Option<Duration> {
707    match mode {
708        CompletionMode::Single(CompletionCondition::Timeout(d)) => Some(*d),
709        CompletionMode::Any(conditions) => conditions.iter().find_map(|c| {
710            if let CompletionCondition::Timeout(d) = c {
711                Some(*d)
712            } else {
713                None
714            }
715        }),
716        _ => None,
717    }
718}
719
720fn cancel_timeout_task(key: &str, timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>) {
721    let mut guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
722    if let Some(token) = guard.remove(key) {
723        token.cancel();
724    }
725}
726
727/// Also removes the stored JoinHandle for a cancelled/completed timeout task.
728fn cancel_timeout_task_with_handle(
729    key: &str,
730    timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>,
731    timeout_handles: &Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
732) {
733    cancel_timeout_task(key, timeout_tasks);
734    let mut guard = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
735    guard.remove(key);
736}
737
738#[allow(clippy::too_many_arguments)]
739fn spawn_timeout_task(
740    key: String,
741    timeout: Duration,
742    cancel: CancellationToken,
743    buckets: Arc<Mutex<HashMap<String, Bucket>>>,
744    timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
745    timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
746    late_tx: mpsc::Sender<Exchange>,
747    strategy: AggregationStrategy,
748    discard: bool,
749) -> JoinHandle<()> {
750    let cancel_clone = cancel.clone();
751    tokio::spawn(async move {
752        tokio::select! {
753            _ = tokio::time::sleep(timeout) => {
754                let should_proceed = {
755                    let mut tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
756                    if cancel_clone.is_cancelled() {
757                        false
758                    } else {
759                        tt_guard.remove(&key);
760                        true
761                    }
762                };
763                if !should_proceed {
764                    return;
765                }
766                // Clean up our own JoinHandle from the map on natural completion.
767                // Without this, the handle entry leaks until route shutdown.
768                {
769                    let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
770                    hh.remove(&key);
771                }
772                let bucket_exchanges = {
773                    let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
774                    guard.remove(&key).map(|b| b.exchanges)
775                };
776                if let Some(exchanges) = bucket_exchanges
777                    && !discard
778                {
779                    match aggregate(exchanges, &strategy) {
780                        Ok(mut result) => {
781                            result.set_property(
782                                CAMEL_AGGREGATED_COMPLETION_REASON,
783                                serde_json::json!(CompletionReason::Timeout.as_str()),
784                            );
785                            if late_tx.try_send(result).is_err() {
786                                tracing::warn!(
787                                    key = %key,
788                                    "aggregator timeout emit dropped: late channel full"
789                                );
790                            }
791                        }
792                        Err(e) => {
793                            // log-policy: handler-owned
794                            tracing::warn!(
795                                key = %key,
796                                error = %e,
797                                "aggregation failed in timeout task"
798                            );
799                        }
800                    }
801                }
802            }
803            _ = cancel_clone.cancelled() => {}
804        }
805    })
806}
807
808fn aggregate(
809    exchanges: Vec<Exchange>,
810    strategy: &AggregationStrategy,
811) -> Result<Exchange, CamelError> {
812    match strategy {
813        AggregationStrategy::CollectAll => {
814            let bodies: Vec<serde_json::Value> = exchanges
815                .into_iter()
816                .map(|e| match e.input.body {
817                    Body::Json(v) => v,
818                    Body::Text(s) => serde_json::Value::String(s),
819                    Body::Xml(s) => serde_json::Value::String(s),
820                    Body::Bytes(b) => {
821                        serde_json::Value::String(String::from_utf8_lossy(&b).into_owned())
822                    }
823                    Body::Stream(s) => serde_json::json!({
824                        "_stream": {
825                            "origin": s.metadata.origin,
826                            "placeholder": true,
827                            "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
828                        }
829                    }),
830                    // Empty and future variants contribute no extractable value.
831                    _ => serde_json::Value::Null,
832                })
833                .collect();
834            Ok(Exchange::new(Message {
835                headers: Default::default(),
836                body: Body::Json(serde_json::Value::Array(bodies)),
837            }))
838        }
839        AggregationStrategy::Custom(f) => {
840            let mut iter = exchanges.into_iter();
841            let first = iter.next().ok_or_else(|| {
842                CamelError::ProcessorError("Aggregator: empty bucket".to_string())
843            })?;
844            Ok(iter.fold(first, |acc, next| f(acc, next)))
845        }
846        // Future aggregation strategies are unsupported here.
847        _ => Err(CamelError::ProcessorError(
848            "Aggregator: unsupported aggregation strategy".to_string(),
849        )),
850    }
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856    use std::collections::HashMap;
857
858    use camel_api::{
859        StepLifecycle, StepShutdownReason,
860        aggregator::{AggregationStrategy, AggregatorConfig},
861        body::Body,
862        exchange::Exchange,
863        message::Message,
864    };
865    use tokio::sync::mpsc;
866    use tokio_util::sync::CancellationToken;
867    use tower::ServiceExt;
868
869    fn make_exchange(header: &str, value: &str, body: &str) -> Exchange {
870        let mut msg = Message {
871            headers: Default::default(),
872            body: Body::Text(body.to_string()),
873        };
874        msg.headers
875            .insert(header.to_string(), serde_json::json!(value));
876        Exchange::new(msg)
877    }
878
879    fn config_size(n: usize) -> AggregatorConfig {
880        AggregatorConfig::correlate_by("orderId")
881            .complete_when_size(n)
882            .build()
883            .unwrap()
884    }
885
886    fn new_test_svc(config: AggregatorConfig) -> AggregatorService {
887        let (tx, _rx) = mpsc::channel(256);
888        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
889        let cancel = CancellationToken::new();
890        AggregatorService::new(config, tx, registry, cancel)
891    }
892
893    /// Same as `new_test_svc` but lets the caller install a pre-populated
894    /// language registry. Used by `CompletionCondition::PredicateExpr` tests
895    /// where the simple language must be resolvable at evaluation time.
896    fn new_test_svc_with_registry(
897        config: AggregatorConfig,
898        registry: SharedLanguageRegistry,
899    ) -> AggregatorService {
900        let (tx, _rx) = mpsc::channel(256);
901        let cancel = CancellationToken::new();
902        AggregatorService::new(config, tx, registry, cancel)
903    }
904
905    #[tokio::test]
906    async fn test_pending_exchange_not_yet_complete() {
907        let mut svc = new_test_svc(config_size(3));
908        let ex = make_exchange("orderId", "A", "first");
909        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
910        assert!(matches!(result.input.body, Body::Empty));
911        assert_eq!(
912            result.property(CAMEL_AGGREGATOR_PENDING),
913            Some(&serde_json::json!(true))
914        );
915    }
916
917    #[tokio::test]
918    async fn test_completes_on_size() {
919        let mut svc = new_test_svc(config_size(3));
920        for _ in 0..2 {
921            let ex = make_exchange("orderId", "A", "item");
922            let r = svc.ready().await.unwrap().call(ex).await.unwrap();
923            assert!(matches!(r.input.body, Body::Empty));
924        }
925        let ex = make_exchange("orderId", "A", "last");
926        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
927        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
928        assert_eq!(
929            result.property(CAMEL_AGGREGATED_SIZE),
930            Some(&serde_json::json!(3u64))
931        );
932    }
933
934    #[tokio::test]
935    async fn test_collect_all_produces_json_array() {
936        let mut svc = new_test_svc(config_size(2));
937        svc.ready()
938            .await
939            .unwrap()
940            .call(make_exchange("orderId", "A", "alpha"))
941            .await
942            .unwrap();
943        let result = svc
944            .ready()
945            .await
946            .unwrap()
947            .call(make_exchange("orderId", "A", "beta"))
948            .await
949            .unwrap();
950        let Body::Json(v) = &result.input.body else {
951            panic!("expected Body::Json")
952        };
953        let arr = v.as_array().unwrap();
954        assert_eq!(arr.len(), 2);
955        assert_eq!(arr[0], serde_json::json!("alpha"));
956        assert_eq!(arr[1], serde_json::json!("beta"));
957    }
958
959    #[tokio::test]
960    async fn test_two_keys_independent_buckets() {
961        // completionSize=3 so we can test that A and B accumulate independently.
962        let mut svc = new_test_svc(config_size(3));
963        svc.ready()
964            .await
965            .unwrap()
966            .call(make_exchange("orderId", "A", "a1"))
967            .await
968            .unwrap();
969        svc.ready()
970            .await
971            .unwrap()
972            .call(make_exchange("orderId", "B", "b1"))
973            .await
974            .unwrap();
975        svc.ready()
976            .await
977            .unwrap()
978            .call(make_exchange("orderId", "A", "a2"))
979            .await
980            .unwrap();
981        // A has 2 items, B has 1 item — neither complete yet
982        let ra = svc
983            .ready()
984            .await
985            .unwrap()
986            .call(make_exchange("orderId", "A", "a3"))
987            .await
988            .unwrap();
989        // A now has 3 → completes
990        assert!(matches!(ra.input.body, Body::Json(_)));
991        // B only has 1 → still pending
992        let rb = svc
993            .ready()
994            .await
995            .unwrap()
996            .call(make_exchange("orderId", "B", "b_check"))
997            .await
998            .unwrap();
999        assert!(matches!(rb.input.body, Body::Empty));
1000    }
1001
1002    #[tokio::test]
1003    async fn test_bucket_resets_after_completion() {
1004        let mut svc = new_test_svc(config_size(2));
1005        svc.ready()
1006            .await
1007            .unwrap()
1008            .call(make_exchange("orderId", "A", "x"))
1009            .await
1010            .unwrap();
1011        svc.ready()
1012            .await
1013            .unwrap()
1014            .call(make_exchange("orderId", "A", "x"))
1015            .await
1016            .unwrap(); // completes
1017        // New bucket starts
1018        let r = svc
1019            .ready()
1020            .await
1021            .unwrap()
1022            .call(make_exchange("orderId", "A", "new"))
1023            .await
1024            .unwrap();
1025        assert!(matches!(r.input.body, Body::Empty)); // pending again
1026    }
1027
1028    #[tokio::test]
1029    async fn test_completion_size_1_emits_immediately() {
1030        let mut svc = new_test_svc(config_size(1));
1031        let ex = make_exchange("orderId", "A", "solo");
1032        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1033        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1034    }
1035
1036    #[tokio::test]
1037    async fn test_custom_aggregation_strategy() {
1038        use camel_api::aggregator::AggregationFn;
1039        use std::sync::Arc;
1040
1041        let f: AggregationFn = Arc::new(|mut acc: Exchange, next: Exchange| {
1042            let combined = format!(
1043                "{}+{}",
1044                acc.input.body.as_text().unwrap_or(""),
1045                next.input.body.as_text().unwrap_or("")
1046            );
1047            acc.input.body = Body::Text(combined);
1048            acc
1049        });
1050        let config = AggregatorConfig::correlate_by("key")
1051            .complete_when_size(2)
1052            .strategy(AggregationStrategy::Custom(f))
1053            .build()
1054            .unwrap();
1055        let mut svc = new_test_svc(config);
1056        svc.ready()
1057            .await
1058            .unwrap()
1059            .call(make_exchange("key", "X", "hello"))
1060            .await
1061            .unwrap();
1062        let result = svc
1063            .ready()
1064            .await
1065            .unwrap()
1066            .call(make_exchange("key", "X", "world"))
1067            .await
1068            .unwrap();
1069        assert_eq!(result.input.body.as_text(), Some("hello+world"));
1070    }
1071
1072    #[tokio::test]
1073    async fn test_completion_predicate() {
1074        let config = AggregatorConfig::correlate_by("key")
1075            .complete_when(|bucket| {
1076                bucket
1077                    .iter()
1078                    .any(|e| e.input.body.as_text() == Some("DONE"))
1079            })
1080            .build()
1081            .unwrap();
1082        let mut svc = new_test_svc(config);
1083        svc.ready()
1084            .await
1085            .unwrap()
1086            .call(make_exchange("key", "K", "first"))
1087            .await
1088            .unwrap();
1089        svc.ready()
1090            .await
1091            .unwrap()
1092            .call(make_exchange("key", "K", "second"))
1093            .await
1094            .unwrap();
1095        let result = svc
1096            .ready()
1097            .await
1098            .unwrap()
1099            .call(make_exchange("key", "K", "DONE"))
1100            .await
1101            .unwrap();
1102        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1103    }
1104
1105    #[tokio::test]
1106    async fn test_missing_header_returns_error() {
1107        let mut svc = new_test_svc(config_size(2));
1108        let msg = Message {
1109            headers: Default::default(),
1110            body: Body::Text("no key".into()),
1111        };
1112        let ex = Exchange::new(msg);
1113        let result = svc.ready().await.unwrap().call(ex).await;
1114        assert!(result.is_err());
1115        assert!(matches!(
1116            result.unwrap_err(),
1117            camel_api::CamelError::ProcessorError(_)
1118        ));
1119    }
1120
1121    #[tokio::test]
1122    async fn test_cloned_service_shares_state() {
1123        let svc1 = new_test_svc(config_size(2));
1124        let mut svc2 = svc1.clone();
1125        // send first exchange via svc1
1126        svc1.clone()
1127            .ready()
1128            .await
1129            .unwrap()
1130            .call(make_exchange("orderId", "A", "from-svc1"))
1131            .await
1132            .unwrap();
1133        // send second exchange via svc2 — should complete because same Arc<Mutex>
1134        let result = svc2
1135            .ready()
1136            .await
1137            .unwrap()
1138            .call(make_exchange("orderId", "A", "from-svc2"))
1139            .await
1140            .unwrap();
1141        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1142    }
1143
1144    #[tokio::test]
1145    async fn test_camel_aggregated_key_property_set() {
1146        let mut svc = new_test_svc(config_size(1));
1147        let ex = make_exchange("orderId", "ORDER-42", "body");
1148        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1149        assert_eq!(
1150            result.property(CAMEL_AGGREGATED_KEY),
1151            Some(&serde_json::json!("ORDER-42"))
1152        );
1153    }
1154
1155    #[tokio::test]
1156    async fn test_aggregator_enforces_max_buckets() {
1157        let config = AggregatorConfig::correlate_by("orderId")
1158            .complete_when_size(2)
1159            .max_buckets(3)
1160            .build()
1161            .unwrap();
1162
1163        let mut svc = new_test_svc(config);
1164
1165        // Create 3 different correlation keys (fills limit)
1166        for i in 0..3 {
1167            let ex = make_exchange("orderId", &format!("key-{}", i), "body");
1168            let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1169        }
1170
1171        // 4th key should be rejected
1172        let ex = make_exchange("orderId", "key-4", "body");
1173        let result = svc.ready().await.unwrap().call(ex).await;
1174
1175        assert!(result.is_err(), "Should reject when max buckets reached");
1176        let err = result.unwrap_err().to_string();
1177        assert!(
1178            err.contains("maximum"),
1179            "Error message should contain 'maximum': {}",
1180            err
1181        );
1182    }
1183
1184    #[tokio::test]
1185    async fn test_max_buckets_allows_existing_key() {
1186        let config = AggregatorConfig::correlate_by("orderId")
1187            .complete_when_size(5) // Large size so bucket doesn't complete
1188            .max_buckets(2)
1189            .build()
1190            .unwrap();
1191
1192        let mut svc = new_test_svc(config);
1193
1194        // Create 2 different correlation keys (fills limit)
1195        let ex1 = make_exchange("orderId", "key-A", "body1");
1196        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1197        let ex2 = make_exchange("orderId", "key-B", "body2");
1198        let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1199
1200        // Should still allow adding to existing key
1201        let ex3 = make_exchange("orderId", "key-A", "body3");
1202        let result = svc.ready().await.unwrap().call(ex3).await;
1203        assert!(
1204            result.is_ok(),
1205            "Should allow adding to existing bucket even at max limit"
1206        );
1207    }
1208
1209    /// Audit 2026-08-31, F6-2: one hot correlation key must not buffer
1210    /// unboundedly. With a predicate-only completion and a tiny
1211    /// max_bucket_size, the Nth+1 exchange on the same key is rejected.
1212    #[tokio::test]
1213    async fn test_aggregator_enforces_max_bucket_size() {
1214        let config = AggregatorConfig::correlate_by("orderId")
1215            // Predicate that never fires — completion would otherwise end the test.
1216            .complete_when(|_| false)
1217            .max_bucket_size(3)
1218            .build()
1219            .unwrap();
1220
1221        let mut svc = new_test_svc(config);
1222
1223        for _ in 0..3 {
1224            let ex = make_exchange("orderId", "hot-key", "body");
1225            let r = svc.ready().await.unwrap().call(ex).await;
1226            assert!(r.is_ok(), "first 3 exchanges accepted: {r:?}");
1227        }
1228
1229        let ex = make_exchange("orderId", "hot-key", "body");
1230        let result = svc.ready().await.unwrap().call(ex).await;
1231        assert!(result.is_err(), "4th exchange on hot key must be rejected");
1232        let err = result.unwrap_err().to_string();
1233        assert!(
1234            err.contains("maximum"),
1235            "error should mention the per-bucket maximum: {err}"
1236        );
1237
1238        // A DIFFERENT key is unaffected (bounded per bucket, not global).
1239        let ex = make_exchange("orderId", "other-key", "body");
1240        let result = svc.ready().await.unwrap().call(ex).await;
1241        assert!(result.is_ok(), "other keys still accepted: {result:?}");
1242    }
1243
1244    #[tokio::test]
1245    async fn test_bucket_ttl_eviction() {
1246        let config = AggregatorConfig::correlate_by("orderId")
1247            .complete_when_size(10) // Large size so bucket doesn't complete normally
1248            .bucket_ttl(Duration::from_millis(50))
1249            .build()
1250            .unwrap();
1251
1252        let mut svc = new_test_svc(config);
1253
1254        // Create a bucket
1255        let ex1 = make_exchange("orderId", "key-A", "body1");
1256        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1257
1258        // Wait for TTL to expire
1259        tokio::time::sleep(Duration::from_millis(100)).await;
1260
1261        // Create a new bucket - this should trigger eviction of the old one
1262        let ex2 = make_exchange("orderId", "key-B", "body2");
1263        let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1264
1265        // The expired bucket should have been evicted, so we should be able to
1266        // add a new key-A bucket again
1267        let ex3 = make_exchange("orderId", "key-A", "body3");
1268        let result = svc.ready().await.unwrap().call(ex3).await;
1269        assert!(result.is_ok(), "Should be able to recreate evicted bucket");
1270    }
1271
1272    #[tokio::test(start_paused = true)]
1273    async fn test_timeout_completes_bucket() {
1274        let config = AggregatorConfig::correlate_by("key")
1275            .complete_on_timeout(Duration::from_millis(100))
1276            .build()
1277            .unwrap();
1278        let mut svc = new_test_svc(config);
1279        let ex = make_exchange("key", "A", "data");
1280        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1281        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_some());
1282
1283        tokio::time::sleep(Duration::from_millis(200)).await;
1284
1285        assert_eq!(
1286            svc.buckets.lock().unwrap().len(),
1287            0,
1288            "bucket should be removed after timeout"
1289        );
1290    }
1291
1292    #[tokio::test(start_paused = true)]
1293    async fn test_timeout_resets_on_new_exchange() {
1294        let config = AggregatorConfig::correlate_by("key")
1295            .complete_on_timeout(Duration::from_millis(150))
1296            .build()
1297            .unwrap();
1298        let mut svc = new_test_svc(config);
1299
1300        let ex1 = make_exchange("key", "A", "first");
1301        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1302
1303        tokio::time::sleep(Duration::from_millis(100)).await;
1304
1305        let ex2 = make_exchange("key", "A", "second");
1306        let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1307
1308        tokio::time::sleep(Duration::from_millis(100)).await;
1309
1310        assert_eq!(
1311            svc.buckets.lock().unwrap().len(),
1312            1,
1313            "bucket should still exist — timeout was reset"
1314        );
1315
1316        tokio::time::sleep(Duration::from_millis(100)).await;
1317
1318        assert_eq!(
1319            svc.buckets.lock().unwrap().len(),
1320            0,
1321            "bucket should be gone after timeout fires"
1322        );
1323    }
1324
1325    #[tokio::test]
1326    async fn test_composable_size_and_timeout() {
1327        let config = AggregatorConfig::correlate_by("key")
1328            .complete_on_size_or_timeout(2, Duration::from_millis(200))
1329            .build()
1330            .unwrap();
1331        let mut svc = new_test_svc(config);
1332
1333        let ex1 = make_exchange("key", "A", "first");
1334        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1335        assert!(svc.buckets.lock().unwrap().contains_key("\"A\""));
1336
1337        let ex2 = make_exchange("key", "A", "second");
1338        let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1339        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1340        assert_eq!(
1341            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1342            Some(&serde_json::json!("size"))
1343        );
1344    }
1345
1346    #[tokio::test(start_paused = true)]
1347    async fn test_discard_on_timeout() {
1348        let config = AggregatorConfig::correlate_by("key")
1349            .complete_on_timeout(Duration::from_millis(50))
1350            .discard_on_timeout(true)
1351            .build()
1352            .unwrap();
1353        let (tx, mut rx) = mpsc::channel(256);
1354        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1355        let cancel = CancellationToken::new();
1356        let mut svc = AggregatorService::new(config, tx, registry, cancel);
1357
1358        let ex = make_exchange("key", "A", "data");
1359        let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1360
1361        tokio::time::sleep(Duration::from_millis(100)).await;
1362
1363        assert!(
1364            rx.try_recv().is_err(),
1365            "no emit expected with discard_on_timeout"
1366        );
1367        assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1368        assert!(
1369            svc.timeout_tasks.lock().unwrap().is_empty(),
1370            "timeout task should be cleaned up"
1371        );
1372    }
1373
1374    #[tokio::test]
1375    async fn test_force_completion_on_stop() {
1376        let config = AggregatorConfig::correlate_by("key")
1377            .complete_when_size(10)
1378            .force_completion_on_stop(true)
1379            .build()
1380            .unwrap();
1381        let (tx, mut rx) = mpsc::channel(256);
1382        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1383        let cancel = CancellationToken::new();
1384        let svc = AggregatorService::new(config, tx, registry, cancel);
1385
1386        let mut call_svc = svc.clone();
1387        let ex = make_exchange("key", "A", "data");
1388        let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1389
1390        svc.force_complete_all();
1391
1392        let result = rx.try_recv().expect("should emit on force-complete");
1393        assert!(
1394            result.input.body.as_text().is_some() || matches!(result.input.body, Body::Json(_))
1395        );
1396        assert_eq!(
1397            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1398            Some(&serde_json::json!("stop"))
1399        );
1400    }
1401
1402    #[tokio::test]
1403    async fn test_completion_reason_property_size() {
1404        let config = AggregatorConfig::correlate_by("key")
1405            .complete_when_size(1)
1406            .build()
1407            .unwrap();
1408        let mut svc = new_test_svc(config);
1409        let ex = make_exchange("key", "X", "body");
1410        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1411        assert_eq!(
1412            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1413            Some(&serde_json::json!("size"))
1414        );
1415    }
1416
1417    #[tokio::test]
1418    async fn test_completion_reason_property_predicate() {
1419        let config = AggregatorConfig::correlate_by("key")
1420            .complete_when(|_| true)
1421            .build()
1422            .unwrap();
1423        let mut svc = new_test_svc(config);
1424        let ex = make_exchange("key", "X", "body");
1425        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1426        assert_eq!(
1427            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1428            Some(&serde_json::json!("predicate"))
1429        );
1430    }
1431
1432    #[tokio::test(start_paused = true)]
1433    async fn test_size_completes_before_timeout() {
1434        let config = AggregatorConfig::correlate_by("key")
1435            .complete_on_size_or_timeout(2, Duration::from_millis(200))
1436            .build()
1437            .unwrap();
1438        let mut svc = new_test_svc(config);
1439
1440        let ex1 = make_exchange("key", "A", "first");
1441        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1442
1443        let ex2 = make_exchange("key", "A", "second");
1444        let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1445
1446        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1447        assert_eq!(
1448            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1449            Some(&serde_json::json!("size"))
1450        );
1451        assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1452
1453        tokio::time::sleep(Duration::from_millis(300)).await;
1454        assert_eq!(
1455            svc.buckets.lock().unwrap().len(),
1456            0,
1457            "no re-fire after timeout"
1458        );
1459    }
1460
1461    #[tokio::test(start_paused = true)]
1462    async fn test_concurrent_timeout_fire_and_new_exchange() {
1463        let config = AggregatorConfig::correlate_by("key")
1464            .complete_on_size_or_timeout(2, Duration::from_millis(100))
1465            .build()
1466            .unwrap();
1467        let (tx, mut rx) = mpsc::channel(256);
1468        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1469        let cancel = CancellationToken::new();
1470        let mut svc = AggregatorService::new(config, tx, registry, cancel);
1471
1472        let ex = make_exchange("key", "A", "data");
1473        let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1474
1475        // Advance time past timeout — timeout task fires and removes bucket
1476        tokio::time::sleep(Duration::from_millis(150)).await;
1477
1478        // New exchange arrives after timeout — starts a fresh bucket
1479        let ex2 = make_exchange("key", "A", "data2");
1480        let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1481        assert!(
1482            result.property(CAMEL_AGGREGATOR_PENDING).is_some(),
1483            "should be pending in new bucket"
1484        );
1485
1486        // Drain late emits from timeout
1487        let mut late_count = 0;
1488        while rx.try_recv().is_ok() {
1489            late_count += 1;
1490        }
1491        assert_eq!(
1492            late_count, 1,
1493            "exactly 1 late emit from the timed-out bucket"
1494        );
1495    }
1496
1497    #[tokio::test(start_paused = true)]
1498    async fn test_late_channel_full_drops_with_warning() {
1499        let config = AggregatorConfig::correlate_by("key")
1500            .complete_on_timeout(Duration::from_millis(50))
1501            .build()
1502            .unwrap();
1503        let (tx, mut rx) = mpsc::channel(1);
1504        rx.close();
1505        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1506        let cancel = CancellationToken::new();
1507        let mut svc = AggregatorService::new(config, tx, registry, cancel);
1508
1509        let ex = make_exchange("key", "A", "data");
1510        let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1511
1512        tokio::time::sleep(Duration::from_millis(100)).await;
1513        assert_eq!(
1514            svc.buckets.lock().unwrap().len(),
1515            0,
1516            "bucket removed despite channel closed"
1517        );
1518    }
1519
1520    // D-A3 semantic pin: exercises the `force_complete_all()` -> `late_tx.try_send`
1521    // path specifically (not the timeout-task path covered by
1522    // `test_late_channel_full_drops_with_warning` above). Pre-saturates the
1523    // single-slot `late_tx` before constructing the service so every
1524    // force-completed exchange must drop on the `try_send` failure branch.
1525    #[tokio::test]
1526    async fn test_da3_force_complete_all_drops_on_saturated_channel() {
1527        let config = AggregatorConfig::correlate_by("k")
1528            .complete_when_size(10)
1529            .force_completion_on_stop(true)
1530            .build()
1531            .unwrap();
1532        // capacity 1 — deliberately tiny so the pre-fill fully saturates the slot.
1533        let (late_tx, mut late_rx) = mpsc::channel::<Exchange>(1);
1534        // Pre-saturate the 1-slot mpsc BEFORE the Sender is moved into
1535        // AggregatorService::new. The Sender is taken by value into the
1536        // service, so this `try_send` is the only chance to occupy the slot.
1537        late_tx
1538            .try_send(make_exchange("k", "99", "dummy"))
1539            .expect("pre-fill succeeds");
1540
1541        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1542        let cancel = CancellationToken::new();
1543        let mut svc = AggregatorService::new(config, late_tx, registry, cancel);
1544
1545        // Three distinct correlation keys -> three buckets, each holding one
1546        // exchange. size=10 means no bucket auto-completes.
1547        for v in ["1", "2", "3"] {
1548            let ex = make_exchange("k", v, "body");
1549            let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1550        }
1551        assert_eq!(svc.buckets.lock().unwrap().len(), 3);
1552
1553        // Action: drive the force-complete path.
1554        svc.force_complete_all();
1555
1556        // (a) the manually-sent pre-fill item is still in the channel.
1557        let pre_fill = late_rx
1558            .try_recv()
1559            .expect("pre-fill should still be in channel");
1560        assert_eq!(
1561            pre_fill.input.headers.get("k"),
1562            Some(&serde_json::json!("99"))
1563        );
1564
1565        // (b) channel drained -- no force-completed exchange got through
1566        // (all three try_send calls hit the full channel and dropped).
1567        assert!(matches!(
1568            late_rx.try_recv(),
1569            Err(mpsc::error::TryRecvError::Empty)
1570        ));
1571
1572        // (c) all three buckets were removed during force_complete_all.
1573        assert!(svc.buckets.lock().unwrap().is_empty());
1574    }
1575
1576    #[tokio::test]
1577    async fn test_aggregate_stream_bodies_creates_valid_json() {
1578        use bytes::Bytes;
1579        use camel_api::{Body, StreamBody, StreamMetadata};
1580        use futures::stream;
1581        use tokio::sync::Mutex;
1582
1583        let chunks = vec![Ok(Bytes::from("test"))];
1584        let stream_body = StreamBody {
1585            stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1586            metadata: StreamMetadata {
1587                origin: Some("file:///test.txt".to_string()),
1588                ..Default::default()
1589            },
1590        };
1591
1592        let ex1 = Exchange::new(Message {
1593            headers: Default::default(),
1594            body: Body::Stream(stream_body),
1595        });
1596
1597        let exchanges = vec![ex1];
1598        let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1599
1600        let exchange = result.expect("Expected Ok result");
1601        assert!(
1602            matches!(exchange.input.body, Body::Json(_)),
1603            "Expected Json body"
1604        );
1605
1606        if let Body::Json(value) = exchange.input.body {
1607            let json_str = serde_json::to_string(&value).unwrap();
1608            let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1609
1610            assert!(parsed.is_array(), "Result should be an array");
1611            let arr = parsed.as_array().unwrap();
1612            assert!(arr[0].is_object(), "First element should be an object");
1613            assert!(
1614                arr[0]["_stream"].is_object(),
1615                "Should contain _stream object"
1616            );
1617            assert_eq!(arr[0]["_stream"]["origin"], "file:///test.txt");
1618            assert_eq!(
1619                arr[0]["_stream"]["placeholder"], true,
1620                "placeholder flag should be true"
1621            );
1622        }
1623    }
1624
1625    #[tokio::test]
1626    async fn test_aggregate_stream_bodies_with_none_origin() {
1627        use bytes::Bytes;
1628        use camel_api::{Body, StreamBody, StreamMetadata};
1629        use futures::stream;
1630        use tokio::sync::Mutex;
1631
1632        let chunks = vec![Ok(Bytes::from("test"))];
1633        let stream_body = StreamBody {
1634            stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1635            metadata: StreamMetadata {
1636                origin: None,
1637                ..Default::default()
1638            },
1639        };
1640
1641        let ex1 = Exchange::new(Message {
1642            headers: Default::default(),
1643            body: Body::Stream(stream_body),
1644        });
1645
1646        let exchanges = vec![ex1];
1647        let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1648
1649        let exchange = result.expect("Expected Ok result");
1650        assert!(
1651            matches!(exchange.input.body, Body::Json(_)),
1652            "Expected Json body"
1653        );
1654
1655        if let Body::Json(value) = exchange.input.body {
1656            let json_str = serde_json::to_string(&value).unwrap();
1657            let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1658
1659            assert!(parsed.is_array(), "Result should be an array");
1660            let arr = parsed.as_array().unwrap();
1661            assert!(arr[0].is_object(), "First element should be an object");
1662            assert!(
1663                arr[0]["_stream"].is_object(),
1664                "Should contain _stream object"
1665            );
1666            assert_eq!(
1667                arr[0]["_stream"]["origin"],
1668                serde_json::Value::Null,
1669                "origin should be null when None"
1670            );
1671            assert_eq!(
1672                arr[0]["_stream"]["placeholder"], true,
1673                "placeholder flag should be true"
1674            );
1675        }
1676    }
1677
1678    #[tokio::test]
1679    async fn timeout_completion_clears_handle_from_map() {
1680        // Regression: Oracle audit found that natural timeout completion removed
1681        // the bucket but left the JoinHandle in `timeout_handles`, leaking the
1682        // entry until route shutdown. After fix, the timeout task itself cleans
1683        // its handle from the map on natural completion.
1684        let config = AggregatorConfig::correlate_by("key")
1685            .complete_on_timeout(Duration::from_millis(50))
1686            .build()
1687            .unwrap();
1688        let (tx, _rx) = mpsc::channel(256);
1689        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1690        let cancel = CancellationToken::new();
1691        let svc = AggregatorService::new(config, tx, registry, cancel);
1692
1693        // Send an exchange to create a pending bucket with a timeout task.
1694        let mut call_svc = svc.clone();
1695        let ex = make_exchange("key", "A", "data");
1696        let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1697        assert!(
1698            !svc.timeout_handles.lock().unwrap().is_empty(),
1699            "handle should exist while timeout pending"
1700        );
1701
1702        // Wait real time for the 50ms timeout to fire + spawned task to complete.
1703        tokio::time::sleep(Duration::from_millis(200)).await;
1704
1705        assert!(
1706            svc.timeout_handles.lock().unwrap().is_empty(),
1707            "handle should be cleared from map after natural timeout completion (was leak)"
1708        );
1709    }
1710
1711    #[tokio::test]
1712    async fn aggregator_shutdown_via_trait_dispatch() {
1713        // RED: builds an AggregatorService, dispatches through Arc<dyn StepLifecycle>,
1714        // and asserts idempotent shutdown works.
1715        let config = AggregatorConfig::correlate_by("key")
1716            .complete_when_size(10)
1717            .build()
1718            .unwrap();
1719        let (tx, _rx) = mpsc::channel(256);
1720        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1721        let cancel = CancellationToken::new();
1722        let svc = AggregatorService::new(config, tx, registry, cancel);
1723
1724        let step: Arc<dyn StepLifecycle> = Arc::new(svc);
1725        step.shutdown(StepShutdownReason::RouteStop)
1726            .await
1727            .expect("first shutdown should succeed");
1728        step.shutdown(StepShutdownReason::RouteStop)
1729            .await
1730            .expect("second shutdown (idempotent) should succeed");
1731    }
1732
1733    #[tokio::test(start_paused = true)]
1734    async fn test_shutdown_awaits_timeout_handles() {
1735        let config = AggregatorConfig::correlate_by("key")
1736            .complete_on_timeout(Duration::from_millis(100))
1737            .build()
1738            .unwrap();
1739        let (tx, _rx) = mpsc::channel(256);
1740        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1741        let cancel = CancellationToken::new();
1742        let svc = AggregatorService::new(config, tx, registry, cancel);
1743
1744        // Send an exchange to create a pending bucket with a timeout task.
1745        let mut call_svc = svc.clone();
1746        let ex = make_exchange("key", "A", "data");
1747        let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1748
1749        // Verify timeout handle exists.
1750        assert!(
1751            !svc.timeout_handles.lock().unwrap().is_empty(),
1752            "should have a timeout handle"
1753        );
1754
1755        // Shutdown should complete within the 5s deadline (the timeout task
1756        // gets cancelled so it won't wait for the full 100ms sleep).
1757        svc.shutdown_inner().await;
1758
1759        assert!(
1760            svc.timeout_handles.lock().unwrap().is_empty(),
1761            "all handles should be cleaned up after shutdown"
1762        );
1763    }
1764
1765    // ── R3-C1 Batch 1: DoS cap + background sweep ───────────────────
1766
1767    /// R3-C1: a flood of unique correlation keys must stay bounded.
1768    /// Default `max_buckets` is 10_000; the 10_001st unique key is rejected
1769    /// with `Aggregator reached maximum N buckets` (or its updated equivalent
1770    /// after the fix). The unique-key flood does NOT OOM the process.
1771    #[tokio::test]
1772    async fn test_unique_key_flood_stays_bounded_by_default() {
1773        // Builder defaults to max_buckets = 10_000, bucket_ttl = 300s.
1774        let config = AggregatorConfig::correlate_by("orderId")
1775            .complete_when_size(1_000_000) // never completes normally
1776            .build()
1777            .unwrap();
1778        let mut svc = new_test_svc(config);
1779
1780        // Send 10_001 unique keys. The first 10_000 should be accepted
1781        // (pending in their buckets); the 10_001st MUST be rejected.
1782        for i in 0..10_000usize {
1783            let ex = make_exchange("orderId", &format!("key-{i}"), "body");
1784            let result = svc.ready().await.unwrap().call(ex).await;
1785            assert!(result.is_ok(), "key {i} should be accepted under the cap");
1786        }
1787        let ex = make_exchange("orderId", "key-10001", "body");
1788        let result = svc.ready().await.unwrap().call(ex).await;
1789        assert!(
1790            result.is_err(),
1791            "10_001st unique key must be rejected by the max_buckets cap"
1792        );
1793        let err = result.unwrap_err().to_string();
1794        assert!(
1795            err.contains("maximum") || err.contains("max"),
1796            "error should mention cap: {err}"
1797        );
1798    }
1799
1800    /// The `AggregatorService` exposes a `sweep_handle` for the background
1801    /// sweep task. When `config.bucket_ttl` is `Some`, `AggregatorService::new`
1802    /// automatically spawns the sweep task and stores the handle, so the
1803    /// caller never sees `None` for a TTL-configured service. Cancelling the
1804    /// route token (via `shutdown`) aborts the sweep.
1805    #[tokio::test]
1806    async fn test_background_sweep_spawns_on_first_poll_not_construction() {
1807        let config = AggregatorConfig::correlate_by("key")
1808            .complete_when_size(10_000)
1809            .bucket_ttl(Duration::from_millis(50))
1810            .build()
1811            .unwrap();
1812        let cancel = CancellationToken::new();
1813        let (tx, _rx) = mpsc::channel(8);
1814        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1815        let mut svc = AggregatorService::new(config, tx, registry, cancel.clone());
1816
1817        // Construction is runtime-free: no sweep spawned yet.
1818        assert!(
1819            svc.sweep_handle
1820                .lock()
1821                .unwrap_or_else(|e| e.into_inner())
1822                .is_none(),
1823            "sweep must NOT be spawned at construction (runtime-free new)"
1824        );
1825
1826        // First readiness poll lazily spawns the sweep (a runtime is present
1827        // here). `ready()` drives `poll_ready` until Ready.
1828        let _ = svc.ready().await.unwrap();
1829        let sweep_present = svc
1830            .sweep_handle
1831            .lock()
1832            .unwrap_or_else(|e| e.into_inner())
1833            .is_some();
1834        assert!(
1835            sweep_present,
1836            "sweep handle should be Some after first poll when bucket_ttl is set"
1837        );
1838
1839        // Cancel the route token; the sweep task observes it and exits.
1840        cancel.cancel();
1841        // Give the task a moment to observe the cancel.
1842        tokio::time::sleep(Duration::from_millis(50)).await;
1843    }
1844
1845    /// Shared `MetricsCollector` double that logs every `set_queue_depth`
1846    /// call (queue label, depth). Used by the queue-depth sampling tests.
1847    struct QueueDepthRecorder(Mutex<Vec<(String, usize)>>);
1848    impl MetricsCollector for QueueDepthRecorder {
1849        fn record_exchange_duration(&self, _: &str, _: std::time::Duration) {}
1850        fn increment_errors(&self, _: &str, _: &str) {}
1851        fn increment_exchanges(&self, _: &str) {}
1852        fn set_queue_depth(&self, queue: &str, depth: usize) {
1853            self.0
1854                .lock()
1855                .unwrap_or_else(|e| e.into_inner())
1856                .push((queue.to_string(), depth));
1857        }
1858        fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
1859    }
1860
1861    /// Poll the recorder until a sample for `label` matches `pred`, or fail
1862    /// after `deadline`. Keeps the sampling tests scheduling-tolerant.
1863    async fn await_depth_sample(
1864        recorder: &QueueDepthRecorder,
1865        label: &str,
1866        pred: impl Fn(usize) -> bool,
1867    ) {
1868        let deadline = std::time::Instant::now() + Duration::from_secs(2);
1869        loop {
1870            let matched = recorder
1871                .0
1872                .lock()
1873                .unwrap_or_else(|e| e.into_inner())
1874                .iter()
1875                .any(|(q, d)| q == label && pred(*d));
1876            if matched {
1877                return;
1878            }
1879            assert!(
1880                std::time::Instant::now() < deadline,
1881                "no queue-depth sample for '{label}' matched within 2s"
1882            );
1883            tokio::time::sleep(Duration::from_millis(20)).await;
1884        }
1885    }
1886
1887    /// Queue-depth sampling (dashboard-observability T3.3): when
1888    /// `with_queue_metrics` is set, the TTL-sweep maintenance pass publishes
1889    /// the buffered group count, and the count returns to zero once the
1890    /// group completes.
1891    #[tokio::test]
1892    async fn test_sweep_reports_queue_depth() {
1893        let config = AggregatorConfig::correlate_by("orderId")
1894            .complete_when_size(3)
1895            // Short TTL keeps the sweep interval at its 50ms floor so the
1896            // test observes samples quickly; the bucket is completed well
1897            // before eviction.
1898            .bucket_ttl(Duration::from_millis(100))
1899            .build()
1900            .unwrap();
1901        let cancel = CancellationToken::new();
1902        let (tx, _rx) = mpsc::channel(8);
1903        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1904        let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
1905        let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
1906            Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
1907            "aggregator:t",
1908        );
1909
1910        // Spawn the sweep via the first readiness poll.
1911        let _ = svc.ready().await.unwrap();
1912
1913        // Partial group: 1 of 3 messages.
1914        let ex = make_exchange("orderId", "g1", "partial");
1915        let _ = svc.ready().await.unwrap().call(ex).await;
1916
1917        await_depth_sample(&recorder, "aggregator:t", |d| d > 0).await;
1918
1919        // Complete the group; the sweep must report zero again.
1920        let ex = make_exchange("orderId", "g1", "b");
1921        let _ = svc.ready().await.unwrap().call(ex).await;
1922        let ex = make_exchange("orderId", "g1", "c");
1923        let _ = svc.ready().await.unwrap().call(ex).await;
1924
1925        await_depth_sample(&recorder, "aggregator:t", |d| d == 0).await;
1926
1927        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
1928    }
1929
1930    /// F2 (T3.3 review): a no-TTL aggregator (size-only completion,
1931    /// `bucket_ttl = None`) with `queue_metrics` set must still sample —
1932    /// the sweep used to live entirely inside the TTL branch and never ran.
1933    /// Eviction stays TTL-gated; only sampling runs.
1934    #[tokio::test]
1935    async fn test_no_ttl_aggregator_reports_queue_depth() {
1936        use camel_api::aggregator::CorrelationStrategy;
1937
1938        let config = AggregatorConfig {
1939            header_name: "orderId".into(),
1940            completion: CompletionMode::Single(CompletionCondition::Size(3)),
1941            correlation: CorrelationStrategy::HeaderName("orderId".into()),
1942            strategy: AggregationStrategy::CollectAll,
1943            max_buckets: Some(100),
1944            max_bucket_size: Some(100),
1945            bucket_ttl: None,
1946            force_completion_on_stop: false,
1947            discard_on_timeout: false,
1948            max_timeout_tasks: 64,
1949        };
1950        let cancel = CancellationToken::new();
1951        let (tx, _rx) = mpsc::channel(8);
1952        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1953        let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
1954        let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
1955            Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
1956            "aggregator:nottl",
1957        );
1958
1959        let _ = svc.ready().await.unwrap();
1960        assert!(
1961            svc.sweep_handle
1962                .lock()
1963                .unwrap_or_else(|e| e.into_inner())
1964                .is_some(),
1965            "metrics-only config (bucket_ttl = None) must still spawn the sweep"
1966        );
1967
1968        // Partial group: 1 of 3 messages — depth must publish > 0.
1969        let ex = make_exchange("orderId", "g1", "partial");
1970        let _ = svc.ready().await.unwrap().call(ex).await;
1971        await_depth_sample(&recorder, "aggregator:nottl", |d| d > 0).await;
1972
1973        // Complete the group; depth must publish 0 again.
1974        let ex = make_exchange("orderId", "g1", "b");
1975        let _ = svc.ready().await.unwrap().call(ex).await;
1976        let ex = make_exchange("orderId", "g1", "c");
1977        let _ = svc.ready().await.unwrap().call(ex).await;
1978        await_depth_sample(&recorder, "aggregator:nottl", |d| d == 0).await;
1979
1980        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
1981    }
1982
1983    /// F3 (T3.3 review): the last-owner guard — the pipeline clones the
1984    /// service on every `poll_ready`; a transient clone dropping must NOT
1985    /// abort the shared sweep. The sweep keeps ticking (depth still
1986    /// published) until the final owner drops.
1987    #[tokio::test]
1988    async fn test_transient_clone_drop_keeps_sweep_sampling() {
1989        let config = AggregatorConfig::correlate_by("orderId")
1990            .complete_when_size(3)
1991            .bucket_ttl(Duration::from_millis(100))
1992            .build()
1993            .unwrap();
1994        let cancel = CancellationToken::new();
1995        let (tx, _rx) = mpsc::channel(8);
1996        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1997        let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
1998        let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
1999            Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
2000            "aggregator:clone",
2001        );
2002
2003        let _ = svc.ready().await.unwrap();
2004
2005        // Transient clone drops (as the pipeline does per poll_ready).
2006        drop(svc.clone());
2007        assert!(
2008            svc.sweep_handle
2009                .lock()
2010                .unwrap_or_else(|e| e.into_inner())
2011                .is_some(),
2012            "transient clone drop must not abort the shared sweep"
2013        );
2014
2015        // The sweep still ticks: a partial group produces depth samples.
2016        let ex = make_exchange("orderId", "g1", "partial");
2017        let _ = svc.ready().await.unwrap().call(ex).await;
2018        await_depth_sample(&recorder, "aggregator:clone", |d| d > 0).await;
2019
2020        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2021    }
2022
2023    // ── R3-M3: bounded timeout-task spawn ─────────────────────────────
2024
2025    #[tokio::test]
2026    async fn test_aggregator_timeout_task_cap_no_panic_under_flood() {
2027        // R3-M3: a flood of unique keys with a tiny max_timeout_tasks must not
2028        // spawn unbounded tasks, panic, or deadlock. Each call returns Ok(pending).
2029        use camel_api::aggregator::CorrelationStrategy;
2030
2031        let config = AggregatorConfig {
2032            header_name: "k".into(),
2033            completion: CompletionMode::Any(vec![
2034                CompletionCondition::Size(999),
2035                CompletionCondition::Timeout(Duration::from_secs(30)),
2036            ]),
2037            correlation: CorrelationStrategy::HeaderName("k".into()),
2038            strategy: AggregationStrategy::CollectAll,
2039            max_buckets: Some(50),
2040            max_bucket_size: Some(50),
2041            bucket_ttl: Some(Duration::from_secs(30)),
2042            force_completion_on_stop: false,
2043            discard_on_timeout: false,
2044            max_timeout_tasks: 2,
2045        };
2046        let (late_tx, mut late_rx) = mpsc::channel(64);
2047        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2048        let cancel = CancellationToken::new();
2049        let svc = AggregatorService::new(config, late_tx, registry, cancel);
2050
2051        // Drive 20 unique-key exchanges — far exceeding max_timeout_tasks=2.
2052        for i in 0..20u64 {
2053            let mut ex = Exchange::new(Message {
2054                headers: HashMap::from([("k".to_string(), serde_json::json!(i))]),
2055                body: Body::Text(i.to_string()),
2056            });
2057            ex.input
2058                .headers
2059                .insert("k".to_string(), serde_json::json!(i));
2060            let outcome = tokio::time::timeout(Duration::from_secs(2), async {
2061                let mut s = svc.clone();
2062                use tower::ServiceExt;
2063                s.ready().await.unwrap().call(ex).await
2064            })
2065            .await;
2066            assert!(outcome.is_ok(), "call {} hung/panicked under task cap", i);
2067            // Each returns Ok(pending) since Size(999) is never reached.
2068            let res = outcome.unwrap().unwrap();
2069            assert_eq!(
2070                res.properties
2071                    .get(CAMEL_AGGREGATOR_PENDING)
2072                    .and_then(|v| v.as_bool()),
2073                Some(true),
2074                "exchange {} should be pending",
2075                i
2076            );
2077        }
2078        // Drain any late emissions to avoid blocking the channel.
2079        let _ = late_rx.try_recv();
2080    }
2081
2082    #[tokio::test]
2083    async fn evaluate_completion_predicate_or_combines_any() {
2084        use camel_api::aggregator::{CompletionCondition, CompletionMode};
2085        use camel_language_api::Language;
2086        use std::collections::HashMap;
2087
2088        // Register the simple language in an otherwise-empty registry.
2089        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2090        registry.lock().unwrap().insert(
2091            "simple".to_string(),
2092            Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2093        );
2094
2095        let incoming = make_exchange("k", "X", "hello");
2096
2097        // Any with two PredicateExpr conditions; second matches body == "hello".
2098        let mode = CompletionMode::Any(vec![
2099            CompletionCondition::PredicateExpr {
2100                expr: "${body} == 'NOPE'".to_string(),
2101                language: "simple".to_string(),
2102            },
2103            CompletionCondition::PredicateExpr {
2104                expr: "${body} == 'hello'".to_string(),
2105                language: "simple".to_string(),
2106            },
2107        ]);
2108
2109        let satisfied = evaluate_completion_predicate(&mode, &incoming, &registry)
2110            .await
2111            .expect("eval must succeed");
2112        assert!(satisfied, "second predicate matches → OR true");
2113    }
2114
2115    #[tokio::test]
2116    async fn evaluate_completion_predicate_no_predicate_skips_registry() {
2117        use camel_api::aggregator::{CompletionCondition, CompletionMode};
2118        use std::collections::HashMap;
2119
2120        // Size-only completion: iter_predicate_exprs returns empty vec.
2121        // The registry is EMPTY — any accidental lock-and-get would fail loudly,
2122        // proving the fast path does not touch the registry.
2123        let mode = CompletionMode::Single(CompletionCondition::Size(3));
2124        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2125        let incoming = make_exchange("k", "X", "hello");
2126        let result = evaluate_completion_predicate(&mode, &incoming, &registry).await;
2127        assert!(result.is_ok(), "fast path must not error: {:?}", result);
2128        assert!(!result.unwrap(), "no predicate → not satisfied");
2129    }
2130
2131    #[tokio::test]
2132    async fn evaluate_completion_predicate_unregistered_language_errors() {
2133        use camel_api::aggregator::{CompletionCondition, CompletionMode};
2134
2135        let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
2136            expr: "${body} == 'DONE'".to_string(),
2137            language: "nonexistent-lang".to_string(),
2138        });
2139        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2140        let incoming = make_exchange("k", "X", "hello");
2141        let result = evaluate_completion_predicate(&mode, &incoming, &registry).await;
2142        assert!(result.is_err(), "unregistered language must error");
2143    }
2144
2145    #[tokio::test]
2146    async fn evaluate_completion_predicate_all_miss_returns_false() {
2147        use camel_api::aggregator::{CompletionCondition, CompletionMode};
2148        use camel_language_api::Language;
2149        use std::collections::HashMap;
2150
2151        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2152        registry.lock().unwrap().insert(
2153            "simple".to_string(),
2154            Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2155        );
2156
2157        let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
2158            expr: "${body} == 'NOPE'".to_string(),
2159            language: "simple".to_string(),
2160        });
2161        let incoming = make_exchange("k", "X", "hello");
2162        let result = evaluate_completion_predicate(&mode, &incoming, &registry).await;
2163        assert!(result.is_ok(), "eval must succeed: {:?}", result);
2164        assert!(!result.unwrap(), "predicate does not match");
2165    }
2166
2167    #[tokio::test]
2168    async fn completion_predicate_expr_completes_on_match() {
2169        use camel_api::aggregator::{CompletionCondition, CompletionMode};
2170        use camel_language_api::Language;
2171        use std::collections::HashMap;
2172
2173        // Build a registry with the simple language registered.
2174        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2175        registry.lock().unwrap().insert(
2176            "simple".to_string(),
2177            Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2178        );
2179
2180        // Build the config via the builder for correlation + size defaults
2181        // (required by `AggregatorService::new`'s validate()), then OVERRIDE
2182        // the completion field directly — there is no builder setter for
2183        // `PredicateExpr`.
2184        let mut config = AggregatorConfig::correlate_by("key")
2185            .complete_when_size(999) // placeholder; replaced below
2186            .build()
2187            .expect("config builds");
2188        config.completion = CompletionMode::Single(CompletionCondition::PredicateExpr {
2189            expr: "${body} == 'DONE'".to_string(),
2190            language: "simple".to_string(),
2191        });
2192
2193        let mut svc = new_test_svc_with_registry(config, registry);
2194
2195        // First exchange — predicate false → still pending.
2196        let r = svc
2197            .ready()
2198            .await
2199            .unwrap()
2200            .call(make_exchange("key", "K", "first"))
2201            .await
2202            .unwrap();
2203        assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_some());
2204
2205        // Matching exchange — predicate true → bucket completes.
2206        let r = svc
2207            .ready()
2208            .await
2209            .unwrap()
2210            .call(make_exchange("key", "K", "DONE"))
2211            .await
2212            .unwrap();
2213        assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_none());
2214        assert_eq!(
2215            r.property(CAMEL_AGGREGATED_COMPLETION_REASON),
2216            Some(&serde_json::json!("predicate"))
2217        );
2218    }
2219
2220    // ── ADR-0046 D-A1: binary-fold strategy contract (no null oldExchange) ──
2221    //
2222    // Apache Camel's AggregationStrategy receives a null `oldExchange` on the
2223    // FIRST message of a bucket so the strategy can initialize. rust-camel's
2224    // AggregationFn has a binary signature `(Exchange, Exchange) -> Exchange`
2225    // and the bucket model holds the first message untouched; the strategy is
2226    // only first invoked on the SECOND message with both exchanges present.
2227    //
2228    // This test pins that contract: a strategy needing initialize-on-first
2229    // logic cannot rely on a null oldExchange — it must branch on a sentinel
2230    // in the accumulated body or on a property.
2231    #[tokio::test]
2232    async fn test_da1_strategy_receives_two_exchanges_first_message_preserved() {
2233        use camel_api::aggregator::{AggregationFn, AggregationStrategy};
2234        use std::sync::Arc;
2235
2236        let recorded: Arc<std::sync::Mutex<Vec<(String, String)>>> =
2237            Arc::new(std::sync::Mutex::new(Vec::new()));
2238        let recorded_for_closure = Arc::clone(&recorded);
2239
2240        let f: AggregationFn = Arc::new(move |old: Exchange, new: Exchange| {
2241            let old_body = old.input.body.as_text().unwrap_or("").to_string();
2242            let new_body = new.input.body.as_text().unwrap_or("").to_string();
2243            recorded_for_closure
2244                .lock()
2245                .expect("recorded mutex poisoned")
2246                .push((old_body, new_body));
2247            new
2248        });
2249
2250        let config = AggregatorConfig::correlate_by("k")
2251            .complete_when_size(2)
2252            .strategy(AggregationStrategy::Custom(f))
2253            .build()
2254            .unwrap();
2255        let mut svc = new_test_svc(config);
2256
2257        // First message: bucket goes 0→1, strategy NOT invoked, return pending.
2258        let first = svc
2259            .ready()
2260            .await
2261            .unwrap()
2262            .call(make_exchange("k", "1", "A"))
2263            .await
2264            .unwrap();
2265        assert!(
2266            first.property(CAMEL_AGGREGATOR_PENDING).is_some(),
2267            "first message must leave the bucket pending (size < 2)"
2268        );
2269        assert!(
2270            recorded.lock().expect("recorded mutex poisoned").is_empty(),
2271            "strategy must NOT be invoked on the first message of a bucket"
2272        );
2273
2274        // Second message: bucket goes 1→2, strategy IS invoked as f(ex1, ex2),
2275        // and the result is emitted.
2276        let _completed = svc
2277            .ready()
2278            .await
2279            .unwrap()
2280            .call(make_exchange("k", "1", "B"))
2281            .await
2282            .unwrap();
2283
2284        let recorded = recorded.lock().expect("recorded mutex poisoned");
2285        assert_eq!(
2286            recorded.len(),
2287            1,
2288            "strategy must be invoked exactly once across the two-message bucket, got {recorded:?}"
2289        );
2290        assert_eq!(
2291            recorded[0],
2292            ("A".to_string(), "B".to_string()),
2293            "strategy must observe the first message as `old` and the second as `new`, \
2294             with both bodies preserved unchanged"
2295        );
2296    }
2297
2298    // ── Sweep lifecycle tests (aggregate-route-cancel-threading) ──────
2299
2300    #[tokio::test]
2301    async fn sweep_shutdown_cancels_task() {
2302        let config = AggregatorConfig::correlate_by("key")
2303            .complete_when_size(10)
2304            .bucket_ttl(Duration::from_millis(100))
2305            .build()
2306            .unwrap();
2307        let (tx, _rx) = mpsc::channel(256);
2308        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2309        let cancel = CancellationToken::new();
2310        let mut svc = AggregatorService::new(config, tx, registry, cancel);
2311
2312        let _ = svc.ready().await.unwrap();
2313
2314        assert!(
2315            svc.sweep_handle
2316                .lock()
2317                .unwrap_or_else(|e| e.into_inner())
2318                .is_some(),
2319            "sweep handle should be Some after poll_ready"
2320        );
2321
2322        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2323
2324        assert!(
2325            svc.sweep_handle
2326                .lock()
2327                .unwrap_or_else(|e| e.into_inner())
2328                .is_none(),
2329            "sweep handle should be None after shutdown (taken + aborted)"
2330        );
2331        assert!(
2332            svc.sweep_cancel
2333                .lock()
2334                .unwrap_or_else(|e| e.into_inner())
2335                .is_cancelled(),
2336            "sweep_cancel token should be cancelled after shutdown"
2337        );
2338
2339        tokio::time::sleep(Duration::from_millis(50)).await;
2340    }
2341
2342    #[tokio::test]
2343    async fn sweep_start_respawns_after_shutdown() {
2344        let config = AggregatorConfig::correlate_by("key")
2345            .complete_when_size(10)
2346            .bucket_ttl(Duration::from_millis(100))
2347            .build()
2348            .unwrap();
2349        let (tx, _rx) = mpsc::channel(256);
2350        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2351        let cancel = CancellationToken::new();
2352        let mut svc = AggregatorService::new(config, tx, registry, cancel);
2353
2354        let _ = svc.ready().await.unwrap();
2355        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2356
2357        svc.start().await.unwrap();
2358        let _ = svc.ready().await.unwrap();
2359
2360        assert!(
2361            svc.sweep_handle
2362                .lock()
2363                .unwrap_or_else(|e| e.into_inner())
2364                .is_some(),
2365            "sweep handle should be Some after start + poll_ready"
2366        );
2367        assert!(
2368            !svc.sweep_cancel
2369                .lock()
2370                .unwrap_or_else(|e| e.into_inner())
2371                .is_cancelled(),
2372            "sweep_cancel should be a fresh uncancelled token after start"
2373        );
2374    }
2375
2376    #[tokio::test]
2377    async fn sweep_shutdown_hotswap_cancels_task() {
2378        let config = AggregatorConfig::correlate_by("key")
2379            .complete_when_size(10)
2380            .bucket_ttl(Duration::from_millis(100))
2381            .build()
2382            .unwrap();
2383        let (tx, _rx) = mpsc::channel(256);
2384        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2385        let cancel = CancellationToken::new();
2386        let mut svc = AggregatorService::new(config, tx, registry, cancel);
2387
2388        let _ = svc.ready().await.unwrap();
2389
2390        assert!(
2391            svc.sweep_handle
2392                .lock()
2393                .unwrap_or_else(|e| e.into_inner())
2394                .is_some(),
2395            "sweep handle should be Some after poll_ready"
2396        );
2397
2398        svc.shutdown(StepShutdownReason::HotSwap).await.unwrap();
2399
2400        assert!(
2401            svc.sweep_handle
2402                .lock()
2403                .unwrap_or_else(|e| e.into_inner())
2404                .is_none(),
2405            "sweep handle should be None after HotSwap shutdown"
2406        );
2407        assert!(
2408            svc.sweep_cancel
2409                .lock()
2410                .unwrap_or_else(|e| e.into_inner())
2411                .is_cancelled(),
2412            "sweep_cancel token should be cancelled after HotSwap shutdown"
2413        );
2414
2415        tokio::time::sleep(Duration::from_millis(50)).await;
2416    }
2417}