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 expression = {
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            lang.create_expression(expr)
846                .map_err(|e| CamelError::ProcessorError(e.to_string()))?
847        }; // registry guard dropped here — safe to await below
848        let value = expression
849            .evaluate(incoming)
850            .await
851            .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
852        if value.as_bool().unwrap_or(false) {
853            satisfied = true;
854        }
855    }
856    Ok(satisfied)
857}
858
859fn check_sync_completion(
860    mode: &CompletionMode,
861    exchanges: &[Exchange],
862    predicate_satisfied: bool,
863) -> (bool, CompletionReason) {
864    match mode {
865        CompletionMode::Single(cond) => check_single(cond, exchanges, predicate_satisfied),
866        CompletionMode::Any(conditions) => {
867            for cond in conditions {
868                if let CompletionCondition::Timeout(_) = cond {
869                    continue;
870                }
871                let (done, reason) = check_single(cond, exchanges, predicate_satisfied);
872                if done {
873                    return (true, reason);
874                }
875            }
876            (false, CompletionReason::Size)
877        }
878        // Future completion modes are not synchronously complete.
879        _ => (false, CompletionReason::Size),
880    }
881}
882
883fn check_single(
884    cond: &CompletionCondition,
885    exchanges: &[Exchange],
886    predicate_satisfied: bool,
887) -> (bool, CompletionReason) {
888    match cond {
889        CompletionCondition::Size(n) => (exchanges.len() >= *n, CompletionReason::Size),
890        CompletionCondition::Predicate(pred) => (pred(exchanges), CompletionReason::Predicate),
891        CompletionCondition::PredicateExpr { .. } => {
892            (predicate_satisfied, CompletionReason::Predicate)
893        }
894        CompletionCondition::Timeout(_) => (false, CompletionReason::Timeout),
895        // Future condition types cannot be evaluated synchronously.
896        _ => (false, CompletionReason::Size),
897    }
898}
899
900fn extract_timeout_duration(mode: &CompletionMode) -> Option<Duration> {
901    match mode {
902        CompletionMode::Single(CompletionCondition::Timeout(d)) => Some(*d),
903        CompletionMode::Any(conditions) => conditions.iter().find_map(|c| {
904            if let CompletionCondition::Timeout(d) = c {
905                Some(*d)
906            } else {
907                None
908            }
909        }),
910        _ => None,
911    }
912}
913
914fn cancel_timeout_task(key: &str, timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>) {
915    let mut guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
916    if let Some(token) = guard.remove(key) {
917        token.cancel();
918    }
919}
920
921/// Also removes the stored JoinHandle for a cancelled/completed timeout task.
922fn cancel_timeout_task_with_handle(
923    key: &str,
924    timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>,
925    timeout_handles: &Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
926) {
927    cancel_timeout_task(key, timeout_tasks);
928    let mut guard = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
929    guard.remove(key);
930}
931
932#[allow(clippy::too_many_arguments)]
933fn spawn_timeout_task(
934    key: String,
935    timeout: Duration,
936    cancel: CancellationToken,
937    buckets: Arc<Mutex<HashMap<String, Bucket>>>,
938    timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
939    timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
940    late_tx: mpsc::Sender<AggregateEmission>,
941    strategy: AggregationStrategy,
942    discard: bool,
943) -> JoinHandle<()> {
944    let cancel_clone = cancel.clone();
945    tokio::spawn(async move {
946        tokio::select! {
947            _ = tokio::time::sleep(timeout) => {
948                let should_proceed = {
949                    let mut tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
950                    if cancel_clone.is_cancelled() {
951                        false
952                    } else {
953                        tt_guard.remove(&key);
954                        true
955                    }
956                };
957                if !should_proceed {
958                    return;
959                }
960                // Clean up our own JoinHandle from the map on natural completion.
961                // Without this, the handle entry leaks until route shutdown.
962                {
963                    let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
964                    hh.remove(&key);
965                }
966                let bucket_parts = {
967                    let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
968                    guard.remove(&key).map(Bucket::into_parts)
969                };
970                if let Some((exchanges, claims)) = bucket_parts
971                    && !discard
972                {
973                    match aggregate(exchanges, &strategy) {
974                        Ok(mut result) => {
975                            result.set_property(
976                                CAMEL_AGGREGATED_COMPLETION_REASON,
977                                serde_json::json!(CompletionReason::Timeout.as_str()),
978                            );
979                            if late_tx
980                                .try_send(AggregateEmission {
981                                    exchange: result,
982                                    claims,
983                                })
984                                .is_err()
985                            {
986                                tracing::warn!(
987                                    key = %key,
988                                    "aggregator timeout emit dropped: late channel full"
989                                );
990                            }
991                        }
992                        Err(e) => {
993                            // log-policy: handler-owned
994                            tracing::warn!(
995                                key = %key,
996                                error = %e,
997                                "aggregation failed in timeout task"
998                            );
999                        }
1000                    }
1001                }
1002            }
1003            _ = cancel_clone.cancelled() => {}
1004        }
1005    })
1006}
1007
1008fn aggregate(
1009    exchanges: Vec<Exchange>,
1010    strategy: &AggregationStrategy,
1011) -> Result<Exchange, CamelError> {
1012    match strategy {
1013        AggregationStrategy::CollectAll => {
1014            let bodies: Vec<serde_json::Value> = exchanges
1015                .into_iter()
1016                .map(|e| match e.input.body {
1017                    Body::Json(v) => v,
1018                    Body::Text(s) => serde_json::Value::String(s),
1019                    Body::Xml(s) => serde_json::Value::String(s),
1020                    Body::Bytes(b) => {
1021                        serde_json::Value::String(String::from_utf8_lossy(&b).into_owned())
1022                    }
1023                    Body::Stream(s) => serde_json::json!({
1024                        "_stream": {
1025                            "origin": s.metadata.origin,
1026                            "placeholder": true,
1027                            "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
1028                        }
1029                    }),
1030                    // Empty and future variants contribute no extractable value.
1031                    _ => serde_json::Value::Null,
1032                })
1033                .collect();
1034            Ok(Exchange::new(Message {
1035                headers: Default::default(),
1036                body: Body::Json(serde_json::Value::Array(bodies)),
1037            }))
1038        }
1039        AggregationStrategy::Custom(f) => {
1040            let mut iter = exchanges.into_iter();
1041            let first = iter.next().ok_or_else(|| {
1042                CamelError::ProcessorError("Aggregator: empty bucket".to_string())
1043            })?;
1044            Ok(iter.fold(first, |acc, next| f(acc, next)))
1045        }
1046        // Future aggregation strategies are unsupported here.
1047        _ => Err(CamelError::ProcessorError(
1048            "Aggregator: unsupported aggregation strategy".to_string(),
1049        )),
1050    }
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055    use super::*;
1056    use std::collections::HashMap;
1057
1058    use camel_api::{
1059        StepLifecycle, StepShutdownReason,
1060        aggregator::{AggregationStrategy, AggregatorConfig},
1061        body::Body,
1062        exchange::Exchange,
1063        message::Message,
1064    };
1065    use tokio::sync::mpsc;
1066    use tokio_util::sync::CancellationToken;
1067    use tower::ServiceExt;
1068
1069    fn make_exchange(header: &str, value: &str, body: &str) -> Exchange {
1070        let mut msg = Message {
1071            headers: Default::default(),
1072            body: Body::Text(body.to_string()),
1073        };
1074        msg.headers
1075            .insert(header.to_string(), serde_json::json!(value));
1076        Exchange::new(msg)
1077    }
1078
1079    fn config_size(n: usize) -> AggregatorConfig {
1080        AggregatorConfig::correlate_by("orderId")
1081            .complete_when_size(n)
1082            .build()
1083            .unwrap()
1084    }
1085
1086    fn new_test_svc(config: AggregatorConfig) -> AggregatorService {
1087        let (tx, _rx) = mpsc::channel(256);
1088        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1089        let cancel = CancellationToken::new();
1090        AggregatorService::new(config, tx, registry, cancel)
1091    }
1092
1093    /// Same as `new_test_svc` but lets the caller install a pre-populated
1094    /// language registry. Used by `CompletionCondition::PredicateExpr` tests
1095    /// where the simple language must be resolvable at evaluation time.
1096    fn new_test_svc_with_registry(
1097        config: AggregatorConfig,
1098        registry: SharedLanguageRegistry,
1099    ) -> AggregatorService {
1100        let (tx, _rx) = mpsc::channel(256);
1101        let cancel = CancellationToken::new();
1102        AggregatorService::new(config, tx, registry, cancel)
1103    }
1104
1105    #[tokio::test]
1106    async fn test_pending_exchange_not_yet_complete() {
1107        let mut svc = new_test_svc(config_size(3));
1108        let ex = make_exchange("orderId", "A", "first");
1109        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1110        assert!(matches!(result.input.body, Body::Empty));
1111        assert_eq!(
1112            result.property(CAMEL_AGGREGATOR_PENDING),
1113            Some(&serde_json::json!(true))
1114        );
1115    }
1116
1117    // ── claimfamily (rc-qbigm): embedded (size-only) stash claims ──
1118
1119    #[tokio::test]
1120    async fn embedded_stash_keeps_carried_claim_until_completion() {
1121        let counter = Arc::new(camel_api::InFlightGauge::new());
1122        let svc = new_test_svc(config_size(2));
1123
1124        // First exchange: pending stash — the carried claim moves into
1125        // the bucket and keeps the exchange counted after `call`
1126        // (the embedding pipeline's ack) resolves.
1127        let mut first = make_exchange("orderId", "A", "first");
1128        first.in_flight_claim = Some(InFlightClaim::attach(&counter));
1129        let pending = svc.clone().oneshot(first).await.unwrap();
1130        assert_eq!(
1131            pending.property(CAMEL_AGGREGATOR_PENDING),
1132            Some(&serde_json::json!(true)),
1133            "first exchange must be stashed"
1134        );
1135        assert!(
1136            pending.in_flight_claim.is_none(),
1137            "the pending marker carries no claim (it completes immediately)"
1138        );
1139        assert_eq!(counter.total(), 1, "stashed exchange must stay counted");
1140
1141        // Second exchange completes the bucket: the aggregated output
1142        // carries ONE claim for its downstream continuation; the other
1143        // claim dropped (that input was consumed into the aggregate).
1144        let mut second = make_exchange("orderId", "A", "second");
1145        second.in_flight_claim = Some(InFlightClaim::attach(&counter));
1146        let aggregated = svc.clone().oneshot(second).await.unwrap();
1147        assert!(
1148            aggregated.property(CAMEL_AGGREGATOR_PENDING).is_none(),
1149            "second exchange must complete the bucket"
1150        );
1151        assert!(
1152            aggregated.in_flight_claim.is_some(),
1153            "aggregated output carries one claim downstream"
1154        );
1155        assert_eq!(
1156            counter.total(),
1157            1,
1158            "consumed input's claim released; the output's stays held"
1159        );
1160        drop(aggregated);
1161        assert_eq!(
1162            counter.total(),
1163            0,
1164            "dropping the completed output releases the last claim"
1165        );
1166    }
1167
1168    #[tokio::test]
1169    async fn embedded_rejection_releases_carried_claim() {
1170        let counter = Arc::new(camel_api::InFlightGauge::new());
1171        let svc = new_test_svc(config_size(2));
1172
1173        // Missing correlation header: the submission is rejected and the
1174        // carried claim drops inside — rejected = released.
1175        let mut bad = Exchange::new(Message::new("no-header"));
1176        bad.in_flight_claim = Some(InFlightClaim::attach(&counter));
1177        let result = svc.clone().oneshot(bad).await;
1178        assert!(result.is_err(), "missing correlation key must reject");
1179        assert_eq!(counter.total(), 0, "rejected submission releases its claim");
1180    }
1181
1182    #[tokio::test]
1183    async fn test_completes_on_size() {
1184        let mut svc = new_test_svc(config_size(3));
1185        for _ in 0..2 {
1186            let ex = make_exchange("orderId", "A", "item");
1187            let r = svc.ready().await.unwrap().call(ex).await.unwrap();
1188            assert!(matches!(r.input.body, Body::Empty));
1189        }
1190        let ex = make_exchange("orderId", "A", "last");
1191        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1192        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1193        assert_eq!(
1194            result.property(CAMEL_AGGREGATED_SIZE),
1195            Some(&serde_json::json!(3u64))
1196        );
1197    }
1198
1199    #[tokio::test]
1200    async fn test_collect_all_produces_json_array() {
1201        let mut svc = new_test_svc(config_size(2));
1202        svc.ready()
1203            .await
1204            .unwrap()
1205            .call(make_exchange("orderId", "A", "alpha"))
1206            .await
1207            .unwrap();
1208        let result = svc
1209            .ready()
1210            .await
1211            .unwrap()
1212            .call(make_exchange("orderId", "A", "beta"))
1213            .await
1214            .unwrap();
1215        let Body::Json(v) = &result.input.body else {
1216            panic!("expected Body::Json")
1217        };
1218        let arr = v.as_array().unwrap();
1219        assert_eq!(arr.len(), 2);
1220        assert_eq!(arr[0], serde_json::json!("alpha"));
1221        assert_eq!(arr[1], serde_json::json!("beta"));
1222    }
1223
1224    #[tokio::test]
1225    async fn test_two_keys_independent_buckets() {
1226        // completionSize=3 so we can test that A and B accumulate independently.
1227        let mut svc = new_test_svc(config_size(3));
1228        svc.ready()
1229            .await
1230            .unwrap()
1231            .call(make_exchange("orderId", "A", "a1"))
1232            .await
1233            .unwrap();
1234        svc.ready()
1235            .await
1236            .unwrap()
1237            .call(make_exchange("orderId", "B", "b1"))
1238            .await
1239            .unwrap();
1240        svc.ready()
1241            .await
1242            .unwrap()
1243            .call(make_exchange("orderId", "A", "a2"))
1244            .await
1245            .unwrap();
1246        // A has 2 items, B has 1 item — neither complete yet
1247        let ra = svc
1248            .ready()
1249            .await
1250            .unwrap()
1251            .call(make_exchange("orderId", "A", "a3"))
1252            .await
1253            .unwrap();
1254        // A now has 3 → completes
1255        assert!(matches!(ra.input.body, Body::Json(_)));
1256        // B only has 1 → still pending
1257        let rb = svc
1258            .ready()
1259            .await
1260            .unwrap()
1261            .call(make_exchange("orderId", "B", "b_check"))
1262            .await
1263            .unwrap();
1264        assert!(matches!(rb.input.body, Body::Empty));
1265    }
1266
1267    #[tokio::test]
1268    async fn test_bucket_resets_after_completion() {
1269        let mut svc = new_test_svc(config_size(2));
1270        svc.ready()
1271            .await
1272            .unwrap()
1273            .call(make_exchange("orderId", "A", "x"))
1274            .await
1275            .unwrap();
1276        svc.ready()
1277            .await
1278            .unwrap()
1279            .call(make_exchange("orderId", "A", "x"))
1280            .await
1281            .unwrap(); // completes
1282        // New bucket starts
1283        let r = svc
1284            .ready()
1285            .await
1286            .unwrap()
1287            .call(make_exchange("orderId", "A", "new"))
1288            .await
1289            .unwrap();
1290        assert!(matches!(r.input.body, Body::Empty)); // pending again
1291    }
1292
1293    #[tokio::test]
1294    async fn test_completion_size_1_emits_immediately() {
1295        let mut svc = new_test_svc(config_size(1));
1296        let ex = make_exchange("orderId", "A", "solo");
1297        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1298        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1299    }
1300
1301    #[tokio::test]
1302    async fn test_custom_aggregation_strategy() {
1303        use camel_api::aggregator::AggregationFn;
1304        use std::sync::Arc;
1305
1306        let f: AggregationFn = Arc::new(|mut acc: Exchange, next: Exchange| {
1307            let combined = format!(
1308                "{}+{}",
1309                acc.input.body.as_text().unwrap_or(""),
1310                next.input.body.as_text().unwrap_or("")
1311            );
1312            acc.input.body = Body::Text(combined);
1313            acc
1314        });
1315        let config = AggregatorConfig::correlate_by("key")
1316            .complete_when_size(2)
1317            .strategy(AggregationStrategy::Custom(f))
1318            .build()
1319            .unwrap();
1320        let mut svc = new_test_svc(config);
1321        svc.ready()
1322            .await
1323            .unwrap()
1324            .call(make_exchange("key", "X", "hello"))
1325            .await
1326            .unwrap();
1327        let result = svc
1328            .ready()
1329            .await
1330            .unwrap()
1331            .call(make_exchange("key", "X", "world"))
1332            .await
1333            .unwrap();
1334        assert_eq!(result.input.body.as_text(), Some("hello+world"));
1335    }
1336
1337    #[tokio::test]
1338    async fn test_completion_predicate() {
1339        let config = AggregatorConfig::correlate_by("key")
1340            .complete_when(|bucket| {
1341                bucket
1342                    .iter()
1343                    .any(|e| e.input.body.as_text() == Some("DONE"))
1344            })
1345            .build()
1346            .unwrap();
1347        let mut svc = new_test_svc(config);
1348        svc.ready()
1349            .await
1350            .unwrap()
1351            .call(make_exchange("key", "K", "first"))
1352            .await
1353            .unwrap();
1354        svc.ready()
1355            .await
1356            .unwrap()
1357            .call(make_exchange("key", "K", "second"))
1358            .await
1359            .unwrap();
1360        let result = svc
1361            .ready()
1362            .await
1363            .unwrap()
1364            .call(make_exchange("key", "K", "DONE"))
1365            .await
1366            .unwrap();
1367        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1368    }
1369
1370    #[tokio::test]
1371    async fn test_missing_header_returns_error() {
1372        let mut svc = new_test_svc(config_size(2));
1373        let msg = Message {
1374            headers: Default::default(),
1375            body: Body::Text("no key".into()),
1376        };
1377        let ex = Exchange::new(msg);
1378        let result = svc.ready().await.unwrap().call(ex).await;
1379        assert!(result.is_err());
1380        assert!(matches!(
1381            result.unwrap_err(),
1382            camel_api::CamelError::ProcessorError(_)
1383        ));
1384    }
1385
1386    #[tokio::test]
1387    async fn test_cloned_service_shares_state() {
1388        let svc1 = new_test_svc(config_size(2));
1389        let mut svc2 = svc1.clone();
1390        // send first exchange via svc1
1391        svc1.clone()
1392            .ready()
1393            .await
1394            .unwrap()
1395            .call(make_exchange("orderId", "A", "from-svc1"))
1396            .await
1397            .unwrap();
1398        // send second exchange via svc2 — should complete because same Arc<Mutex>
1399        let result = svc2
1400            .ready()
1401            .await
1402            .unwrap()
1403            .call(make_exchange("orderId", "A", "from-svc2"))
1404            .await
1405            .unwrap();
1406        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1407    }
1408
1409    #[tokio::test]
1410    async fn test_camel_aggregated_key_property_set() {
1411        let mut svc = new_test_svc(config_size(1));
1412        let ex = make_exchange("orderId", "ORDER-42", "body");
1413        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1414        assert_eq!(
1415            result.property(CAMEL_AGGREGATED_KEY),
1416            Some(&serde_json::json!("ORDER-42"))
1417        );
1418    }
1419
1420    #[tokio::test]
1421    async fn test_aggregator_enforces_max_buckets() {
1422        let config = AggregatorConfig::correlate_by("orderId")
1423            .complete_when_size(2)
1424            .max_buckets(3)
1425            .build()
1426            .unwrap();
1427
1428        let mut svc = new_test_svc(config);
1429
1430        // Create 3 different correlation keys (fills limit)
1431        for i in 0..3 {
1432            let ex = make_exchange("orderId", &format!("key-{}", i), "body");
1433            let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1434        }
1435
1436        // 4th key should be rejected
1437        let ex = make_exchange("orderId", "key-4", "body");
1438        let result = svc.ready().await.unwrap().call(ex).await;
1439
1440        assert!(result.is_err(), "Should reject when max buckets reached");
1441        let err = result.unwrap_err().to_string();
1442        assert!(
1443            err.contains("maximum"),
1444            "Error message should contain 'maximum': {}",
1445            err
1446        );
1447    }
1448
1449    #[tokio::test]
1450    async fn test_max_buckets_allows_existing_key() {
1451        let config = AggregatorConfig::correlate_by("orderId")
1452            .complete_when_size(5) // Large size so bucket doesn't complete
1453            .max_buckets(2)
1454            .build()
1455            .unwrap();
1456
1457        let mut svc = new_test_svc(config);
1458
1459        // Create 2 different correlation keys (fills limit)
1460        let ex1 = make_exchange("orderId", "key-A", "body1");
1461        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1462        let ex2 = make_exchange("orderId", "key-B", "body2");
1463        let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1464
1465        // Should still allow adding to existing key
1466        let ex3 = make_exchange("orderId", "key-A", "body3");
1467        let result = svc.ready().await.unwrap().call(ex3).await;
1468        assert!(
1469            result.is_ok(),
1470            "Should allow adding to existing bucket even at max limit"
1471        );
1472    }
1473
1474    /// Audit 2026-08-31, F6-2: one hot correlation key must not buffer
1475    /// unboundedly. With a predicate-only completion and a tiny
1476    /// max_bucket_size, the Nth+1 exchange on the same key is rejected.
1477    #[tokio::test]
1478    async fn test_aggregator_enforces_max_bucket_size() {
1479        let config = AggregatorConfig::correlate_by("orderId")
1480            // Predicate that never fires — completion would otherwise end the test.
1481            .complete_when(|_| false)
1482            .max_bucket_size(3)
1483            .build()
1484            .unwrap();
1485
1486        let mut svc = new_test_svc(config);
1487
1488        for _ in 0..3 {
1489            let ex = make_exchange("orderId", "hot-key", "body");
1490            let r = svc.ready().await.unwrap().call(ex).await;
1491            assert!(r.is_ok(), "first 3 exchanges accepted: {r:?}");
1492        }
1493
1494        let ex = make_exchange("orderId", "hot-key", "body");
1495        let result = svc.ready().await.unwrap().call(ex).await;
1496        assert!(result.is_err(), "4th exchange on hot key must be rejected");
1497        let err = result.unwrap_err().to_string();
1498        assert!(
1499            err.contains("maximum"),
1500            "error should mention the per-bucket maximum: {err}"
1501        );
1502
1503        // A DIFFERENT key is unaffected (bounded per bucket, not global).
1504        let ex = make_exchange("orderId", "other-key", "body");
1505        let result = svc.ready().await.unwrap().call(ex).await;
1506        assert!(result.is_ok(), "other keys still accepted: {result:?}");
1507    }
1508
1509    #[tokio::test]
1510    async fn test_bucket_ttl_eviction() {
1511        let config = AggregatorConfig::correlate_by("orderId")
1512            .complete_when_size(10) // Large size so bucket doesn't complete normally
1513            .bucket_ttl(Duration::from_millis(50))
1514            .build()
1515            .unwrap();
1516
1517        let mut svc = new_test_svc(config);
1518
1519        // Create a bucket
1520        let ex1 = make_exchange("orderId", "key-A", "body1");
1521        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1522
1523        // Wait for TTL to expire
1524        tokio::time::sleep(Duration::from_millis(100)).await;
1525
1526        // Create a new bucket - this should trigger eviction of the old one
1527        let ex2 = make_exchange("orderId", "key-B", "body2");
1528        let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1529
1530        // The expired bucket should have been evicted, so we should be able to
1531        // add a new key-A bucket again
1532        let ex3 = make_exchange("orderId", "key-A", "body3");
1533        let result = svc.ready().await.unwrap().call(ex3).await;
1534        assert!(result.is_ok(), "Should be able to recreate evicted bucket");
1535    }
1536
1537    #[tokio::test(start_paused = true)]
1538    async fn test_timeout_completes_bucket() {
1539        let config = AggregatorConfig::correlate_by("key")
1540            .complete_on_timeout(Duration::from_millis(100))
1541            .build()
1542            .unwrap();
1543        let mut svc = new_test_svc(config);
1544        let ex = make_exchange("key", "A", "data");
1545        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1546        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_some());
1547
1548        tokio::time::sleep(Duration::from_millis(200)).await;
1549
1550        assert_eq!(
1551            svc.buckets.lock().unwrap().len(),
1552            0,
1553            "bucket should be removed after timeout"
1554        );
1555    }
1556
1557    #[tokio::test(start_paused = true)]
1558    async fn test_timeout_resets_on_new_exchange() {
1559        let config = AggregatorConfig::correlate_by("key")
1560            .complete_on_timeout(Duration::from_millis(150))
1561            .build()
1562            .unwrap();
1563        let mut svc = new_test_svc(config);
1564
1565        let ex1 = make_exchange("key", "A", "first");
1566        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1567
1568        tokio::time::sleep(Duration::from_millis(100)).await;
1569
1570        let ex2 = make_exchange("key", "A", "second");
1571        let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1572
1573        tokio::time::sleep(Duration::from_millis(100)).await;
1574
1575        assert_eq!(
1576            svc.buckets.lock().unwrap().len(),
1577            1,
1578            "bucket should still exist — timeout was reset"
1579        );
1580
1581        tokio::time::sleep(Duration::from_millis(100)).await;
1582
1583        assert_eq!(
1584            svc.buckets.lock().unwrap().len(),
1585            0,
1586            "bucket should be gone after timeout fires"
1587        );
1588    }
1589
1590    #[tokio::test]
1591    async fn test_composable_size_and_timeout() {
1592        let config = AggregatorConfig::correlate_by("key")
1593            .complete_on_size_or_timeout(2, Duration::from_millis(200))
1594            .build()
1595            .unwrap();
1596        let mut svc = new_test_svc(config);
1597
1598        let ex1 = make_exchange("key", "A", "first");
1599        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1600        assert!(svc.buckets.lock().unwrap().contains_key("\"A\""));
1601
1602        let ex2 = make_exchange("key", "A", "second");
1603        let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1604        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1605        assert_eq!(
1606            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1607            Some(&serde_json::json!("size"))
1608        );
1609    }
1610
1611    #[tokio::test(start_paused = true)]
1612    async fn test_discard_on_timeout() {
1613        let config = AggregatorConfig::correlate_by("key")
1614            .complete_on_timeout(Duration::from_millis(50))
1615            .discard_on_timeout(true)
1616            .build()
1617            .unwrap();
1618        let (tx, mut rx) = mpsc::channel(256);
1619        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1620        let cancel = CancellationToken::new();
1621        let mut svc = AggregatorService::new(config, tx, registry, cancel);
1622
1623        let ex = make_exchange("key", "A", "data");
1624        let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1625
1626        tokio::time::sleep(Duration::from_millis(100)).await;
1627
1628        assert!(
1629            rx.try_recv().is_err(),
1630            "no emit expected with discard_on_timeout"
1631        );
1632        assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1633        assert!(
1634            svc.timeout_tasks.lock().unwrap().is_empty(),
1635            "timeout task should be cleaned up"
1636        );
1637    }
1638
1639    #[tokio::test]
1640    async fn test_force_completion_on_stop() {
1641        let config = AggregatorConfig::correlate_by("key")
1642            .complete_when_size(10)
1643            .force_completion_on_stop(true)
1644            .build()
1645            .unwrap();
1646        let (tx, mut rx) = mpsc::channel(256);
1647        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1648        let cancel = CancellationToken::new();
1649        let svc = AggregatorService::new(config, tx, registry, cancel);
1650
1651        let mut call_svc = svc.clone();
1652        let ex = make_exchange("key", "A", "data");
1653        let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1654
1655        svc.force_complete_all();
1656
1657        let result = rx.try_recv().expect("should emit on force-complete");
1658        let result = result.exchange;
1659        assert!(
1660            result.input.body.as_text().is_some() || matches!(result.input.body, Body::Json(_))
1661        );
1662        assert_eq!(
1663            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1664            Some(&serde_json::json!("stop"))
1665        );
1666    }
1667
1668    #[tokio::test]
1669    async fn test_completion_reason_property_size() {
1670        let config = AggregatorConfig::correlate_by("key")
1671            .complete_when_size(1)
1672            .build()
1673            .unwrap();
1674        let mut svc = new_test_svc(config);
1675        let ex = make_exchange("key", "X", "body");
1676        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1677        assert_eq!(
1678            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1679            Some(&serde_json::json!("size"))
1680        );
1681    }
1682
1683    #[tokio::test]
1684    async fn test_completion_reason_property_predicate() {
1685        let config = AggregatorConfig::correlate_by("key")
1686            .complete_when(|_| true)
1687            .build()
1688            .unwrap();
1689        let mut svc = new_test_svc(config);
1690        let ex = make_exchange("key", "X", "body");
1691        let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1692        assert_eq!(
1693            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1694            Some(&serde_json::json!("predicate"))
1695        );
1696    }
1697
1698    #[tokio::test(start_paused = true)]
1699    async fn test_size_completes_before_timeout() {
1700        let config = AggregatorConfig::correlate_by("key")
1701            .complete_on_size_or_timeout(2, Duration::from_millis(200))
1702            .build()
1703            .unwrap();
1704        let mut svc = new_test_svc(config);
1705
1706        let ex1 = make_exchange("key", "A", "first");
1707        let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1708
1709        let ex2 = make_exchange("key", "A", "second");
1710        let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1711
1712        assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1713        assert_eq!(
1714            result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1715            Some(&serde_json::json!("size"))
1716        );
1717        assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1718
1719        tokio::time::sleep(Duration::from_millis(300)).await;
1720        assert_eq!(
1721            svc.buckets.lock().unwrap().len(),
1722            0,
1723            "no re-fire after timeout"
1724        );
1725    }
1726
1727    #[tokio::test(start_paused = true)]
1728    async fn test_concurrent_timeout_fire_and_new_exchange() {
1729        let config = AggregatorConfig::correlate_by("key")
1730            .complete_on_size_or_timeout(2, Duration::from_millis(100))
1731            .build()
1732            .unwrap();
1733        let (tx, mut rx) = mpsc::channel(256);
1734        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1735        let cancel = CancellationToken::new();
1736        let mut svc = AggregatorService::new(config, tx, registry, cancel);
1737
1738        let ex = make_exchange("key", "A", "data");
1739        let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1740
1741        // Advance time past timeout — timeout task fires and removes bucket
1742        tokio::time::sleep(Duration::from_millis(150)).await;
1743
1744        // New exchange arrives after timeout — starts a fresh bucket
1745        let ex2 = make_exchange("key", "A", "data2");
1746        let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1747        assert!(
1748            result.property(CAMEL_AGGREGATOR_PENDING).is_some(),
1749            "should be pending in new bucket"
1750        );
1751
1752        // Drain late emits from timeout
1753        let mut late_count = 0;
1754        while rx.try_recv().is_ok() {
1755            late_count += 1;
1756        }
1757        assert_eq!(
1758            late_count, 1,
1759            "exactly 1 late emit from the timed-out bucket"
1760        );
1761    }
1762
1763    #[tokio::test(start_paused = true)]
1764    async fn test_late_channel_full_drops_with_warning() {
1765        let config = AggregatorConfig::correlate_by("key")
1766            .complete_on_timeout(Duration::from_millis(50))
1767            .build()
1768            .unwrap();
1769        let (tx, mut rx) = mpsc::channel(1);
1770        rx.close();
1771        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1772        let cancel = CancellationToken::new();
1773        let mut svc = AggregatorService::new(config, tx, registry, cancel);
1774
1775        let ex = make_exchange("key", "A", "data");
1776        let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1777
1778        tokio::time::sleep(Duration::from_millis(100)).await;
1779        assert_eq!(
1780            svc.buckets.lock().unwrap().len(),
1781            0,
1782            "bucket removed despite channel closed"
1783        );
1784    }
1785
1786    // D-A3 semantic pin: exercises the `force_complete_all()` -> `late_tx.try_send`
1787    // path specifically (not the timeout-task path covered by
1788    // `test_late_channel_full_drops_with_warning` above). Pre-saturates the
1789    // single-slot `late_tx` before constructing the service so every
1790    // force-completed exchange must drop on the `try_send` failure branch.
1791    #[tokio::test]
1792    async fn test_da3_force_complete_all_drops_on_saturated_channel() {
1793        let config = AggregatorConfig::correlate_by("k")
1794            .complete_when_size(10)
1795            .force_completion_on_stop(true)
1796            .build()
1797            .unwrap();
1798        // capacity 1 — deliberately tiny so the pre-fill fully saturates the slot.
1799        let (late_tx, mut late_rx) = mpsc::channel::<AggregateEmission>(1);
1800        // Pre-saturate the 1-slot mpsc BEFORE the Sender is moved into
1801        // AggregatorService::new. The Sender is taken by value into the
1802        // service, so this `try_send` is the only chance to occupy the slot.
1803        late_tx
1804            .try_send(AggregateEmission {
1805                exchange: make_exchange("k", "99", "dummy"),
1806                claims: Vec::new(),
1807            })
1808            .expect("pre-fill succeeds");
1809
1810        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1811        let cancel = CancellationToken::new();
1812        let mut svc = AggregatorService::new(config, late_tx, registry, cancel);
1813
1814        // Three distinct correlation keys -> three buckets, each holding one
1815        // exchange. size=10 means no bucket auto-completes.
1816        for v in ["1", "2", "3"] {
1817            let ex = make_exchange("k", v, "body");
1818            let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1819        }
1820        assert_eq!(svc.buckets.lock().unwrap().len(), 3);
1821
1822        // Action: drive the force-complete path.
1823        svc.force_complete_all();
1824
1825        // (a) the manually-sent pre-fill item is still in the channel.
1826        let pre_fill = late_rx
1827            .try_recv()
1828            .expect("pre-fill should still be in channel");
1829        assert_eq!(
1830            pre_fill.exchange.input.headers.get("k"),
1831            Some(&serde_json::json!("99"))
1832        );
1833
1834        // (b) channel drained -- no force-completed exchange got through
1835        // (all three try_send calls hit the full channel and dropped).
1836        assert!(matches!(
1837            late_rx.try_recv(),
1838            Err(mpsc::error::TryRecvError::Empty)
1839        ));
1840
1841        // (c) all three buckets were removed during force_complete_all.
1842        assert!(svc.buckets.lock().unwrap().is_empty());
1843    }
1844
1845    #[tokio::test]
1846    async fn test_aggregate_stream_bodies_creates_valid_json() {
1847        use bytes::Bytes;
1848        use camel_api::{Body, StreamBody, StreamMetadata};
1849        use futures::stream;
1850        use tokio::sync::Mutex;
1851
1852        let chunks = vec![Ok(Bytes::from("test"))];
1853        let stream_body = StreamBody {
1854            stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1855            metadata: StreamMetadata {
1856                origin: Some("file:///test.txt".to_string()),
1857                ..Default::default()
1858            },
1859        };
1860
1861        let ex1 = Exchange::new(Message {
1862            headers: Default::default(),
1863            body: Body::Stream(stream_body),
1864        });
1865
1866        let exchanges = vec![ex1];
1867        let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1868
1869        let exchange = result.expect("Expected Ok result");
1870        assert!(
1871            matches!(exchange.input.body, Body::Json(_)),
1872            "Expected Json body"
1873        );
1874
1875        if let Body::Json(value) = exchange.input.body {
1876            let json_str = serde_json::to_string(&value).unwrap();
1877            let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1878
1879            assert!(parsed.is_array(), "Result should be an array");
1880            let arr = parsed.as_array().unwrap();
1881            assert!(arr[0].is_object(), "First element should be an object");
1882            assert!(
1883                arr[0]["_stream"].is_object(),
1884                "Should contain _stream object"
1885            );
1886            assert_eq!(arr[0]["_stream"]["origin"], "file:///test.txt");
1887            assert_eq!(
1888                arr[0]["_stream"]["placeholder"], true,
1889                "placeholder flag should be true"
1890            );
1891        }
1892    }
1893
1894    #[tokio::test]
1895    async fn test_aggregate_stream_bodies_with_none_origin() {
1896        use bytes::Bytes;
1897        use camel_api::{Body, StreamBody, StreamMetadata};
1898        use futures::stream;
1899        use tokio::sync::Mutex;
1900
1901        let chunks = vec![Ok(Bytes::from("test"))];
1902        let stream_body = StreamBody {
1903            stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1904            metadata: StreamMetadata {
1905                origin: None,
1906                ..Default::default()
1907            },
1908        };
1909
1910        let ex1 = Exchange::new(Message {
1911            headers: Default::default(),
1912            body: Body::Stream(stream_body),
1913        });
1914
1915        let exchanges = vec![ex1];
1916        let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1917
1918        let exchange = result.expect("Expected Ok result");
1919        assert!(
1920            matches!(exchange.input.body, Body::Json(_)),
1921            "Expected Json body"
1922        );
1923
1924        if let Body::Json(value) = exchange.input.body {
1925            let json_str = serde_json::to_string(&value).unwrap();
1926            let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1927
1928            assert!(parsed.is_array(), "Result should be an array");
1929            let arr = parsed.as_array().unwrap();
1930            assert!(arr[0].is_object(), "First element should be an object");
1931            assert!(
1932                arr[0]["_stream"].is_object(),
1933                "Should contain _stream object"
1934            );
1935            assert_eq!(
1936                arr[0]["_stream"]["origin"],
1937                serde_json::Value::Null,
1938                "origin should be null when None"
1939            );
1940            assert_eq!(
1941                arr[0]["_stream"]["placeholder"], true,
1942                "placeholder flag should be true"
1943            );
1944        }
1945    }
1946
1947    #[tokio::test]
1948    async fn timeout_completion_clears_handle_from_map() {
1949        // Regression: Oracle audit found that natural timeout completion removed
1950        // the bucket but left the JoinHandle in `timeout_handles`, leaking the
1951        // entry until route shutdown. After fix, the timeout task itself cleans
1952        // its handle from the map on natural completion.
1953        let config = AggregatorConfig::correlate_by("key")
1954            .complete_on_timeout(Duration::from_millis(50))
1955            .build()
1956            .unwrap();
1957        let (tx, _rx) = mpsc::channel(256);
1958        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1959        let cancel = CancellationToken::new();
1960        let svc = AggregatorService::new(config, tx, registry, cancel);
1961
1962        // Send an exchange to create a pending bucket with a timeout task.
1963        let mut call_svc = svc.clone();
1964        let ex = make_exchange("key", "A", "data");
1965        let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1966        assert!(
1967            !svc.timeout_handles.lock().unwrap().is_empty(),
1968            "handle should exist while timeout pending"
1969        );
1970
1971        // Wait real time for the 50ms timeout to fire + spawned task to complete.
1972        tokio::time::sleep(Duration::from_millis(200)).await;
1973
1974        assert!(
1975            svc.timeout_handles.lock().unwrap().is_empty(),
1976            "handle should be cleared from map after natural timeout completion (was leak)"
1977        );
1978    }
1979
1980    #[tokio::test]
1981    async fn aggregator_shutdown_via_trait_dispatch() {
1982        // RED: builds an AggregatorService, dispatches through Arc<dyn StepLifecycle>,
1983        // and asserts idempotent shutdown works.
1984        let config = AggregatorConfig::correlate_by("key")
1985            .complete_when_size(10)
1986            .build()
1987            .unwrap();
1988        let (tx, _rx) = mpsc::channel(256);
1989        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1990        let cancel = CancellationToken::new();
1991        let svc = AggregatorService::new(config, tx, registry, cancel);
1992
1993        let step: Arc<dyn StepLifecycle> = Arc::new(svc);
1994        step.shutdown(StepShutdownReason::RouteStop)
1995            .await
1996            .expect("first shutdown should succeed");
1997        step.shutdown(StepShutdownReason::RouteStop)
1998            .await
1999            .expect("second shutdown (idempotent) should succeed");
2000    }
2001
2002    #[tokio::test(start_paused = true)]
2003    async fn test_shutdown_awaits_timeout_handles() {
2004        let config = AggregatorConfig::correlate_by("key")
2005            .complete_on_timeout(Duration::from_millis(100))
2006            .build()
2007            .unwrap();
2008        let (tx, _rx) = mpsc::channel(256);
2009        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2010        let cancel = CancellationToken::new();
2011        let svc = AggregatorService::new(config, tx, registry, cancel);
2012
2013        // Send an exchange to create a pending bucket with a timeout task.
2014        let mut call_svc = svc.clone();
2015        let ex = make_exchange("key", "A", "data");
2016        let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
2017
2018        // Verify timeout handle exists.
2019        assert!(
2020            !svc.timeout_handles.lock().unwrap().is_empty(),
2021            "should have a timeout handle"
2022        );
2023
2024        // Shutdown should complete within the 5s deadline (the timeout task
2025        // gets cancelled so it won't wait for the full 100ms sleep).
2026        svc.shutdown_inner().await;
2027
2028        assert!(
2029            svc.timeout_handles.lock().unwrap().is_empty(),
2030            "all handles should be cleaned up after shutdown"
2031        );
2032    }
2033
2034    // ── R3-C1 Batch 1: DoS cap + background sweep ───────────────────
2035
2036    /// R3-C1: a flood of unique correlation keys must stay bounded.
2037    /// Default `max_buckets` is 10_000; the 10_001st unique key is rejected
2038    /// with `Aggregator reached maximum N buckets` (or its updated equivalent
2039    /// after the fix). The unique-key flood does NOT OOM the process.
2040    #[tokio::test]
2041    async fn test_unique_key_flood_stays_bounded_by_default() {
2042        // Builder defaults to max_buckets = 10_000, bucket_ttl = 300s.
2043        let config = AggregatorConfig::correlate_by("orderId")
2044            .complete_when_size(1_000_000) // never completes normally
2045            .build()
2046            .unwrap();
2047        let mut svc = new_test_svc(config);
2048
2049        // Send 10_001 unique keys. The first 10_000 should be accepted
2050        // (pending in their buckets); the 10_001st MUST be rejected.
2051        for i in 0..10_000usize {
2052            let ex = make_exchange("orderId", &format!("key-{i}"), "body");
2053            let result = svc.ready().await.unwrap().call(ex).await;
2054            assert!(result.is_ok(), "key {i} should be accepted under the cap");
2055        }
2056        let ex = make_exchange("orderId", "key-10001", "body");
2057        let result = svc.ready().await.unwrap().call(ex).await;
2058        assert!(
2059            result.is_err(),
2060            "10_001st unique key must be rejected by the max_buckets cap"
2061        );
2062        let err = result.unwrap_err().to_string();
2063        assert!(
2064            err.contains("maximum") || err.contains("max"),
2065            "error should mention cap: {err}"
2066        );
2067    }
2068
2069    /// The `AggregatorService` exposes a `sweep_handle` for the background
2070    /// sweep task. When `config.bucket_ttl` is `Some`, `AggregatorService::new`
2071    /// automatically spawns the sweep task and stores the handle, so the
2072    /// caller never sees `None` for a TTL-configured service. Cancelling the
2073    /// route token (via `shutdown`) aborts the sweep.
2074    #[tokio::test]
2075    async fn test_background_sweep_spawns_on_first_poll_not_construction() {
2076        let config = AggregatorConfig::correlate_by("key")
2077            .complete_when_size(10_000)
2078            .bucket_ttl(Duration::from_millis(50))
2079            .build()
2080            .unwrap();
2081        let cancel = CancellationToken::new();
2082        let (tx, _rx) = mpsc::channel(8);
2083        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2084        let mut svc = AggregatorService::new(config, tx, registry, cancel.clone());
2085
2086        // Construction is runtime-free: no sweep spawned yet.
2087        assert!(
2088            svc.sweep_handle
2089                .lock()
2090                .unwrap_or_else(|e| e.into_inner())
2091                .is_none(),
2092            "sweep must NOT be spawned at construction (runtime-free new)"
2093        );
2094
2095        // First readiness poll lazily spawns the sweep (a runtime is present
2096        // here). `ready()` drives `poll_ready` until Ready.
2097        let _ = svc.ready().await.unwrap();
2098        let sweep_present = svc
2099            .sweep_handle
2100            .lock()
2101            .unwrap_or_else(|e| e.into_inner())
2102            .is_some();
2103        assert!(
2104            sweep_present,
2105            "sweep handle should be Some after first poll when bucket_ttl is set"
2106        );
2107
2108        // Cancel the route token; the sweep task observes it and exits.
2109        cancel.cancel();
2110        // Give the task a moment to observe the cancel.
2111        tokio::time::sleep(Duration::from_millis(50)).await;
2112    }
2113
2114    /// Shared `MetricsCollector` double that logs every `set_queue_depth`
2115    /// call (queue label, depth). Used by the queue-depth sampling tests.
2116    struct QueueDepthRecorder(Mutex<Vec<(String, usize)>>);
2117    impl MetricsCollector for QueueDepthRecorder {
2118        fn record_exchange_duration(&self, _: &str, _: std::time::Duration) {}
2119        fn increment_errors(&self, _: &str, _: &str) {}
2120        fn increment_exchanges(&self, _: &str) {}
2121        fn set_queue_depth(&self, queue: &str, depth: usize) {
2122            self.0
2123                .lock()
2124                .unwrap_or_else(|e| e.into_inner())
2125                .push((queue.to_string(), depth));
2126        }
2127        fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
2128    }
2129
2130    /// Poll the recorder until a sample for `label` matches `pred`, or fail
2131    /// after `deadline`. Keeps the sampling tests scheduling-tolerant.
2132    async fn await_depth_sample(
2133        recorder: &QueueDepthRecorder,
2134        label: &str,
2135        pred: impl Fn(usize) -> bool,
2136    ) {
2137        let deadline = std::time::Instant::now() + Duration::from_secs(2);
2138        loop {
2139            let matched = recorder
2140                .0
2141                .lock()
2142                .unwrap_or_else(|e| e.into_inner())
2143                .iter()
2144                .any(|(q, d)| q == label && pred(*d));
2145            if matched {
2146                return;
2147            }
2148            assert!(
2149                std::time::Instant::now() < deadline,
2150                "no queue-depth sample for '{label}' matched within 2s"
2151            );
2152            tokio::time::sleep(Duration::from_millis(20)).await;
2153        }
2154    }
2155
2156    /// Queue-depth sampling (dashboard-observability T3.3): when
2157    /// `with_queue_metrics` is set, the TTL-sweep maintenance pass publishes
2158    /// the buffered group count, and the count returns to zero once the
2159    /// group completes.
2160    #[tokio::test]
2161    async fn test_sweep_reports_queue_depth() {
2162        let config = AggregatorConfig::correlate_by("orderId")
2163            .complete_when_size(3)
2164            // Short TTL keeps the sweep interval at its 50ms floor so the
2165            // test observes samples quickly; the bucket is completed well
2166            // before eviction.
2167            .bucket_ttl(Duration::from_millis(100))
2168            .build()
2169            .unwrap();
2170        let cancel = CancellationToken::new();
2171        let (tx, _rx) = mpsc::channel(8);
2172        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2173        let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
2174        let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
2175            Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
2176            "aggregator:t",
2177        );
2178
2179        // Spawn the sweep via the first readiness poll.
2180        let _ = svc.ready().await.unwrap();
2181
2182        // Partial group: 1 of 3 messages.
2183        let ex = make_exchange("orderId", "g1", "partial");
2184        let _ = svc.ready().await.unwrap().call(ex).await;
2185
2186        await_depth_sample(&recorder, "aggregator:t", |d| d > 0).await;
2187
2188        // Complete the group; the sweep must report zero again.
2189        let ex = make_exchange("orderId", "g1", "b");
2190        let _ = svc.ready().await.unwrap().call(ex).await;
2191        let ex = make_exchange("orderId", "g1", "c");
2192        let _ = svc.ready().await.unwrap().call(ex).await;
2193
2194        await_depth_sample(&recorder, "aggregator:t", |d| d == 0).await;
2195
2196        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2197    }
2198
2199    /// F2 (T3.3 review): a no-TTL aggregator (size-only completion,
2200    /// `bucket_ttl = None`) with `queue_metrics` set must still sample —
2201    /// the sweep used to live entirely inside the TTL branch and never ran.
2202    /// Eviction stays TTL-gated; only sampling runs.
2203    #[tokio::test]
2204    async fn test_no_ttl_aggregator_reports_queue_depth() {
2205        use camel_api::aggregator::CorrelationStrategy;
2206
2207        let config = AggregatorConfig {
2208            header_name: "orderId".into(),
2209            completion: CompletionMode::Single(CompletionCondition::Size(3)),
2210            correlation: CorrelationStrategy::HeaderName("orderId".into()),
2211            strategy: AggregationStrategy::CollectAll,
2212            max_buckets: Some(100),
2213            max_bucket_size: Some(100),
2214            bucket_ttl: None,
2215            force_completion_on_stop: false,
2216            discard_on_timeout: false,
2217            max_timeout_tasks: 64,
2218        };
2219        let cancel = CancellationToken::new();
2220        let (tx, _rx) = mpsc::channel(8);
2221        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2222        let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
2223        let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
2224            Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
2225            "aggregator:nottl",
2226        );
2227
2228        let _ = svc.ready().await.unwrap();
2229        assert!(
2230            svc.sweep_handle
2231                .lock()
2232                .unwrap_or_else(|e| e.into_inner())
2233                .is_some(),
2234            "metrics-only config (bucket_ttl = None) must still spawn the sweep"
2235        );
2236
2237        // Partial group: 1 of 3 messages — depth must publish > 0.
2238        let ex = make_exchange("orderId", "g1", "partial");
2239        let _ = svc.ready().await.unwrap().call(ex).await;
2240        await_depth_sample(&recorder, "aggregator:nottl", |d| d > 0).await;
2241
2242        // Complete the group; depth must publish 0 again.
2243        let ex = make_exchange("orderId", "g1", "b");
2244        let _ = svc.ready().await.unwrap().call(ex).await;
2245        let ex = make_exchange("orderId", "g1", "c");
2246        let _ = svc.ready().await.unwrap().call(ex).await;
2247        await_depth_sample(&recorder, "aggregator:nottl", |d| d == 0).await;
2248
2249        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2250    }
2251
2252    /// F3 (T3.3 review): the last-owner guard — the pipeline clones the
2253    /// service on every `poll_ready`; a transient clone dropping must NOT
2254    /// abort the shared sweep. The sweep keeps ticking (depth still
2255    /// published) until the final owner drops.
2256    #[tokio::test]
2257    async fn test_transient_clone_drop_keeps_sweep_sampling() {
2258        let config = AggregatorConfig::correlate_by("orderId")
2259            .complete_when_size(3)
2260            .bucket_ttl(Duration::from_millis(100))
2261            .build()
2262            .unwrap();
2263        let cancel = CancellationToken::new();
2264        let (tx, _rx) = mpsc::channel(8);
2265        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2266        let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
2267        let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
2268            Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
2269            "aggregator:clone",
2270        );
2271
2272        let _ = svc.ready().await.unwrap();
2273
2274        // Transient clone drops (as the pipeline does per poll_ready).
2275        drop(svc.clone());
2276        assert!(
2277            svc.sweep_handle
2278                .lock()
2279                .unwrap_or_else(|e| e.into_inner())
2280                .is_some(),
2281            "transient clone drop must not abort the shared sweep"
2282        );
2283
2284        // The sweep still ticks: a partial group produces depth samples.
2285        let ex = make_exchange("orderId", "g1", "partial");
2286        let _ = svc.ready().await.unwrap().call(ex).await;
2287        await_depth_sample(&recorder, "aggregator:clone", |d| d > 0).await;
2288
2289        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2290    }
2291
2292    // ── R3-M3: bounded timeout-task spawn ─────────────────────────────
2293
2294    #[tokio::test]
2295    async fn test_aggregator_timeout_task_cap_no_panic_under_flood() {
2296        // R3-M3: a flood of unique keys with a tiny max_timeout_tasks must not
2297        // spawn unbounded tasks, panic, or deadlock. Each call returns Ok(pending).
2298        use camel_api::aggregator::CorrelationStrategy;
2299
2300        let config = AggregatorConfig {
2301            header_name: "k".into(),
2302            completion: CompletionMode::Any(vec![
2303                CompletionCondition::Size(999),
2304                CompletionCondition::Timeout(Duration::from_secs(30)),
2305            ]),
2306            correlation: CorrelationStrategy::HeaderName("k".into()),
2307            strategy: AggregationStrategy::CollectAll,
2308            max_buckets: Some(50),
2309            max_bucket_size: Some(50),
2310            bucket_ttl: Some(Duration::from_secs(30)),
2311            force_completion_on_stop: false,
2312            discard_on_timeout: false,
2313            max_timeout_tasks: 2,
2314        };
2315        let (late_tx, mut late_rx) = mpsc::channel(64);
2316        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2317        let cancel = CancellationToken::new();
2318        let svc = AggregatorService::new(config, late_tx, registry, cancel);
2319
2320        // Drive 20 unique-key exchanges — far exceeding max_timeout_tasks=2.
2321        for i in 0..20u64 {
2322            let mut ex = Exchange::new(Message {
2323                headers: HashMap::from([("k".to_string(), serde_json::json!(i))]),
2324                body: Body::Text(i.to_string()),
2325            });
2326            ex.input
2327                .headers
2328                .insert("k".to_string(), serde_json::json!(i));
2329            let outcome = tokio::time::timeout(Duration::from_secs(2), async {
2330                let mut s = svc.clone();
2331                use tower::ServiceExt;
2332                s.ready().await.unwrap().call(ex).await
2333            })
2334            .await;
2335            assert!(outcome.is_ok(), "call {} hung/panicked under task cap", i);
2336            // Each returns Ok(pending) since Size(999) is never reached.
2337            let res = outcome.unwrap().unwrap();
2338            assert_eq!(
2339                res.properties
2340                    .get(CAMEL_AGGREGATOR_PENDING)
2341                    .and_then(|v| v.as_bool()),
2342                Some(true),
2343                "exchange {} should be pending",
2344                i
2345            );
2346        }
2347        // Drain any late emissions to avoid blocking the channel.
2348        let _ = late_rx.try_recv();
2349    }
2350
2351    /// bd rc-iioeq: consumer-exit release is decided PER BUCKET, not from
2352    /// config-global `has_timeout()`. With the timeout-task cap reached, a
2353    /// timeout-configured bucket stays unarmed; after the consumer exits
2354    /// it can never complete on its own (no task owns it, the bucket_ttl
2355    /// sweep only runs inside the next `call`, which never arrives).
2356    /// `release_unarmed_buckets` must discard exactly those buckets while
2357    /// leaving armed ones to their timeout tasks.
2358    #[tokio::test]
2359    async fn test_release_unarmed_buckets_discards_cap_exceeded_keeps_armed() {
2360        let config = AggregatorConfig::correlate_by("k")
2361            .complete_on_timeout(Duration::from_millis(400))
2362            .max_timeout_tasks(1)
2363            .build()
2364            .unwrap();
2365        let (late_tx, mut late_rx) = mpsc::channel(8);
2366        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2367        let cancel = CancellationToken::new();
2368        let svc = AggregatorService::new(config, late_tx, registry, cancel);
2369
2370        // Key "a" arms the only timeout slot; key "b" hits the cap and
2371        // stays unarmed.
2372        let a = make_exchange("k", "a", "body-a");
2373        let mut sa = svc.clone();
2374        let _ = sa.ready().await.unwrap().call(a).await.unwrap();
2375        let b = make_exchange("k", "b", "body-b");
2376        let mut sb = svc.clone();
2377        let _ = sb.ready().await.unwrap().call(b).await.unwrap();
2378
2379        {
2380            let armed = svc.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
2381            assert_eq!(armed.len(), 1, "cap=1 must arm exactly one timeout task");
2382            assert!(
2383                armed.contains_key("\"a\""),
2384                "first key must hold the armed slot, got {armed:?}"
2385            );
2386        }
2387
2388        // Consumer-exit release: unarmed bucket discarded, armed kept.
2389        svc.release_unarmed_buckets();
2390        {
2391            let buckets = svc.buckets.lock().unwrap_or_else(|e| e.into_inner());
2392            assert!(
2393                buckets.contains_key("\"a\""),
2394                "armed bucket must survive the release"
2395            );
2396            assert!(
2397                !buckets.contains_key("\"b\""),
2398                "cap-exceeded (unarmed) bucket must be released, not orphaned"
2399            );
2400        }
2401
2402        // The armed bucket's timeout still owns completion: it emits
2403        // reason=timeout through the late channel.
2404        let emitted = tokio::time::timeout(Duration::from_secs(2), late_rx.recv())
2405            .await
2406            .expect("armed bucket must emit on its timeout")
2407            .expect("late channel must stay open");
2408        assert_eq!(
2409            emitted
2410                .exchange
2411                .properties
2412                .get(CAMEL_AGGREGATED_COMPLETION_REASON),
2413            Some(&serde_json::json!("timeout"))
2414        );
2415
2416        // The released bucket must never emit: both keys were sent within
2417        // milliseconds of each other, so 600 ms after "a"'s emission is
2418        // past "b"'s would-be deadline (400 ms) with margin.
2419        assert!(
2420            tokio::time::timeout(Duration::from_millis(600), late_rx.recv())
2421                .await
2422                .is_err(),
2423            "released unarmed bucket must not emit"
2424        );
2425
2426        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2427    }
2428
2429    #[tokio::test]
2430    async fn evaluate_completion_predicate_or_combines_any() {
2431        use camel_api::aggregator::{CompletionCondition, CompletionMode};
2432        use camel_language_api::Language;
2433        use std::collections::HashMap;
2434
2435        // Register the simple language in an otherwise-empty registry.
2436        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2437        registry.lock().unwrap().insert(
2438            "simple".to_string(),
2439            Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2440        );
2441
2442        let incoming = make_exchange("k", "X", "hello");
2443
2444        // Any with two PredicateExpr conditions; second matches body == "hello".
2445        let mode = CompletionMode::Any(vec![
2446            CompletionCondition::PredicateExpr {
2447                expr: "${body} == 'NOPE'".to_string(),
2448                language: "simple".to_string(),
2449            },
2450            CompletionCondition::PredicateExpr {
2451                expr: "${body} == 'hello'".to_string(),
2452                language: "simple".to_string(),
2453            },
2454        ]);
2455
2456        let satisfied = evaluate_completion_predicate(&mode, &incoming, &registry)
2457            .await
2458            .expect("eval must succeed");
2459        assert!(satisfied, "second predicate matches → OR true");
2460    }
2461
2462    #[tokio::test]
2463    async fn evaluate_completion_predicate_no_predicate_skips_registry() {
2464        use camel_api::aggregator::{CompletionCondition, CompletionMode};
2465        use std::collections::HashMap;
2466
2467        // Size-only completion: iter_predicate_exprs returns empty vec.
2468        // The registry is EMPTY — any accidental lock-and-get would fail loudly,
2469        // proving the fast path does not touch the registry.
2470        let mode = CompletionMode::Single(CompletionCondition::Size(3));
2471        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2472        let incoming = make_exchange("k", "X", "hello");
2473        let result = evaluate_completion_predicate(&mode, &incoming, &registry).await;
2474        assert!(result.is_ok(), "fast path must not error: {:?}", result);
2475        assert!(!result.unwrap(), "no predicate → not satisfied");
2476    }
2477
2478    #[tokio::test]
2479    async fn evaluate_completion_predicate_unregistered_language_errors() {
2480        use camel_api::aggregator::{CompletionCondition, CompletionMode};
2481
2482        let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
2483            expr: "${body} == 'DONE'".to_string(),
2484            language: "nonexistent-lang".to_string(),
2485        });
2486        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2487        let incoming = make_exchange("k", "X", "hello");
2488        let result = evaluate_completion_predicate(&mode, &incoming, &registry).await;
2489        assert!(result.is_err(), "unregistered language must error");
2490    }
2491
2492    #[tokio::test]
2493    async fn evaluate_completion_predicate_all_miss_returns_false() {
2494        use camel_api::aggregator::{CompletionCondition, CompletionMode};
2495        use camel_language_api::Language;
2496        use std::collections::HashMap;
2497
2498        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2499        registry.lock().unwrap().insert(
2500            "simple".to_string(),
2501            Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2502        );
2503
2504        let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
2505            expr: "${body} == 'NOPE'".to_string(),
2506            language: "simple".to_string(),
2507        });
2508        let incoming = make_exchange("k", "X", "hello");
2509        let result = evaluate_completion_predicate(&mode, &incoming, &registry).await;
2510        assert!(result.is_ok(), "eval must succeed: {:?}", result);
2511        assert!(!result.unwrap(), "predicate does not match");
2512    }
2513
2514    #[tokio::test]
2515    async fn completion_predicate_expr_completes_on_match() {
2516        use camel_api::aggregator::{CompletionCondition, CompletionMode};
2517        use camel_language_api::Language;
2518        use std::collections::HashMap;
2519
2520        // Build a registry with the simple language registered.
2521        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2522        registry.lock().unwrap().insert(
2523            "simple".to_string(),
2524            Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2525        );
2526
2527        // Build the config via the builder for correlation + size defaults
2528        // (required by `AggregatorService::new`'s validate()), then OVERRIDE
2529        // the completion field directly — there is no builder setter for
2530        // `PredicateExpr`.
2531        let mut config = AggregatorConfig::correlate_by("key")
2532            .complete_when_size(999) // placeholder; replaced below
2533            .build()
2534            .expect("config builds");
2535        config.completion = CompletionMode::Single(CompletionCondition::PredicateExpr {
2536            expr: "${body} == 'DONE'".to_string(),
2537            language: "simple".to_string(),
2538        });
2539
2540        let mut svc = new_test_svc_with_registry(config, registry);
2541
2542        // First exchange — predicate false → still pending.
2543        let r = svc
2544            .ready()
2545            .await
2546            .unwrap()
2547            .call(make_exchange("key", "K", "first"))
2548            .await
2549            .unwrap();
2550        assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_some());
2551
2552        // Matching exchange — predicate true → bucket completes.
2553        let r = svc
2554            .ready()
2555            .await
2556            .unwrap()
2557            .call(make_exchange("key", "K", "DONE"))
2558            .await
2559            .unwrap();
2560        assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_none());
2561        assert_eq!(
2562            r.property(CAMEL_AGGREGATED_COMPLETION_REASON),
2563            Some(&serde_json::json!("predicate"))
2564        );
2565    }
2566
2567    // ── ADR-0046 D-A1: binary-fold strategy contract (no null oldExchange) ──
2568    //
2569    // Apache Camel's AggregationStrategy receives a null `oldExchange` on the
2570    // FIRST message of a bucket so the strategy can initialize. rust-camel's
2571    // AggregationFn has a binary signature `(Exchange, Exchange) -> Exchange`
2572    // and the bucket model holds the first message untouched; the strategy is
2573    // only first invoked on the SECOND message with both exchanges present.
2574    //
2575    // This test pins that contract: a strategy needing initialize-on-first
2576    // logic cannot rely on a null oldExchange — it must branch on a sentinel
2577    // in the accumulated body or on a property.
2578    #[tokio::test]
2579    async fn test_da1_strategy_receives_two_exchanges_first_message_preserved() {
2580        use camel_api::aggregator::{AggregationFn, AggregationStrategy};
2581        use std::sync::Arc;
2582
2583        let recorded: Arc<std::sync::Mutex<Vec<(String, String)>>> =
2584            Arc::new(std::sync::Mutex::new(Vec::new()));
2585        let recorded_for_closure = Arc::clone(&recorded);
2586
2587        let f: AggregationFn = Arc::new(move |old: Exchange, new: Exchange| {
2588            let old_body = old.input.body.as_text().unwrap_or("").to_string();
2589            let new_body = new.input.body.as_text().unwrap_or("").to_string();
2590            recorded_for_closure
2591                .lock()
2592                .expect("recorded mutex poisoned")
2593                .push((old_body, new_body));
2594            new
2595        });
2596
2597        let config = AggregatorConfig::correlate_by("k")
2598            .complete_when_size(2)
2599            .strategy(AggregationStrategy::Custom(f))
2600            .build()
2601            .unwrap();
2602        let mut svc = new_test_svc(config);
2603
2604        // First message: bucket goes 0→1, strategy NOT invoked, return pending.
2605        let first = svc
2606            .ready()
2607            .await
2608            .unwrap()
2609            .call(make_exchange("k", "1", "A"))
2610            .await
2611            .unwrap();
2612        assert!(
2613            first.property(CAMEL_AGGREGATOR_PENDING).is_some(),
2614            "first message must leave the bucket pending (size < 2)"
2615        );
2616        assert!(
2617            recorded.lock().expect("recorded mutex poisoned").is_empty(),
2618            "strategy must NOT be invoked on the first message of a bucket"
2619        );
2620
2621        // Second message: bucket goes 1→2, strategy IS invoked as f(ex1, ex2),
2622        // and the result is emitted.
2623        let _completed = svc
2624            .ready()
2625            .await
2626            .unwrap()
2627            .call(make_exchange("k", "1", "B"))
2628            .await
2629            .unwrap();
2630
2631        let recorded = recorded.lock().expect("recorded mutex poisoned");
2632        assert_eq!(
2633            recorded.len(),
2634            1,
2635            "strategy must be invoked exactly once across the two-message bucket, got {recorded:?}"
2636        );
2637        assert_eq!(
2638            recorded[0],
2639            ("A".to_string(), "B".to_string()),
2640            "strategy must observe the first message as `old` and the second as `new`, \
2641             with both bodies preserved unchanged"
2642        );
2643    }
2644
2645    // ── Sweep lifecycle tests (aggregate-route-cancel-threading) ──────
2646
2647    #[tokio::test]
2648    async fn sweep_shutdown_cancels_task() {
2649        let config = AggregatorConfig::correlate_by("key")
2650            .complete_when_size(10)
2651            .bucket_ttl(Duration::from_millis(100))
2652            .build()
2653            .unwrap();
2654        let (tx, _rx) = mpsc::channel(256);
2655        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2656        let cancel = CancellationToken::new();
2657        let mut svc = AggregatorService::new(config, tx, registry, cancel);
2658
2659        let _ = svc.ready().await.unwrap();
2660
2661        assert!(
2662            svc.sweep_handle
2663                .lock()
2664                .unwrap_or_else(|e| e.into_inner())
2665                .is_some(),
2666            "sweep handle should be Some after poll_ready"
2667        );
2668
2669        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2670
2671        assert!(
2672            svc.sweep_handle
2673                .lock()
2674                .unwrap_or_else(|e| e.into_inner())
2675                .is_none(),
2676            "sweep handle should be None after shutdown (taken + aborted)"
2677        );
2678        assert!(
2679            svc.sweep_cancel
2680                .lock()
2681                .unwrap_or_else(|e| e.into_inner())
2682                .is_cancelled(),
2683            "sweep_cancel token should be cancelled after shutdown"
2684        );
2685
2686        tokio::time::sleep(Duration::from_millis(50)).await;
2687    }
2688
2689    #[tokio::test]
2690    async fn sweep_start_respawns_after_shutdown() {
2691        let config = AggregatorConfig::correlate_by("key")
2692            .complete_when_size(10)
2693            .bucket_ttl(Duration::from_millis(100))
2694            .build()
2695            .unwrap();
2696        let (tx, _rx) = mpsc::channel(256);
2697        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2698        let cancel = CancellationToken::new();
2699        let mut svc = AggregatorService::new(config, tx, registry, cancel);
2700
2701        let _ = svc.ready().await.unwrap();
2702        svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2703
2704        svc.start().await.unwrap();
2705        let _ = svc.ready().await.unwrap();
2706
2707        assert!(
2708            svc.sweep_handle
2709                .lock()
2710                .unwrap_or_else(|e| e.into_inner())
2711                .is_some(),
2712            "sweep handle should be Some after start + poll_ready"
2713        );
2714        assert!(
2715            !svc.sweep_cancel
2716                .lock()
2717                .unwrap_or_else(|e| e.into_inner())
2718                .is_cancelled(),
2719            "sweep_cancel should be a fresh uncancelled token after start"
2720        );
2721    }
2722
2723    #[tokio::test]
2724    async fn sweep_shutdown_hotswap_cancels_task() {
2725        let config = AggregatorConfig::correlate_by("key")
2726            .complete_when_size(10)
2727            .bucket_ttl(Duration::from_millis(100))
2728            .build()
2729            .unwrap();
2730        let (tx, _rx) = mpsc::channel(256);
2731        let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2732        let cancel = CancellationToken::new();
2733        let mut svc = AggregatorService::new(config, tx, registry, cancel);
2734
2735        let _ = svc.ready().await.unwrap();
2736
2737        assert!(
2738            svc.sweep_handle
2739                .lock()
2740                .unwrap_or_else(|e| e.into_inner())
2741                .is_some(),
2742            "sweep handle should be Some after poll_ready"
2743        );
2744
2745        svc.shutdown(StepShutdownReason::HotSwap).await.unwrap();
2746
2747        assert!(
2748            svc.sweep_handle
2749                .lock()
2750                .unwrap_or_else(|e| e.into_inner())
2751                .is_none(),
2752            "sweep handle should be None after HotSwap shutdown"
2753        );
2754        assert!(
2755            svc.sweep_cancel
2756                .lock()
2757                .unwrap_or_else(|e| e.into_inner())
2758                .is_cancelled(),
2759            "sweep_cancel token should be cancelled after HotSwap shutdown"
2760        );
2761
2762        tokio::time::sleep(Duration::from_millis(50)).await;
2763    }
2764
2765    // ── Task 4.1 (direct-inline-dispatch): constant-key memo fast path ──
2766
2767    /// A constant scalar correlation key must serialize exactly ONCE: the
2768    /// first fragment fills the memo, later fragments reuse the memoized
2769    /// string via a scalar equality check. All fragments still land in ONE
2770    /// bucket (the size-3 completion with `CamelAggregatedSize == 3` proves
2771    /// a single bucket held all three).
2772    #[tokio::test]
2773    async fn constant_key_skips_reserialization() {
2774        let mut svc = new_test_svc(config_size(3));
2775        let mut result = None;
2776        for body in ["first", "second", "third"] {
2777            result = Some(
2778                svc.ready()
2779                    .await
2780                    .unwrap()
2781                    .call(make_exchange("orderId", "A", body))
2782                    .await
2783                    .unwrap(),
2784            );
2785        }
2786        let result = result.unwrap();
2787        assert_eq!(
2788            result.property(CAMEL_AGGREGATED_SIZE),
2789            Some(&serde_json::json!(3u64)),
2790            "all 3 fragments must aggregate out of a single bucket"
2791        );
2792        assert!(svc.buckets.lock().unwrap().is_empty());
2793        assert_eq!(
2794            svc.key_serializations
2795                .load(std::sync::atomic::Ordering::Relaxed),
2796            1,
2797            "constant scalar key must serialize exactly once"
2798        );
2799    }
2800
2801    /// Alternating scalar keys keep byte-identical bucket names to direct
2802    /// `serde_json::to_string` calls: k1, k2, k1 → exactly two buckets named
2803    /// `"k1"` and `"k2"` (including the JSON quotes).
2804    #[tokio::test]
2805    async fn divergent_keys_keep_serde_semantics() {
2806        let mut svc = new_test_svc(config_size(10)); // never completes
2807        for key in ["k1", "k2", "k1"] {
2808            svc.ready()
2809                .await
2810                .unwrap()
2811                .call(make_exchange("orderId", key, "body"))
2812                .await
2813                .unwrap();
2814        }
2815        let guard = svc.buckets.lock().unwrap();
2816        assert_eq!(guard.len(), 2, "k1/k2/k1 → exactly two buckets");
2817        for key in ["k1", "k2"] {
2818            let expected = serde_json::to_string(&serde_json::json!(key)).unwrap();
2819            assert!(
2820                guard.contains_key(expected.as_str()),
2821                "bucket name must be byte-identical to serde_json::to_string: \
2822                 expected {expected}, have {:?}",
2823                guard.keys().collect::<Vec<_>>()
2824            );
2825        }
2826    }
2827
2828    /// Object correlation keys bypass the memo entirely — every fragment
2829    /// serializes, and bucketing stays byte-identical to direct
2830    /// `serde_json::to_string` of the objects. The two fixtures share an
2831    /// equal key-set but are built in different insertion orders; the
2832    /// expected bucket-key set is DERIVED from serde_json itself so the pin
2833    /// holds under both order-canonicalizing (BTreeMap) and
2834    /// order-preserving (`preserve_order`) serde_json builds.
2835    #[tokio::test]
2836    async fn object_keys_bypass_cache() {
2837        let mut svc = new_test_svc(config_size(10)); // never completes
2838        let obj_a = serde_json::json!({"a": 1, "b": 2});
2839        let obj_b = serde_json::json!({"b": 2, "a": 1});
2840        for obj in [&obj_a, &obj_b, &obj_a] {
2841            let mut msg = Message {
2842                headers: Default::default(),
2843                body: Body::Text("body".into()),
2844            };
2845            msg.headers.insert("orderId".to_string(), obj.clone());
2846            svc.ready()
2847                .await
2848                .unwrap()
2849                .call(Exchange::new(msg))
2850                .await
2851                .unwrap();
2852        }
2853        let expected_keys: std::collections::HashSet<String> = [&obj_a, &obj_b]
2854            .into_iter()
2855            .map(|o| serde_json::to_string(o).unwrap())
2856            .collect();
2857        let guard = svc.buckets.lock().unwrap();
2858        let actual_keys: std::collections::HashSet<String> = guard.keys().cloned().collect();
2859        assert_eq!(
2860            actual_keys, expected_keys,
2861            "object keys must bucket exactly per serde_json::to_string (cache bypassed)"
2862        );
2863        assert_eq!(
2864            svc.key_serializations
2865                .load(std::sync::atomic::Ordering::Relaxed),
2866            3,
2867            "each object fragment must serialize (memo never consulted for objects)"
2868        );
2869    }
2870
2871    /// Float ±0.0 regression: `json!(0.0) == json!(-0.0)` under `Value`
2872    /// equality, but serde serializes them as "0.0" and "-0.0". Numbers are
2873    /// NOT memoizable — every fragment serializes, and the two zero signs
2874    /// stay in distinct buckets named byte-identically to
2875    /// `serde_json::to_string` of each value.
2876    #[tokio::test]
2877    async fn float_zero_sign_keys_stay_distinct() {
2878        let mut svc = new_test_svc(config_size(10)); // never completes
2879        for key in [0.0_f64, -0.0, 0.0] {
2880            let mut msg = Message {
2881                headers: Default::default(),
2882                body: Body::Text("body".into()),
2883            };
2884            msg.headers
2885                .insert("orderId".to_string(), serde_json::json!(key));
2886            svc.ready()
2887                .await
2888                .unwrap()
2889                .call(Exchange::new(msg))
2890                .await
2891                .unwrap();
2892        }
2893        let guard = svc.buckets.lock().unwrap();
2894        assert_eq!(guard.len(), 2, "±0.0 must stay in distinct buckets");
2895        for key in [0.0_f64, -0.0] {
2896            let expected = serde_json::to_string(&serde_json::json!(key)).unwrap();
2897            assert!(
2898                guard.contains_key(expected.as_str()),
2899                "bucket name must be byte-identical to serde_json::to_string: \
2900                 expected {expected}, have {:?}",
2901                guard.keys().collect::<Vec<_>>()
2902            );
2903        }
2904        assert_eq!(
2905            svc.key_serializations
2906                .load(std::sync::atomic::Ordering::Relaxed),
2907            3,
2908            "number keys serialize per fragment (never memoized)"
2909        );
2910    }
2911}