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