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