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