Skip to main content

camel_processor/
aggregator.rs

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