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