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    }
489}
490
491/// Yield `(&expr, &language)` for every `PredicateExpr` condition in `mode`.
492fn iter_predicate_exprs(mode: &CompletionMode) -> Vec<(&String, &String)> {
493    let mut out = Vec::new();
494    match mode {
495        CompletionMode::Single(CompletionCondition::PredicateExpr { expr, language }) => {
496            out.push((expr, language));
497        }
498        CompletionMode::Any(conds) => {
499            for c in conds {
500                if let CompletionCondition::PredicateExpr { expr, language } = c {
501                    out.push((expr, language));
502                }
503            }
504        }
505        _ => {}
506    }
507    out
508}
509
510/// Pre-evaluate ALL `PredicateExpr` conditions against the incoming exchange,
511/// OR-combining (matches `CompletionMode::Any` semantics). MUST be called BEFORE
512/// the `buckets` lock — it awaits the language registry and cannot run under the
513/// `!Send` `buckets` MutexGuard. Mirrors `extract_correlation_key`.
514async fn evaluate_completion_predicate(
515    mode: &CompletionMode,
516    incoming: &Exchange,
517    registry: &SharedLanguageRegistry,
518) -> Result<bool, CamelError> {
519    let mut satisfied = false;
520    for (expr, language) in iter_predicate_exprs(mode) {
521        let expression = {
522            let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
523            let lang = reg.get(language).ok_or_else(|| {
524                CamelError::ProcessorError(format!(
525                    "Aggregator: language '{}' not found in registry",
526                    language
527                ))
528            })?;
529            lang.create_expression(expr)
530                .map_err(|e| CamelError::ProcessorError(e.to_string()))?
531        }; // registry guard dropped here — safe to await below
532        let value = expression
533            .evaluate(incoming)
534            .await
535            .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
536        if value.as_bool().unwrap_or(false) {
537            satisfied = true;
538        }
539    }
540    Ok(satisfied)
541}
542
543fn check_sync_completion(
544    mode: &CompletionMode,
545    exchanges: &[Exchange],
546    predicate_satisfied: bool,
547) -> (bool, CompletionReason) {
548    match mode {
549        CompletionMode::Single(cond) => check_single(cond, exchanges, predicate_satisfied),
550        CompletionMode::Any(conditions) => {
551            for cond in conditions {
552                if let CompletionCondition::Timeout(_) = cond {
553                    continue;
554                }
555                let (done, reason) = check_single(cond, exchanges, predicate_satisfied);
556                if done {
557                    return (true, reason);
558                }
559            }
560            (false, CompletionReason::Size)
561        }
562    }
563}
564
565fn check_single(
566    cond: &CompletionCondition,
567    exchanges: &[Exchange],
568    predicate_satisfied: bool,
569) -> (bool, CompletionReason) {
570    match cond {
571        CompletionCondition::Size(n) => (exchanges.len() >= *n, CompletionReason::Size),
572        CompletionCondition::Predicate(pred) => (pred(exchanges), CompletionReason::Predicate),
573        CompletionCondition::PredicateExpr { .. } => {
574            (predicate_satisfied, CompletionReason::Predicate)
575        }
576        CompletionCondition::Timeout(_) => (false, CompletionReason::Timeout),
577    }
578}
579
580fn extract_timeout_duration(mode: &CompletionMode) -> Option<Duration> {
581    match mode {
582        CompletionMode::Single(CompletionCondition::Timeout(d)) => Some(*d),
583        CompletionMode::Any(conditions) => conditions.iter().find_map(|c| {
584            if let CompletionCondition::Timeout(d) = c {
585                Some(*d)
586            } else {
587                None
588            }
589        }),
590        _ => None,
591    }
592}
593
594fn cancel_timeout_task(key: &str, timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>) {
595    let mut guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
596    if let Some(token) = guard.remove(key) {
597        token.cancel();
598    }
599}
600
601/// Also removes the stored JoinHandle for a cancelled/completed timeout task.
602fn cancel_timeout_task_with_handle(
603    key: &str,
604    timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>,
605    timeout_handles: &Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
606) {
607    cancel_timeout_task(key, timeout_tasks);
608    let mut guard = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
609    guard.remove(key);
610}
611
612#[allow(clippy::too_many_arguments)]
613fn spawn_timeout_task(
614    key: String,
615    timeout: Duration,
616    cancel: CancellationToken,
617    buckets: Arc<Mutex<HashMap<String, Bucket>>>,
618    timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
619    timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
620    late_tx: mpsc::Sender<Exchange>,
621    strategy: AggregationStrategy,
622    discard: bool,
623    _route_cancel: CancellationToken,
624) -> JoinHandle<()> {
625    let cancel_clone = cancel.clone();
626    tokio::spawn(async move {
627        tokio::select! {
628            _ = tokio::time::sleep(timeout) => {
629                let should_proceed = {
630                    let mut tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
631                    if cancel_clone.is_cancelled() {
632                        false
633                    } else {
634                        tt_guard.remove(&key);
635                        true
636                    }
637                };
638                if !should_proceed {
639                    return;
640                }
641                // Clean up our own JoinHandle from the map on natural completion.
642                // Without this, the handle entry leaks until route shutdown.
643                {
644                    let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
645                    hh.remove(&key);
646                }
647                let bucket_exchanges = {
648                    let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
649                    guard.remove(&key).map(|b| b.exchanges)
650                };
651                if let Some(exchanges) = bucket_exchanges
652                    && !discard
653                {
654                    match aggregate(exchanges, &strategy) {
655                        Ok(mut result) => {
656                            result.set_property(
657                                CAMEL_AGGREGATED_COMPLETION_REASON,
658                                serde_json::json!(CompletionReason::Timeout.as_str()),
659                            );
660                            if late_tx.try_send(result).is_err() {
661                                tracing::warn!(
662                                    key = %key,
663                                    "aggregator timeout emit dropped: late channel full"
664                                );
665                            }
666                        }
667                        Err(e) => {
668                            // log-policy: handler-owned
669                            tracing::warn!(
670                                key = %key,
671                                error = %e,
672                                "aggregation failed in timeout task"
673                            );
674                        }
675                    }
676                }
677            }
678            _ = cancel_clone.cancelled() => {}
679        }
680    })
681}
682
683fn aggregate(
684    exchanges: Vec<Exchange>,
685    strategy: &AggregationStrategy,
686) -> Result<Exchange, CamelError> {
687    match strategy {
688        AggregationStrategy::CollectAll => {
689            let bodies: Vec<serde_json::Value> = exchanges
690                .into_iter()
691                .map(|e| match e.input.body {
692                    Body::Json(v) => v,
693                    Body::Text(s) => serde_json::Value::String(s),
694                    Body::Xml(s) => serde_json::Value::String(s),
695                    Body::Bytes(b) => {
696                        serde_json::Value::String(String::from_utf8_lossy(&b).into_owned())
697                    }
698                    Body::Empty => serde_json::Value::Null,
699                    Body::Stream(s) => serde_json::json!({
700                        "_stream": {
701                            "origin": s.metadata.origin,
702                            "placeholder": true,
703                            "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
704                        }
705                    }),
706                })
707                .collect();
708            Ok(Exchange::new(Message {
709                headers: Default::default(),
710                body: Body::Json(serde_json::Value::Array(bodies)),
711            }))
712        }
713        AggregationStrategy::Custom(f) => {
714            let mut iter = exchanges.into_iter();
715            let first = iter.next().ok_or_else(|| {
716                CamelError::ProcessorError("Aggregator: empty bucket".to_string())
717            })?;
718            Ok(iter.fold(first, |acc, next| f(acc, next)))
719        }
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use std::collections::HashMap;
727
728    use camel_api::{
729        StepLifecycle, StepShutdownReason,
730        aggregator::{AggregationStrategy, AggregatorConfig},
731        body::Body,
732        exchange::Exchange,
733        message::Message,
734    };
735    use tokio::sync::mpsc;
736    use tokio_util::sync::CancellationToken;
737    use tower::ServiceExt;
738
739    fn make_exchange(header: &str, value: &str, body: &str) -> Exchange {
740        let mut msg = Message {
741            headers: Default::default(),
742            body: Body::Text(body.to_string()),
743        };
744        msg.headers
745            .insert(header.to_string(), serde_json::json!(value));
746        Exchange::new(msg)
747    }
748
749    fn config_size(n: usize) -> AggregatorConfig {
750        AggregatorConfig::correlate_by("orderId")
751            .complete_when_size(n)
752            .build()
753            .unwrap()
754    }
755
756    fn new_test_svc(config: AggregatorConfig) -> AggregatorService {
757        let (tx, _rx) = mpsc::channel(256);
758        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
759        let cancel = CancellationToken::new();
760        AggregatorService::new(config, tx, registry, cancel)
761    }
762
763    /// Same as `new_test_svc` but lets the caller install a pre-populated
764    /// language registry. Used by `CompletionCondition::PredicateExpr` tests
765    /// where the simple language must be resolvable at evaluation time.
766    fn new_test_svc_with_registry(
767        config: AggregatorConfig,
768        registry: SharedLanguageRegistry,
769    ) -> AggregatorService {
770        let (tx, _rx) = mpsc::channel(256);
771        let cancel = CancellationToken::new();
772        AggregatorService::new(config, tx, registry, cancel)
773    }
774
775    #[tokio::test]
776    async fn test_pending_exchange_not_yet_complete() {
777        let mut svc = new_test_svc(config_size(3));
778        let ex = make_exchange("orderId", "A", "first");
779        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
780        assert!(matches!(result.input.body, Body::Empty));
781        assert_eq!(
782            result.property(CAMEL_AGGREGATOR_PENDING),
783            Some(&serde_json::json!(true))
784        );
785    }
786
787    #[tokio::test]
788    async fn test_completes_on_size() {
789        let mut svc = new_test_svc(config_size(3));
790        for _ in 0..2 {
791            let ex = make_exchange("orderId", "A", "item");
792            let r = svc.ready().await.unwrap().call(ex).await.unwrap();
793            assert!(matches!(r.input.body, Body::Empty));
794        }
795        let ex = make_exchange("orderId", "A", "last");
796        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
797        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
798        assert_eq!(
799            result.property(CAMEL_AGGREGATED_SIZE),
800            Some(&serde_json::json!(3u64))
801        );
802    }
803
804    #[tokio::test]
805    async fn test_collect_all_produces_json_array() {
806        let mut svc = new_test_svc(config_size(2));
807        svc.ready()
808            .await
809            .unwrap()
810            .call(make_exchange("orderId", "A", "alpha"))
811            .await
812            .unwrap();
813        let result = svc
814            .ready()
815            .await
816            .unwrap()
817            .call(make_exchange("orderId", "A", "beta"))
818            .await
819            .unwrap();
820        let Body::Json(v) = &result.input.body else {
821            panic!("expected Body::Json")
822        };
823        let arr = v.as_array().unwrap();
824        assert_eq!(arr.len(), 2);
825        assert_eq!(arr[0], serde_json::json!("alpha"));
826        assert_eq!(arr[1], serde_json::json!("beta"));
827    }
828
829    #[tokio::test]
830    async fn test_two_keys_independent_buckets() {
831        // completionSize=3 so we can test that A and B accumulate independently.
832        let mut svc = new_test_svc(config_size(3));
833        svc.ready()
834            .await
835            .unwrap()
836            .call(make_exchange("orderId", "A", "a1"))
837            .await
838            .unwrap();
839        svc.ready()
840            .await
841            .unwrap()
842            .call(make_exchange("orderId", "B", "b1"))
843            .await
844            .unwrap();
845        svc.ready()
846            .await
847            .unwrap()
848            .call(make_exchange("orderId", "A", "a2"))
849            .await
850            .unwrap();
851        // A has 2 items, B has 1 item — neither complete yet
852        let ra = svc
853            .ready()
854            .await
855            .unwrap()
856            .call(make_exchange("orderId", "A", "a3"))
857            .await
858            .unwrap();
859        // A now has 3 → completes
860        assert!(matches!(ra.input.body, Body::Json(_)));
861        // B only has 1 → still pending
862        let rb = svc
863            .ready()
864            .await
865            .unwrap()
866            .call(make_exchange("orderId", "B", "b_check"))
867            .await
868            .unwrap();
869        assert!(matches!(rb.input.body, Body::Empty));
870    }
871
872    #[tokio::test]
873    async fn test_bucket_resets_after_completion() {
874        let mut svc = new_test_svc(config_size(2));
875        svc.ready()
876            .await
877            .unwrap()
878            .call(make_exchange("orderId", "A", "x"))
879            .await
880            .unwrap();
881        svc.ready()
882            .await
883            .unwrap()
884            .call(make_exchange("orderId", "A", "x"))
885            .await
886            .unwrap(); // completes
887        // New bucket starts
888        let r = svc
889            .ready()
890            .await
891            .unwrap()
892            .call(make_exchange("orderId", "A", "new"))
893            .await
894            .unwrap();
895        assert!(matches!(r.input.body, Body::Empty)); // pending again
896    }
897
898    #[tokio::test]
899    async fn test_completion_size_1_emits_immediately() {
900        let mut svc = new_test_svc(config_size(1));
901        let ex = make_exchange("orderId", "A", "solo");
902        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
903        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
904    }
905
906    #[tokio::test]
907    async fn test_custom_aggregation_strategy() {
908        use camel_api::aggregator::AggregationFn;
909        use std::sync::Arc;
910
911        let f: AggregationFn = Arc::new(|mut acc: Exchange, next: Exchange| {
912            let combined = format!(
913                "{}+{}",
914                acc.input.body.as_text().unwrap_or(""),
915                next.input.body.as_text().unwrap_or("")
916            );
917            acc.input.body = Body::Text(combined);
918            acc
919        });
920        let config = AggregatorConfig::correlate_by("key")
921            .complete_when_size(2)
922            .strategy(AggregationStrategy::Custom(f))
923            .build()
924            .unwrap();
925        let mut svc = new_test_svc(config);
926        svc.ready()
927            .await
928            .unwrap()
929            .call(make_exchange("key", "X", "hello"))
930            .await
931            .unwrap();
932        let result = svc
933            .ready()
934            .await
935            .unwrap()
936            .call(make_exchange("key", "X", "world"))
937            .await
938            .unwrap();
939        assert_eq!(result.input.body.as_text(), Some("hello+world"));
940    }
941
942    #[tokio::test]
943    async fn test_completion_predicate() {
944        let config = AggregatorConfig::correlate_by("key")
945            .complete_when(|bucket| {
946                bucket
947                    .iter()
948                    .any(|e| e.input.body.as_text() == Some("DONE"))
949            })
950            .build()
951            .unwrap();
952        let mut svc = new_test_svc(config);
953        svc.ready()
954            .await
955            .unwrap()
956            .call(make_exchange("key", "K", "first"))
957            .await
958            .unwrap();
959        svc.ready()
960            .await
961            .unwrap()
962            .call(make_exchange("key", "K", "second"))
963            .await
964            .unwrap();
965        let result = svc
966            .ready()
967            .await
968            .unwrap()
969            .call(make_exchange("key", "K", "DONE"))
970            .await
971            .unwrap();
972        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
973    }
974
975    #[tokio::test]
976    async fn test_missing_header_returns_error() {
977        let mut svc = new_test_svc(config_size(2));
978        let msg = Message {
979            headers: Default::default(),
980            body: Body::Text("no key".into()),
981        };
982        let ex = Exchange::new(msg);
983        let result = svc.ready().await.unwrap().call(ex).await;
984        assert!(result.is_err());
985        assert!(matches!(
986            result.unwrap_err(),
987            camel_api::CamelError::ProcessorError(_)
988        ));
989    }
990
991    #[tokio::test]
992    async fn test_cloned_service_shares_state() {
993        let svc1 = new_test_svc(config_size(2));
994        let mut svc2 = svc1.clone();
995        // send first exchange via svc1
996        svc1.clone()
997            .ready()
998            .await
999            .unwrap()
1000            .call(make_exchange("orderId", "A", "from-svc1"))
1001            .await
1002            .unwrap();
1003        // send second exchange via svc2 — should complete because same Arc<Mutex>
1004        let result = svc2
1005            .ready()
1006            .await
1007            .unwrap()
1008            .call(make_exchange("orderId", "A", "from-svc2"))
1009            .await
1010            .unwrap();
1011        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1012    }
1013
1014    #[tokio::test]
1015    async fn test_camel_aggregated_key_property_set() {
1016        let mut svc = new_test_svc(config_size(1));
1017        let ex = make_exchange("orderId", "ORDER-42", "body");
1018        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1019        assert_eq!(
1020            result.property(CAMEL_AGGREGATED_KEY),
1021            Some(&serde_json::json!("ORDER-42"))
1022        );
1023    }
1024
1025    #[tokio::test]
1026    async fn test_aggregator_enforces_max_buckets() {
1027        let config = AggregatorConfig::correlate_by("orderId")
1028            .complete_when_size(2)
1029            .max_buckets(3)
1030            .build()
1031            .unwrap();
1032
1033        let mut svc = new_test_svc(config);
1034
1035        // Create 3 different correlation keys (fills limit)
1036        for i in 0..3 {
1037            let ex = make_exchange("orderId", &format!("key-{}", i), "body");
1038            let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1039        }
1040
1041        // 4th key should be rejected
1042        let ex = make_exchange("orderId", "key-4", "body");
1043        let result = svc.ready().await.unwrap().call(ex).await;
1044
1045        assert!(result.is_err(), "Should reject when max buckets reached");
1046        let err = result.unwrap_err().to_string();
1047        assert!(
1048            err.contains("maximum"),
1049            "Error message should contain 'maximum': {}",
1050            err
1051        );
1052    }
1053
1054    #[tokio::test]
1055    async fn test_max_buckets_allows_existing_key() {
1056        let config = AggregatorConfig::correlate_by("orderId")
1057            .complete_when_size(5) // Large size so bucket doesn't complete
1058            .max_buckets(2)
1059            .build()
1060            .unwrap();
1061
1062        let mut svc = new_test_svc(config);
1063
1064        // Create 2 different correlation keys (fills limit)
1065        let ex1 = make_exchange("orderId", "key-A", "body1");
1066        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1067        let ex2 = make_exchange("orderId", "key-B", "body2");
1068        let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1069
1070        // Should still allow adding to existing key
1071        let ex3 = make_exchange("orderId", "key-A", "body3");
1072        let result = svc.ready().await.unwrap().call(ex3).await;
1073        assert!(
1074            result.is_ok(),
1075            "Should allow adding to existing bucket even at max limit"
1076        );
1077    }
1078
1079    #[tokio::test]
1080    async fn test_bucket_ttl_eviction() {
1081        let config = AggregatorConfig::correlate_by("orderId")
1082            .complete_when_size(10) // Large size so bucket doesn't complete normally
1083            .bucket_ttl(Duration::from_millis(50))
1084            .build()
1085            .unwrap();
1086
1087        let mut svc = new_test_svc(config);
1088
1089        // Create a bucket
1090        let ex1 = make_exchange("orderId", "key-A", "body1");
1091        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1092
1093        // Wait for TTL to expire
1094        tokio::time::sleep(Duration::from_millis(100)).await;
1095
1096        // Create a new bucket - this should trigger eviction of the old one
1097        let ex2 = make_exchange("orderId", "key-B", "body2");
1098        let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1099
1100        // The expired bucket should have been evicted, so we should be able to
1101        // add a new key-A bucket again
1102        let ex3 = make_exchange("orderId", "key-A", "body3");
1103        let result = svc.ready().await.unwrap().call(ex3).await;
1104        assert!(result.is_ok(), "Should be able to recreate evicted bucket");
1105    }
1106
1107    #[tokio::test(start_paused = true)]
1108    async fn test_timeout_completes_bucket() {
1109        let config = AggregatorConfig::correlate_by("key")
1110            .complete_on_timeout(Duration::from_millis(100))
1111            .build()
1112            .unwrap();
1113        let mut svc = new_test_svc(config);
1114        let ex = make_exchange("key", "A", "data");
1115        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1116        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_some());
1117
1118        tokio::time::sleep(Duration::from_millis(200)).await;
1119
1120        assert_eq!(
1121            svc.buckets.lock().unwrap().len(),
1122            0,
1123            "bucket should be removed after timeout"
1124        );
1125    }
1126
1127    #[tokio::test(start_paused = true)]
1128    async fn test_timeout_resets_on_new_exchange() {
1129        let config = AggregatorConfig::correlate_by("key")
1130            .complete_on_timeout(Duration::from_millis(150))
1131            .build()
1132            .unwrap();
1133        let mut svc = new_test_svc(config);
1134
1135        let ex1 = make_exchange("key", "A", "first");
1136        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1137
1138        tokio::time::sleep(Duration::from_millis(100)).await;
1139
1140        let ex2 = make_exchange("key", "A", "second");
1141        let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1142
1143        tokio::time::sleep(Duration::from_millis(100)).await;
1144
1145        assert_eq!(
1146            svc.buckets.lock().unwrap().len(),
1147            1,
1148            "bucket should still exist — timeout was reset"
1149        );
1150
1151        tokio::time::sleep(Duration::from_millis(100)).await;
1152
1153        assert_eq!(
1154            svc.buckets.lock().unwrap().len(),
1155            0,
1156            "bucket should be gone after timeout fires"
1157        );
1158    }
1159
1160    #[tokio::test]
1161    async fn test_composable_size_and_timeout() {
1162        let config = AggregatorConfig::correlate_by("key")
1163            .complete_on_size_or_timeout(2, Duration::from_millis(200))
1164            .build()
1165            .unwrap();
1166        let mut svc = new_test_svc(config);
1167
1168        let ex1 = make_exchange("key", "A", "first");
1169        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1170        assert!(svc.buckets.lock().unwrap().contains_key("\"A\""));
1171
1172        let ex2 = make_exchange("key", "A", "second");
1173        let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1174        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1175        assert_eq!(
1176            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1177            Some(&serde_json::json!("size"))
1178        );
1179    }
1180
1181    #[tokio::test(start_paused = true)]
1182    async fn test_discard_on_timeout() {
1183        let config = AggregatorConfig::correlate_by("key")
1184            .complete_on_timeout(Duration::from_millis(50))
1185            .discard_on_timeout(true)
1186            .build()
1187            .unwrap();
1188        let (tx, mut rx) = mpsc::channel(256);
1189        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1190        let cancel = CancellationToken::new();
1191        let mut svc = AggregatorService::new(config, tx, registry, cancel);
1192
1193        let ex = make_exchange("key", "A", "data");
1194        let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1195
1196        tokio::time::sleep(Duration::from_millis(100)).await;
1197
1198        assert!(
1199            rx.try_recv().is_err(),
1200            "no emit expected with discard_on_timeout"
1201        );
1202        assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1203        assert!(
1204            svc.timeout_tasks.lock().unwrap().is_empty(),
1205            "timeout task should be cleaned up"
1206        );
1207    }
1208
1209    #[tokio::test]
1210    async fn test_force_completion_on_stop() {
1211        let config = AggregatorConfig::correlate_by("key")
1212            .complete_when_size(10)
1213            .force_completion_on_stop(true)
1214            .build()
1215            .unwrap();
1216        let (tx, mut rx) = mpsc::channel(256);
1217        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1218        let cancel = CancellationToken::new();
1219        let svc = AggregatorService::new(config, tx, registry, cancel);
1220
1221        let mut call_svc = svc.clone();
1222        let ex = make_exchange("key", "A", "data");
1223        let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1224
1225        svc.force_complete_all();
1226
1227        let result = rx.try_recv().expect("should emit on force-complete");
1228        assert!(
1229            result.input.body.as_text().is_some() || matches!(result.input.body, Body::Json(_))
1230        );
1231        assert_eq!(
1232            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1233            Some(&serde_json::json!("stop"))
1234        );
1235    }
1236
1237    #[tokio::test]
1238    async fn test_completion_reason_property_size() {
1239        let config = AggregatorConfig::correlate_by("key")
1240            .complete_when_size(1)
1241            .build()
1242            .unwrap();
1243        let mut svc = new_test_svc(config);
1244        let ex = make_exchange("key", "X", "body");
1245        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1246        assert_eq!(
1247            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1248            Some(&serde_json::json!("size"))
1249        );
1250    }
1251
1252    #[tokio::test]
1253    async fn test_completion_reason_property_predicate() {
1254        let config = AggregatorConfig::correlate_by("key")
1255            .complete_when(|_| true)
1256            .build()
1257            .unwrap();
1258        let mut svc = new_test_svc(config);
1259        let ex = make_exchange("key", "X", "body");
1260        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1261        assert_eq!(
1262            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1263            Some(&serde_json::json!("predicate"))
1264        );
1265    }
1266
1267    #[tokio::test(start_paused = true)]
1268    async fn test_size_completes_before_timeout() {
1269        let config = AggregatorConfig::correlate_by("key")
1270            .complete_on_size_or_timeout(2, Duration::from_millis(200))
1271            .build()
1272            .unwrap();
1273        let mut svc = new_test_svc(config);
1274
1275        let ex1 = make_exchange("key", "A", "first");
1276        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1277
1278        let ex2 = make_exchange("key", "A", "second");
1279        let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1280
1281        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1282        assert_eq!(
1283            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1284            Some(&serde_json::json!("size"))
1285        );
1286        assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1287
1288        tokio::time::sleep(Duration::from_millis(300)).await;
1289        assert_eq!(
1290            svc.buckets.lock().unwrap().len(),
1291            0,
1292            "no re-fire after timeout"
1293        );
1294    }
1295
1296    #[tokio::test(start_paused = true)]
1297    async fn test_concurrent_timeout_fire_and_new_exchange() {
1298        let config = AggregatorConfig::correlate_by("key")
1299            .complete_on_size_or_timeout(2, Duration::from_millis(100))
1300            .build()
1301            .unwrap();
1302        let (tx, mut rx) = mpsc::channel(256);
1303        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1304        let cancel = CancellationToken::new();
1305        let mut svc = AggregatorService::new(config, tx, registry, cancel);
1306
1307        let ex = make_exchange("key", "A", "data");
1308        let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1309
1310        // Advance time past timeout — timeout task fires and removes bucket
1311        tokio::time::sleep(Duration::from_millis(150)).await;
1312
1313        // New exchange arrives after timeout — starts a fresh bucket
1314        let ex2 = make_exchange("key", "A", "data2");
1315        let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1316        assert!(
1317            result.property(CAMEL_AGGREGATOR_PENDING).is_some(),
1318            "should be pending in new bucket"
1319        );
1320
1321        // Drain late emits from timeout
1322        let mut late_count = 0;
1323        while rx.try_recv().is_ok() {
1324            late_count += 1;
1325        }
1326        assert_eq!(
1327            late_count, 1,
1328            "exactly 1 late emit from the timed-out bucket"
1329        );
1330    }
1331
1332    #[tokio::test(start_paused = true)]
1333    async fn test_late_channel_full_drops_with_warning() {
1334        let config = AggregatorConfig::correlate_by("key")
1335            .complete_on_timeout(Duration::from_millis(50))
1336            .build()
1337            .unwrap();
1338        let (tx, mut rx) = mpsc::channel(1);
1339        rx.close();
1340        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1341        let cancel = CancellationToken::new();
1342        let mut svc = AggregatorService::new(config, tx, registry, cancel);
1343
1344        let ex = make_exchange("key", "A", "data");
1345        let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1346
1347        tokio::time::sleep(Duration::from_millis(100)).await;
1348        assert_eq!(
1349            svc.buckets.lock().unwrap().len(),
1350            0,
1351            "bucket removed despite channel closed"
1352        );
1353    }
1354
1355    #[tokio::test]
1356    async fn test_aggregate_stream_bodies_creates_valid_json() {
1357        use bytes::Bytes;
1358        use camel_api::{Body, StreamBody, StreamMetadata};
1359        use futures::stream;
1360        use tokio::sync::Mutex;
1361
1362        let chunks = vec![Ok(Bytes::from("test"))];
1363        let stream_body = StreamBody {
1364            stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1365            metadata: StreamMetadata {
1366                origin: Some("file:///test.txt".to_string()),
1367                ..Default::default()
1368            },
1369        };
1370
1371        let ex1 = Exchange::new(Message {
1372            headers: Default::default(),
1373            body: Body::Stream(stream_body),
1374        });
1375
1376        let exchanges = vec![ex1];
1377        let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1378
1379        let exchange = result.expect("Expected Ok result");
1380        assert!(
1381            matches!(exchange.input.body, Body::Json(_)),
1382            "Expected Json body"
1383        );
1384
1385        if let Body::Json(value) = exchange.input.body {
1386            let json_str = serde_json::to_string(&value).unwrap();
1387            let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1388
1389            assert!(parsed.is_array(), "Result should be an array");
1390            let arr = parsed.as_array().unwrap();
1391            assert!(arr[0].is_object(), "First element should be an object");
1392            assert!(
1393                arr[0]["_stream"].is_object(),
1394                "Should contain _stream object"
1395            );
1396            assert_eq!(arr[0]["_stream"]["origin"], "file:///test.txt");
1397            assert_eq!(
1398                arr[0]["_stream"]["placeholder"], true,
1399                "placeholder flag should be true"
1400            );
1401        }
1402    }
1403
1404    #[tokio::test]
1405    async fn test_aggregate_stream_bodies_with_none_origin() {
1406        use bytes::Bytes;
1407        use camel_api::{Body, StreamBody, StreamMetadata};
1408        use futures::stream;
1409        use tokio::sync::Mutex;
1410
1411        let chunks = vec![Ok(Bytes::from("test"))];
1412        let stream_body = StreamBody {
1413            stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1414            metadata: StreamMetadata {
1415                origin: None,
1416                ..Default::default()
1417            },
1418        };
1419
1420        let ex1 = Exchange::new(Message {
1421            headers: Default::default(),
1422            body: Body::Stream(stream_body),
1423        });
1424
1425        let exchanges = vec![ex1];
1426        let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1427
1428        let exchange = result.expect("Expected Ok result");
1429        assert!(
1430            matches!(exchange.input.body, Body::Json(_)),
1431            "Expected Json body"
1432        );
1433
1434        if let Body::Json(value) = exchange.input.body {
1435            let json_str = serde_json::to_string(&value).unwrap();
1436            let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1437
1438            assert!(parsed.is_array(), "Result should be an array");
1439            let arr = parsed.as_array().unwrap();
1440            assert!(arr[0].is_object(), "First element should be an object");
1441            assert!(
1442                arr[0]["_stream"].is_object(),
1443                "Should contain _stream object"
1444            );
1445            assert_eq!(
1446                arr[0]["_stream"]["origin"],
1447                serde_json::Value::Null,
1448                "origin should be null when None"
1449            );
1450            assert_eq!(
1451                arr[0]["_stream"]["placeholder"], true,
1452                "placeholder flag should be true"
1453            );
1454        }
1455    }
1456
1457    #[tokio::test]
1458    async fn timeout_completion_clears_handle_from_map() {
1459        // Regression: Oracle audit found that natural timeout completion removed
1460        // the bucket but left the JoinHandle in `timeout_handles`, leaking the
1461        // entry until route shutdown. After fix, the timeout task itself cleans
1462        // its handle from the map on natural completion.
1463        let config = AggregatorConfig::correlate_by("key")
1464            .complete_on_timeout(Duration::from_millis(50))
1465            .build()
1466            .unwrap();
1467        let (tx, _rx) = mpsc::channel(256);
1468        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1469        let cancel = CancellationToken::new();
1470        let svc = AggregatorService::new(config, tx, registry, cancel);
1471
1472        // Send an exchange to create a pending bucket with a timeout task.
1473        let mut call_svc = svc.clone();
1474        let ex = make_exchange("key", "A", "data");
1475        let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1476        assert!(
1477            !svc.timeout_handles.lock().unwrap().is_empty(),
1478            "handle should exist while timeout pending"
1479        );
1480
1481        // Wait real time for the 50ms timeout to fire + spawned task to complete.
1482        tokio::time::sleep(Duration::from_millis(200)).await;
1483
1484        assert!(
1485            svc.timeout_handles.lock().unwrap().is_empty(),
1486            "handle should be cleared from map after natural timeout completion (was leak)"
1487        );
1488    }
1489
1490    #[tokio::test]
1491    async fn aggregator_shutdown_via_trait_dispatch() {
1492        // RED: builds an AggregatorService, dispatches through Arc<dyn StepLifecycle>,
1493        // and asserts idempotent shutdown works.
1494        let config = AggregatorConfig::correlate_by("key")
1495            .complete_when_size(10)
1496            .build()
1497            .unwrap();
1498        let (tx, _rx) = mpsc::channel(256);
1499        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1500        let cancel = CancellationToken::new();
1501        let svc = AggregatorService::new(config, tx, registry, cancel);
1502
1503        let step: Arc<dyn StepLifecycle> = Arc::new(svc);
1504        step.shutdown(StepShutdownReason::RouteStop)
1505            .await
1506            .expect("first shutdown should succeed");
1507        step.shutdown(StepShutdownReason::RouteStop)
1508            .await
1509            .expect("second shutdown (idempotent) should succeed");
1510    }
1511
1512    #[tokio::test(start_paused = true)]
1513    async fn test_shutdown_awaits_timeout_handles() {
1514        let config = AggregatorConfig::correlate_by("key")
1515            .complete_on_timeout(Duration::from_millis(100))
1516            .build()
1517            .unwrap();
1518        let (tx, _rx) = mpsc::channel(256);
1519        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1520        let cancel = CancellationToken::new();
1521        let svc = AggregatorService::new(config, tx, registry, cancel);
1522
1523        // Send an exchange to create a pending bucket with a timeout task.
1524        let mut call_svc = svc.clone();
1525        let ex = make_exchange("key", "A", "data");
1526        let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1527
1528        // Verify timeout handle exists.
1529        assert!(
1530            !svc.timeout_handles.lock().unwrap().is_empty(),
1531            "should have a timeout handle"
1532        );
1533
1534        // Shutdown should complete within the 5s deadline (the timeout task
1535        // gets cancelled so it won't wait for the full 100ms sleep).
1536        svc.shutdown_inner().await;
1537
1538        assert!(
1539            svc.timeout_handles.lock().unwrap().is_empty(),
1540            "all handles should be cleaned up after shutdown"
1541        );
1542    }
1543
1544    // ── R3-C1 Batch 1: DoS cap + background sweep ───────────────────
1545
1546    /// R3-C1: a flood of unique correlation keys must stay bounded.
1547    /// Default `max_buckets` is 10_000; the 10_001st unique key is rejected
1548    /// with `Aggregator reached maximum N buckets` (or its updated equivalent
1549    /// after the fix). The unique-key flood does NOT OOM the process.
1550    #[tokio::test]
1551    async fn test_unique_key_flood_stays_bounded_by_default() {
1552        // Builder defaults to max_buckets = 10_000, bucket_ttl = 300s.
1553        let config = AggregatorConfig::correlate_by("orderId")
1554            .complete_when_size(1_000_000) // never completes normally
1555            .build()
1556            .unwrap();
1557        let mut svc = new_test_svc(config);
1558
1559        // Send 10_001 unique keys. The first 10_000 should be accepted
1560        // (pending in their buckets); the 10_001st MUST be rejected.
1561        for i in 0..10_000usize {
1562            let ex = make_exchange("orderId", &format!("key-{i}"), "body");
1563            let result = svc.ready().await.unwrap().call(ex).await;
1564            assert!(result.is_ok(), "key {i} should be accepted under the cap");
1565        }
1566        let ex = make_exchange("orderId", "key-10001", "body");
1567        let result = svc.ready().await.unwrap().call(ex).await;
1568        assert!(
1569            result.is_err(),
1570            "10_001st unique key must be rejected by the max_buckets cap"
1571        );
1572        let err = result.unwrap_err().to_string();
1573        assert!(
1574            err.contains("maximum") || err.contains("max"),
1575            "error should mention cap: {err}"
1576        );
1577    }
1578
1579    /// The `AggregatorService` exposes a `sweep_handle` for the background
1580    /// sweep task. When `config.bucket_ttl` is `Some`, `AggregatorService::new`
1581    /// automatically spawns the sweep task and stores the handle, so the
1582    /// caller never sees `None` for a TTL-configured service. Cancelling the
1583    /// route token (via `shutdown`) aborts the sweep.
1584    #[tokio::test]
1585    async fn test_background_sweep_spawns_on_first_poll_not_construction() {
1586        let config = AggregatorConfig::correlate_by("key")
1587            .complete_when_size(10_000)
1588            .bucket_ttl(Duration::from_millis(50))
1589            .build()
1590            .unwrap();
1591        let cancel = CancellationToken::new();
1592        let (tx, _rx) = mpsc::channel(8);
1593        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1594        let mut svc = AggregatorService::new(config, tx, registry, cancel.clone());
1595
1596        // Construction is runtime-free: no sweep spawned yet.
1597        assert!(
1598            svc.sweep_handle
1599                .lock()
1600                .unwrap_or_else(|e| e.into_inner())
1601                .is_none(),
1602            "sweep must NOT be spawned at construction (runtime-free new)"
1603        );
1604
1605        // First readiness poll lazily spawns the sweep (a runtime is present
1606        // here). `ready()` drives `poll_ready` until Ready.
1607        let _ = svc.ready().await.unwrap();
1608        let sweep_present = svc
1609            .sweep_handle
1610            .lock()
1611            .unwrap_or_else(|e| e.into_inner())
1612            .is_some();
1613        assert!(
1614            sweep_present,
1615            "sweep handle should be Some after first poll when bucket_ttl is set"
1616        );
1617
1618        // Cancel the route token; the sweep task observes it and exits.
1619        cancel.cancel();
1620        // Give the task a moment to observe the cancel.
1621        tokio::time::sleep(Duration::from_millis(50)).await;
1622    }
1623
1624    // ── R3-M3: bounded timeout-task spawn ─────────────────────────────
1625
1626    #[tokio::test]
1627    async fn test_aggregator_timeout_task_cap_no_panic_under_flood() {
1628        // R3-M3: a flood of unique keys with a tiny max_timeout_tasks must not
1629        // spawn unbounded tasks, panic, or deadlock. Each call returns Ok(pending).
1630        use camel_api::aggregator::CorrelationStrategy;
1631
1632        let config = AggregatorConfig {
1633            header_name: "k".into(),
1634            completion: CompletionMode::Any(vec![
1635                CompletionCondition::Size(999),
1636                CompletionCondition::Timeout(Duration::from_secs(30)),
1637            ]),
1638            correlation: CorrelationStrategy::HeaderName("k".into()),
1639            strategy: AggregationStrategy::CollectAll,
1640            max_buckets: Some(50),
1641            bucket_ttl: Some(Duration::from_secs(30)),
1642            force_completion_on_stop: false,
1643            discard_on_timeout: false,
1644            max_timeout_tasks: 2,
1645        };
1646        let (late_tx, mut late_rx) = mpsc::channel(64);
1647        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1648        let cancel = CancellationToken::new();
1649        let svc = AggregatorService::new(config, late_tx, registry, cancel);
1650
1651        // Drive 20 unique-key exchanges — far exceeding max_timeout_tasks=2.
1652        for i in 0..20u64 {
1653            let mut ex = Exchange::new(Message {
1654                headers: HashMap::from([("k".to_string(), serde_json::json!(i))]),
1655                body: Body::Text(i.to_string()),
1656            });
1657            ex.input
1658                .headers
1659                .insert("k".to_string(), serde_json::json!(i));
1660            let outcome = tokio::time::timeout(Duration::from_secs(2), async {
1661                let mut s = svc.clone();
1662                use tower::ServiceExt;
1663                s.ready().await.unwrap().call(ex).await
1664            })
1665            .await;
1666            assert!(outcome.is_ok(), "call {} hung/panicked under task cap", i);
1667            // Each returns Ok(pending) since Size(999) is never reached.
1668            let res = outcome.unwrap().unwrap();
1669            assert_eq!(
1670                res.properties
1671                    .get(CAMEL_AGGREGATOR_PENDING)
1672                    .and_then(|v| v.as_bool()),
1673                Some(true),
1674                "exchange {} should be pending",
1675                i
1676            );
1677        }
1678        // Drain any late emissions to avoid blocking the channel.
1679        let _ = late_rx.try_recv();
1680    }
1681
1682    #[tokio::test]
1683    async fn evaluate_completion_predicate_or_combines_any() {
1684        use camel_api::aggregator::{CompletionCondition, CompletionMode};
1685        use camel_language_api::Language;
1686        use std::collections::HashMap;
1687
1688        // Register the simple language in an otherwise-empty registry.
1689        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1690        registry.lock().unwrap().insert(
1691            "simple".to_string(),
1692            Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
1693        );
1694
1695        let incoming = make_exchange("k", "X", "hello");
1696
1697        // Any with two PredicateExpr conditions; second matches body == "hello".
1698        let mode = CompletionMode::Any(vec![
1699            CompletionCondition::PredicateExpr {
1700                expr: "${body} == 'NOPE'".to_string(),
1701                language: "simple".to_string(),
1702            },
1703            CompletionCondition::PredicateExpr {
1704                expr: "${body} == 'hello'".to_string(),
1705                language: "simple".to_string(),
1706            },
1707        ]);
1708
1709        let satisfied = evaluate_completion_predicate(&mode, &incoming, &registry)
1710            .await
1711            .expect("eval must succeed");
1712        assert!(satisfied, "second predicate matches → OR true");
1713    }
1714
1715    #[tokio::test]
1716    async fn evaluate_completion_predicate_no_predicate_skips_registry() {
1717        use camel_api::aggregator::{CompletionCondition, CompletionMode};
1718        use std::collections::HashMap;
1719
1720        // Size-only completion: iter_predicate_exprs returns empty vec.
1721        // The registry is EMPTY — any accidental lock-and-get would fail loudly,
1722        // proving the fast path does not touch the registry.
1723        let mode = CompletionMode::Single(CompletionCondition::Size(3));
1724        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1725        let incoming = make_exchange("k", "X", "hello");
1726        let result = evaluate_completion_predicate(&mode, &incoming, &registry).await;
1727        assert!(result.is_ok(), "fast path must not error: {:?}", result);
1728        assert!(!result.unwrap(), "no predicate → not satisfied");
1729    }
1730
1731    #[tokio::test]
1732    async fn evaluate_completion_predicate_unregistered_language_errors() {
1733        use camel_api::aggregator::{CompletionCondition, CompletionMode};
1734
1735        let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
1736            expr: "${body} == 'DONE'".to_string(),
1737            language: "nonexistent-lang".to_string(),
1738        });
1739        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1740        let incoming = make_exchange("k", "X", "hello");
1741        let result = evaluate_completion_predicate(&mode, &incoming, &registry).await;
1742        assert!(result.is_err(), "unregistered language must error");
1743    }
1744
1745    #[tokio::test]
1746    async fn evaluate_completion_predicate_all_miss_returns_false() {
1747        use camel_api::aggregator::{CompletionCondition, CompletionMode};
1748        use camel_language_api::Language;
1749        use std::collections::HashMap;
1750
1751        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1752        registry.lock().unwrap().insert(
1753            "simple".to_string(),
1754            Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
1755        );
1756
1757        let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
1758            expr: "${body} == 'NOPE'".to_string(),
1759            language: "simple".to_string(),
1760        });
1761        let incoming = make_exchange("k", "X", "hello");
1762        let result = evaluate_completion_predicate(&mode, &incoming, &registry).await;
1763        assert!(result.is_ok(), "eval must succeed: {:?}", result);
1764        assert!(!result.unwrap(), "predicate does not match");
1765    }
1766
1767    #[tokio::test]
1768    async fn completion_predicate_expr_completes_on_match() {
1769        use camel_api::aggregator::{CompletionCondition, CompletionMode};
1770        use camel_language_api::Language;
1771        use std::collections::HashMap;
1772
1773        // Build a registry with the simple language registered.
1774        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1775        registry.lock().unwrap().insert(
1776            "simple".to_string(),
1777            Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
1778        );
1779
1780        // Build the config via the builder for correlation + size defaults
1781        // (required by `AggregatorService::new`'s validate()), then OVERRIDE
1782        // the completion field directly — there is no builder setter for
1783        // `PredicateExpr`.
1784        let mut config = AggregatorConfig::correlate_by("key")
1785            .complete_when_size(999) // placeholder; replaced below
1786            .build()
1787            .expect("config builds");
1788        config.completion = CompletionMode::Single(CompletionCondition::PredicateExpr {
1789            expr: "${body} == 'DONE'".to_string(),
1790            language: "simple".to_string(),
1791        });
1792
1793        let mut svc = new_test_svc_with_registry(config, registry);
1794
1795        // First exchange — predicate false → still pending.
1796        let r = svc
1797            .ready()
1798            .await
1799            .unwrap()
1800            .call(make_exchange("key", "K", "first"))
1801            .await
1802            .unwrap();
1803        assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_some());
1804
1805        // Matching exchange — predicate true → bucket completes.
1806        let r = svc
1807            .ready()
1808            .await
1809            .unwrap()
1810            .call(make_exchange("key", "K", "DONE"))
1811            .await
1812            .unwrap();
1813        assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_none());
1814        assert_eq!(
1815            r.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1816            Some(&serde_json::json!("predicate"))
1817        );
1818    }
1819}