1use std::collections::HashMap;
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::{Arc, Mutex};
5use std::task::{Context, Poll};
6use std::time::{Duration, Instant};
7
8use tokio::sync::mpsc;
9use tokio::task::JoinHandle;
10use tokio_util::sync::CancellationToken;
11use tower::Service;
12
13use async_trait::async_trait;
14use camel_api::{
15 CamelError, MetricsCollector, StepLifecycle, StepShutdownReason,
16 aggregator::{
17 AggregationStrategy, AggregatorConfig, CompletionCondition, CompletionMode,
18 CompletionReason, CorrelationStrategy,
19 },
20 body::Body,
21 exchange::Exchange,
22 message::Message,
23};
24use camel_component_api::InFlightClaim;
25use camel_language_api::Language;
26
27pub type SharedLanguageRegistry = Arc<std::sync::Mutex<HashMap<String, Arc<dyn Language>>>>;
28
29pub struct AggregateEmission {
35 pub exchange: Exchange,
36 pub claims: Vec<Option<InFlightClaim>>,
37}
38
39pub struct AggregationReceipt {
47 pub reply: Result<Exchange, CamelError>,
50 pub claims: Vec<Option<InFlightClaim>>,
53}
54
55const QUEUE_DEPTH_SAMPLE_INTERVAL: Duration = Duration::from_millis(250);
59
60pub const CAMEL_AGGREGATOR_PENDING: &str = "CamelAggregatorPending";
61pub const CAMEL_AGGREGATED_SIZE: &str = "CamelAggregatedSize";
62pub const CAMEL_AGGREGATED_KEY: &str = "CamelAggregatedKey";
63pub const CAMEL_AGGREGATED_COMPLETION_REASON: &str = "CamelAggregatedCompletionReason";
64
65struct Bucket {
74 exchanges: Vec<Exchange>,
75 claims: Vec<Option<InFlightClaim>>,
76 last_updated: Instant,
77}
78
79impl Bucket {
80 fn new() -> Self {
81 Self {
82 exchanges: Vec::new(),
83 claims: Vec::new(),
84 last_updated: Instant::now(),
85 }
86 }
87
88 fn push(&mut self, exchange: Exchange, claim: Option<InFlightClaim>) {
89 self.exchanges.push(exchange);
90 self.claims.push(claim);
91 self.last_updated = Instant::now();
92 }
93
94 fn into_parts(self) -> (Vec<Exchange>, Vec<Option<InFlightClaim>>) {
97 (self.exchanges, self.claims)
98 }
99
100 fn is_expired(&self, ttl: Duration) -> bool {
101 Instant::now().duration_since(self.last_updated) >= ttl
102 }
103}
104
105#[derive(Clone)]
106pub struct AggregatorService {
107 config: AggregatorConfig,
108 buckets: Arc<Mutex<HashMap<String, Bucket>>>,
109 timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
110 timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
111 late_tx: mpsc::Sender<AggregateEmission>,
112 language_registry: SharedLanguageRegistry,
113 sweep_cancel: Arc<Mutex<CancellationToken>>,
120 sweep_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
128 queue_metrics: Option<(Arc<dyn MetricsCollector>, String)>,
133 clone_guard: Arc<()>,
137 cached_key: Arc<Mutex<Option<(serde_json::Value, String)>>>,
148 #[cfg(test)]
152 key_serializations: Arc<std::sync::atomic::AtomicUsize>,
153}
154
155impl std::fmt::Debug for AggregatorService {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.debug_struct("AggregatorService").finish_non_exhaustive()
158 }
159}
160
161impl Drop for AggregatorService {
162 fn drop(&mut self) {
163 if Arc::strong_count(&self.clone_guard) > 1 {
173 return;
174 }
175 if let Some(handle) = self
176 .sweep_handle
177 .lock()
178 .unwrap_or_else(|e| e.into_inner())
179 .take()
180 {
181 handle.abort();
182 }
183 }
184}
185
186impl AggregatorService {
187 pub fn new(
198 config: AggregatorConfig,
199 late_tx: mpsc::Sender<AggregateEmission>,
200 language_registry: SharedLanguageRegistry,
201 route_cancel: CancellationToken,
202 ) -> Self {
203 config.validate().expect(
205 "AggregatorService::new: config failed validation \
208 (need max_buckets, completionTimeout, or bucket_ttl)",
209 );
210
211 let has_timeout = has_timeout_condition(&config.completion);
215 if !has_timeout && config.bucket_ttl.is_none() {
216 tracing::warn!(
217 "Aggregator configured with Size/Predicate completion and no \
218 bucket_ttl: buckets accumulate until max_buckets is reached"
219 );
220 }
221
222 let buckets: Arc<Mutex<HashMap<String, Bucket>>> = Arc::new(Mutex::new(HashMap::new()));
225
226 Self {
235 config,
236 buckets,
237 timeout_tasks: Arc::new(Mutex::new(HashMap::new())),
238 timeout_handles: Arc::new(Mutex::new(HashMap::new())),
239 late_tx,
240 language_registry,
241 sweep_cancel: Arc::new(Mutex::new(route_cancel)),
242 sweep_handle: Arc::new(Mutex::new(None)),
243 queue_metrics: None,
244 clone_guard: Arc::new(()),
245 cached_key: Arc::new(Mutex::new(None)),
246 #[cfg(test)]
247 key_serializations: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
248 }
249 }
250
251 pub fn with_queue_metrics(
256 mut self,
257 metrics: Arc<dyn MetricsCollector>,
258 label: impl Into<String>,
259 ) -> Self {
260 self.queue_metrics = Some((metrics, label.into()));
261 self
262 }
263
264 pub fn config(&self) -> &AggregatorConfig {
265 &self.config
266 }
267
268 pub fn has_timeout(&self) -> bool {
269 has_timeout_condition(&self.config.completion)
270 }
271
272 pub fn force_complete_all(&self) {
273 let mut buckets_guard = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
274 let keys: Vec<String> = buckets_guard.keys().cloned().collect();
275
276 for key in keys {
277 if let Some(bucket) = buckets_guard.remove(&key) {
278 if self.config.force_completion_on_stop {
279 cancel_timeout_task_with_handle(
280 &key,
281 &self.timeout_tasks,
282 &self.timeout_handles,
283 );
284 let (exchanges, claims) = bucket.into_parts();
285 match aggregate(exchanges, &self.config.strategy) {
286 Ok(mut result) => {
287 result.set_property(
288 CAMEL_AGGREGATED_COMPLETION_REASON,
289 serde_json::json!(CompletionReason::Stop.as_str()),
290 );
291 if self
292 .late_tx
293 .try_send(AggregateEmission {
294 exchange: result,
295 claims,
296 })
297 .is_err()
298 {
299 tracing::warn!(
300 key = %key,
301 "aggregator force-complete emit dropped: late channel full"
302 );
303 }
304 }
305 Err(e) => {
306 tracing::warn!(
308 key = %key,
309 error = %e,
310 "aggregation failed in force_complete_all"
311 );
312 }
313 }
314 } else {
315 cancel_timeout_task_with_handle(
316 &key,
317 &self.timeout_tasks,
318 &self.timeout_handles,
319 );
320 }
321 }
322 }
323 }
324
325 pub fn release_unarmed_buckets(&self) {
342 let mut buckets_guard = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
343 let timeout_guard = self.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
344 let before = buckets_guard.len();
345 buckets_guard.retain(|key, _| timeout_guard.contains_key(key));
346 let released = before - buckets_guard.len();
347 if released > 0 {
348 tracing::debug!(
349 released,
350 armed = buckets_guard.len(),
351 "aggregator released unarmed buckets on consumer exit"
352 );
353 }
354 }
355
356 pub(crate) async fn shutdown_inner(&self) {
359 {
361 let mut guard = self.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
362 for token in guard.values() {
363 token.cancel();
364 }
365 guard.clear();
366 };
367
368 let handles: Vec<JoinHandle<()>> = {
370 let mut guard = self
371 .timeout_handles
372 .lock()
373 .unwrap_or_else(|e| e.into_inner());
374 guard.drain().map(|(_, handle)| handle).collect()
375 };
376
377 if handles.is_empty() {
378 return;
379 }
380
381 let _ = tokio::time::timeout(Duration::from_secs(5), async {
383 for handle in handles {
384 let _ = handle.await;
385 }
386 })
387 .await;
388 }
389}
390
391#[async_trait]
392impl StepLifecycle for AggregatorService {
393 fn name(&self) -> &'static str {
394 "aggregator"
395 }
396
397 async fn shutdown(&self, reason: StepShutdownReason) -> Result<(), CamelError> {
398 tracing::debug!(reason = ?reason, "Aggregator shutdown via StepLifecycle");
399
400 self.sweep_cancel
403 .lock()
404 .unwrap_or_else(|e| e.into_inner())
405 .cancel();
406
407 if let Some(handle) = self
410 .sweep_handle
411 .lock()
412 .unwrap_or_else(|e| e.into_inner())
413 .take()
414 {
415 handle.abort();
416 }
417
418 self.shutdown_inner().await;
419 Ok(())
420 }
421
422 async fn start(&self) -> Result<(), CamelError> {
426 *self.sweep_cancel.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
427
428 if let Some(handle) = self
429 .sweep_handle
430 .lock()
431 .unwrap_or_else(|e| e.into_inner())
432 .take()
433 {
434 handle.abort();
435 }
436
437 Ok(())
438 }
439}
440
441pub fn has_timeout_condition(mode: &CompletionMode) -> bool {
442 match mode {
443 CompletionMode::Single(CompletionCondition::Timeout(_)) => true,
444 CompletionMode::Any(conditions) => conditions
445 .iter()
446 .any(|c| matches!(c, CompletionCondition::Timeout(_))),
447 _ => false,
448 }
449}
450
451impl Service<Exchange> for AggregatorService {
452 type Response = Exchange;
453 type Error = CamelError;
454 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
455
456 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
457 let ttl = self.config.bucket_ttl;
466 if ttl.is_none() && self.queue_metrics.is_none() {
467 return Poll::Ready(Ok(()));
468 }
469 let mut g = self.sweep_handle.lock().unwrap_or_else(|e| e.into_inner());
470 if g.is_none() {
471 let interval = match ttl {
472 Some(ttl) => std::cmp::max(ttl / 2, Duration::from_millis(50)),
473 None => QUEUE_DEPTH_SAMPLE_INTERVAL,
474 };
475 let buckets = Arc::clone(&self.buckets);
476 let cancel = self
477 .sweep_cancel
478 .lock()
479 .unwrap_or_else(|e| e.into_inner())
480 .clone();
481 let queue_metrics = self.queue_metrics.clone();
482 *g = Some(tokio::spawn(async move {
483 loop {
484 tokio::select! {
485 _ = cancel.cancelled() => break,
486 _ = tokio::time::sleep(interval) => {
487 let mut guard =
488 buckets.lock().unwrap_or_else(|e| e.into_inner());
489 if let Some(ttl) = ttl {
490 guard.retain(|_, b| !b.is_expired(ttl));
491 }
492 if let Some((metrics, label)) = queue_metrics.as_ref() {
495 metrics.set_queue_depth(label, guard.len());
496 }
497 }
498 }
499 }
500 }));
501 }
502 Poll::Ready(Ok(()))
503 }
504
505 fn call(&mut self, exchange: Exchange) -> Self::Future {
506 let svc = self.clone();
507 Box::pin(async move {
508 let mut exchange = exchange;
520 let claim = exchange.in_flight_claim.take();
521 let AggregationReceipt { reply, claims } = svc.submit_with_claim(exchange, claim).await;
522 match reply {
523 Ok(mut result) => {
524 result.in_flight_claim = claims.into_iter().flatten().next();
525 Ok(result)
526 }
527 Err(e) => Err(e),
528 }
529 })
530 }
531}
532
533impl AggregatorService {
534 pub async fn submit_with_claim(
545 &self,
546 exchange: Exchange,
547 claim: Option<InFlightClaim>,
548 ) -> AggregationReceipt {
549 let config = self.config.clone();
550 let buckets = Arc::clone(&self.buckets);
551 let timeout_tasks = Arc::clone(&self.timeout_tasks);
552 let timeout_handles = Arc::clone(&self.timeout_handles);
553 let late_tx = self.late_tx.clone();
554 let language_registry = Arc::clone(&self.language_registry);
555 let cached_key = Arc::clone(&self.cached_key);
556 #[cfg(test)]
557 let key_serializations = Arc::clone(&self.key_serializations);
558
559 let inner = async move {
560 let key_value =
561 extract_correlation_key(&exchange, &config.correlation, &language_registry).await?;
562
563 let key_is_memoizable = matches!(key_value, serde_json::Value::String(_));
574 let key_str = if key_is_memoizable {
575 let mut memo = cached_key.lock().unwrap_or_else(|e| e.into_inner());
576 if let Some((v, s)) = memo.as_ref()
577 && *v == key_value
578 {
579 s.clone()
580 } else {
581 #[cfg(test)]
582 key_serializations.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
583 let s = serde_json::to_string(&key_value)
584 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
585 *memo = Some((key_value.clone(), s.clone()));
586 s
587 }
588 } else {
589 #[cfg(test)]
590 key_serializations.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
591 serde_json::to_string(&key_value)
592 .map_err(|e| CamelError::ProcessorError(e.to_string()))?
593 };
594
595 let predicate_satisfied =
599 evaluate_completion_predicate(&config.completion, &exchange, &language_registry)
600 .await?;
601
602 let completed_bucket = {
603 let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
604
605 if let Some(ttl) = config.bucket_ttl {
606 guard.retain(|_, bucket| !bucket.is_expired(ttl));
607 }
608
609 if let Some(max) = config.max_buckets
610 && !guard.contains_key(&key_str)
611 && guard.len() >= max
612 {
613 tracing::warn!(
614 max_buckets = max,
615 correlation_key = %key_str,
616 "Aggregator reached max buckets limit, rejecting new correlation key"
617 );
618 return Err(CamelError::ProcessorError(format!(
619 "Aggregator reached maximum {} buckets",
620 max
621 )));
622 }
623
624 if let Some(max_size) = config.max_bucket_size
628 && let Some(existing) = guard.get(&key_str)
629 && existing.exchanges.len() >= max_size
630 {
631 tracing::warn!(
632 max_bucket_size = max_size,
633 correlation_key = %key_str,
634 "Aggregator reached per-bucket size limit, rejecting exchange"
635 );
636 return Err(CamelError::ProcessorError(format!(
637 "Aggregator bucket reached maximum {max_size} exchanges"
638 )));
639 }
640
641 let bucket = match guard.get_mut(&key_str) {
642 Some(b) => b,
643 None => guard.entry(key_str.clone()).or_insert_with(Bucket::new),
644 };
645 bucket.push(exchange, claim);
646
647 let (is_complete, reason) = check_sync_completion(
648 &config.completion,
649 &bucket.exchanges,
650 predicate_satisfied,
651 );
652
653 let bucket_parts = if is_complete {
654 guard.remove(&key_str).map(Bucket::into_parts)
655 } else {
656 None
657 };
658 (bucket_parts, reason)
659 };
660
661 if completed_bucket.0.is_none() && has_timeout_condition(&config.completion) {
662 let timeout_dur = extract_timeout_duration(&config.completion);
663 if let Some(timeout) = timeout_dur {
664 let live_count = timeout_handles
670 .lock()
671 .unwrap_or_else(|e| e.into_inner())
672 .len();
673 if live_count >= config.max_timeout_tasks {
674 tracing::warn!(
675 live_timeout_tasks = live_count,
676 max_timeout_tasks = config.max_timeout_tasks,
677 correlation_key = %key_str,
678 "Aggregator timeout-task cap reached; bucket will rely on \
679 bucket_ttl eviction instead of a dedicated timeout task"
680 );
681 } else {
682 {
684 let tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
685 if let Some(existing) = tt_guard.get(&key_str) {
686 existing.cancel();
687 }
688 }
689 {
691 let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
692 if let Some(old) = hh.remove(&key_str) {
693 old.abort();
694 }
695 }
696 let cancel = CancellationToken::new();
697 timeout_tasks
698 .lock()
699 .unwrap_or_else(|e| e.into_inner())
700 .insert(key_str.clone(), cancel.clone());
701 let handle = spawn_timeout_task(
702 key_str.clone(),
703 timeout,
704 cancel,
705 buckets.clone(),
706 timeout_tasks.clone(),
707 timeout_handles.clone(),
708 late_tx,
709 config.strategy.clone(),
710 config.discard_on_timeout,
711 );
712 timeout_handles
713 .lock()
714 .unwrap_or_else(|e| e.into_inner())
715 .insert(key_str.clone(), handle);
716 }
717 }
718 }
719
720 if let Some((exchanges, claims)) = completed_bucket.0 {
721 cancel_timeout_task_with_handle(&key_str, &timeout_tasks, &timeout_handles);
722 let reason = completed_bucket.1;
723 let size = exchanges.len();
724 let mut result = aggregate(exchanges, &config.strategy)?;
725 result.set_property(CAMEL_AGGREGATED_SIZE, serde_json::json!(size as u64));
726 result.set_property(CAMEL_AGGREGATED_KEY, key_value);
727 result.set_property(
728 CAMEL_AGGREGATED_COMPLETION_REASON,
729 serde_json::json!(reason.as_str()),
730 );
731 Ok((result, claims))
732 } else {
733 let mut pending = Exchange::new(Message {
734 headers: Default::default(),
735 body: Body::Empty,
736 });
737 pending.set_property(CAMEL_AGGREGATOR_PENDING, serde_json::json!(true));
738 Ok((pending, Vec::new()))
740 }
741 }
742 .await;
743
744 match inner {
745 Ok((exchange, claims)) => AggregationReceipt {
746 reply: Ok(exchange),
747 claims,
748 },
749 Err(e) => AggregationReceipt {
752 reply: Err(e),
753 claims: Vec::new(),
754 },
755 }
756 }
757}
758
759async fn extract_correlation_key(
760 exchange: &Exchange,
761 strategy: &CorrelationStrategy,
762 registry: &SharedLanguageRegistry,
763) -> Result<serde_json::Value, CamelError> {
764 match strategy {
765 CorrelationStrategy::HeaderName(h) => {
766 exchange.input.headers.get(h).cloned().ok_or_else(|| {
767 CamelError::ProcessorError(format!(
768 "Aggregator: missing correlation key header '{}'",
769 h
770 ))
771 })
772 }
773 CorrelationStrategy::Expression { expr, language } => {
774 let expression = {
775 let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
776 let lang = reg.get(language).ok_or_else(|| {
777 CamelError::ProcessorError(format!(
778 "Aggregator: language '{}' not found in registry",
779 language
780 ))
781 })?;
782 lang.create_expression(expr)
783 .map_err(|e| CamelError::ProcessorError(e.to_string()))?
784 };
785 let value = expression
786 .evaluate(exchange)
787 .await
788 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
789 if value.is_null() {
790 return Err(CamelError::ProcessorError(format!(
791 "Aggregator: correlation expression '{}' evaluated to null",
792 expr
793 )));
794 }
795 Ok(value)
796 }
797 CorrelationStrategy::Fn(f) => f(exchange).map(serde_json::Value::String).ok_or_else(|| {
798 CamelError::ProcessorError("Aggregator: correlation function returned None".to_string())
799 }),
800 _ => Err(CamelError::ProcessorError(
802 "Aggregator: unsupported correlation strategy".to_string(),
803 )),
804 }
805}
806
807fn iter_predicate_exprs(mode: &CompletionMode) -> Vec<(&String, &String)> {
809 let mut out = Vec::new();
810 match mode {
811 CompletionMode::Single(CompletionCondition::PredicateExpr { expr, language }) => {
812 out.push((expr, language));
813 }
814 CompletionMode::Any(conds) => {
815 for c in conds {
816 if let CompletionCondition::PredicateExpr { expr, language } = c {
817 out.push((expr, language));
818 }
819 }
820 }
821 _ => {}
822 }
823 out
824}
825
826async fn evaluate_completion_predicate(
831 mode: &CompletionMode,
832 incoming: &Exchange,
833 registry: &SharedLanguageRegistry,
834) -> Result<bool, CamelError> {
835 let mut satisfied = false;
836 for (expr, language) in iter_predicate_exprs(mode) {
837 let predicate = {
838 let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
839 let lang = reg.get(language).ok_or_else(|| {
840 CamelError::ProcessorError(format!(
841 "Aggregator: language '{}' not found in registry",
842 language
843 ))
844 })?;
845 lang.create_predicate(expr)
849 .map_err(|e| CamelError::ProcessorError(e.to_string()))?
850 }; let matched = predicate
852 .matches(incoming)
853 .await
854 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
855 if matched {
856 satisfied = true;
857 }
858 }
859 Ok(satisfied)
860}
861
862fn check_sync_completion(
863 mode: &CompletionMode,
864 exchanges: &[Exchange],
865 predicate_satisfied: bool,
866) -> (bool, CompletionReason) {
867 match mode {
868 CompletionMode::Single(cond) => check_single(cond, exchanges, predicate_satisfied),
869 CompletionMode::Any(conditions) => {
870 for cond in conditions {
871 if let CompletionCondition::Timeout(_) = cond {
872 continue;
873 }
874 let (done, reason) = check_single(cond, exchanges, predicate_satisfied);
875 if done {
876 return (true, reason);
877 }
878 }
879 (false, CompletionReason::Size)
880 }
881 _ => (false, CompletionReason::Size),
883 }
884}
885
886fn check_single(
887 cond: &CompletionCondition,
888 exchanges: &[Exchange],
889 predicate_satisfied: bool,
890) -> (bool, CompletionReason) {
891 match cond {
892 CompletionCondition::Size(n) => (exchanges.len() >= *n, CompletionReason::Size),
893 CompletionCondition::Predicate(pred) => (pred(exchanges), CompletionReason::Predicate),
894 CompletionCondition::PredicateExpr { .. } => {
895 (predicate_satisfied, CompletionReason::Predicate)
896 }
897 CompletionCondition::Timeout(_) => (false, CompletionReason::Timeout),
898 _ => (false, CompletionReason::Size),
900 }
901}
902
903fn extract_timeout_duration(mode: &CompletionMode) -> Option<Duration> {
904 match mode {
905 CompletionMode::Single(CompletionCondition::Timeout(d)) => Some(*d),
906 CompletionMode::Any(conditions) => conditions.iter().find_map(|c| {
907 if let CompletionCondition::Timeout(d) = c {
908 Some(*d)
909 } else {
910 None
911 }
912 }),
913 _ => None,
914 }
915}
916
917fn cancel_timeout_task(key: &str, timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>) {
918 let mut guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
919 if let Some(token) = guard.remove(key) {
920 token.cancel();
921 }
922}
923
924fn cancel_timeout_task_with_handle(
926 key: &str,
927 timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>,
928 timeout_handles: &Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
929) {
930 cancel_timeout_task(key, timeout_tasks);
931 let mut guard = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
932 guard.remove(key);
933}
934
935#[allow(clippy::too_many_arguments)]
936fn spawn_timeout_task(
937 key: String,
938 timeout: Duration,
939 cancel: CancellationToken,
940 buckets: Arc<Mutex<HashMap<String, Bucket>>>,
941 timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
942 timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
943 late_tx: mpsc::Sender<AggregateEmission>,
944 strategy: AggregationStrategy,
945 discard: bool,
946) -> JoinHandle<()> {
947 let cancel_clone = cancel.clone();
948 tokio::spawn(async move {
949 tokio::select! {
950 _ = tokio::time::sleep(timeout) => {
951 let should_proceed = {
952 let mut tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
953 if cancel_clone.is_cancelled() {
954 false
955 } else {
956 tt_guard.remove(&key);
957 true
958 }
959 };
960 if !should_proceed {
961 return;
962 }
963 {
966 let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
967 hh.remove(&key);
968 }
969 let bucket_parts = {
970 let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
971 guard.remove(&key).map(Bucket::into_parts)
972 };
973 if let Some((exchanges, claims)) = bucket_parts
974 && !discard
975 {
976 match aggregate(exchanges, &strategy) {
977 Ok(mut result) => {
978 result.set_property(
979 CAMEL_AGGREGATED_COMPLETION_REASON,
980 serde_json::json!(CompletionReason::Timeout.as_str()),
981 );
982 if late_tx
983 .try_send(AggregateEmission {
984 exchange: result,
985 claims,
986 })
987 .is_err()
988 {
989 tracing::warn!(
990 key = %key,
991 "aggregator timeout emit dropped: late channel full"
992 );
993 }
994 }
995 Err(e) => {
996 tracing::warn!(
998 key = %key,
999 error = %e,
1000 "aggregation failed in timeout task"
1001 );
1002 }
1003 }
1004 }
1005 }
1006 _ = cancel_clone.cancelled() => {}
1007 }
1008 })
1009}
1010
1011fn aggregate(
1012 exchanges: Vec<Exchange>,
1013 strategy: &AggregationStrategy,
1014) -> Result<Exchange, CamelError> {
1015 match strategy {
1016 AggregationStrategy::CollectAll => {
1017 let bodies: Vec<serde_json::Value> = exchanges
1018 .into_iter()
1019 .map(|e| match e.input.body {
1020 Body::Json(v) => v,
1021 Body::Text(s) => serde_json::Value::String(s),
1022 Body::Xml(s) => serde_json::Value::String(s),
1023 Body::Bytes(b) => {
1024 serde_json::Value::String(String::from_utf8_lossy(&b).into_owned())
1025 }
1026 Body::Stream(s) => serde_json::json!({
1027 "_stream": {
1028 "origin": s.metadata.origin,
1029 "placeholder": true,
1030 "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
1031 }
1032 }),
1033 _ => serde_json::Value::Null,
1035 })
1036 .collect();
1037 Ok(Exchange::new(Message {
1038 headers: Default::default(),
1039 body: Body::Json(serde_json::Value::Array(bodies)),
1040 }))
1041 }
1042 AggregationStrategy::Custom(f) => {
1043 let mut iter = exchanges.into_iter();
1044 let first = iter.next().ok_or_else(|| {
1045 CamelError::ProcessorError("Aggregator: empty bucket".to_string())
1046 })?;
1047 Ok(iter.fold(first, |acc, next| f(acc, next)))
1048 }
1049 _ => Err(CamelError::ProcessorError(
1051 "Aggregator: unsupported aggregation strategy".to_string(),
1052 )),
1053 }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059 use std::collections::HashMap;
1060
1061 use camel_api::{
1062 StepLifecycle, StepShutdownReason,
1063 aggregator::{AggregationStrategy, AggregatorConfig},
1064 body::Body,
1065 exchange::Exchange,
1066 message::Message,
1067 };
1068 use tokio::sync::mpsc;
1069 use tokio_util::sync::CancellationToken;
1070 use tower::ServiceExt;
1071
1072 fn make_exchange(header: &str, value: &str, body: &str) -> Exchange {
1073 let mut msg = Message {
1074 headers: Default::default(),
1075 body: Body::Text(body.to_string()),
1076 };
1077 msg.headers
1078 .insert(header.to_string(), serde_json::json!(value));
1079 Exchange::new(msg)
1080 }
1081
1082 fn config_size(n: usize) -> AggregatorConfig {
1083 AggregatorConfig::correlate_by("orderId")
1084 .complete_when_size(n)
1085 .build()
1086 .unwrap()
1087 }
1088
1089 fn new_test_svc(config: AggregatorConfig) -> AggregatorService {
1090 let (tx, _rx) = mpsc::channel(256);
1091 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1092 let cancel = CancellationToken::new();
1093 AggregatorService::new(config, tx, registry, cancel)
1094 }
1095
1096 fn new_test_svc_with_registry(
1100 config: AggregatorConfig,
1101 registry: SharedLanguageRegistry,
1102 ) -> AggregatorService {
1103 let (tx, _rx) = mpsc::channel(256);
1104 let cancel = CancellationToken::new();
1105 AggregatorService::new(config, tx, registry, cancel)
1106 }
1107
1108 #[tokio::test]
1109 async fn test_pending_exchange_not_yet_complete() {
1110 let mut svc = new_test_svc(config_size(3));
1111 let ex = make_exchange("orderId", "A", "first");
1112 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1113 assert!(matches!(result.input.body, Body::Empty));
1114 assert_eq!(
1115 result.property(CAMEL_AGGREGATOR_PENDING),
1116 Some(&serde_json::json!(true))
1117 );
1118 }
1119
1120 #[tokio::test]
1123 async fn embedded_stash_keeps_carried_claim_until_completion() {
1124 let counter = Arc::new(camel_api::InFlightGauge::new());
1125 let svc = new_test_svc(config_size(2));
1126
1127 let mut first = make_exchange("orderId", "A", "first");
1131 first.in_flight_claim = Some(InFlightClaim::attach(&counter));
1132 let pending = svc.clone().oneshot(first).await.unwrap();
1133 assert_eq!(
1134 pending.property(CAMEL_AGGREGATOR_PENDING),
1135 Some(&serde_json::json!(true)),
1136 "first exchange must be stashed"
1137 );
1138 assert!(
1139 pending.in_flight_claim.is_none(),
1140 "the pending marker carries no claim (it completes immediately)"
1141 );
1142 assert_eq!(counter.total(), 1, "stashed exchange must stay counted");
1143
1144 let mut second = make_exchange("orderId", "A", "second");
1148 second.in_flight_claim = Some(InFlightClaim::attach(&counter));
1149 let aggregated = svc.clone().oneshot(second).await.unwrap();
1150 assert!(
1151 aggregated.property(CAMEL_AGGREGATOR_PENDING).is_none(),
1152 "second exchange must complete the bucket"
1153 );
1154 assert!(
1155 aggregated.in_flight_claim.is_some(),
1156 "aggregated output carries one claim downstream"
1157 );
1158 assert_eq!(
1159 counter.total(),
1160 1,
1161 "consumed input's claim released; the output's stays held"
1162 );
1163 drop(aggregated);
1164 assert_eq!(
1165 counter.total(),
1166 0,
1167 "dropping the completed output releases the last claim"
1168 );
1169 }
1170
1171 #[tokio::test]
1172 async fn embedded_rejection_releases_carried_claim() {
1173 let counter = Arc::new(camel_api::InFlightGauge::new());
1174 let svc = new_test_svc(config_size(2));
1175
1176 let mut bad = Exchange::new(Message::new("no-header"));
1179 bad.in_flight_claim = Some(InFlightClaim::attach(&counter));
1180 let result = svc.clone().oneshot(bad).await;
1181 assert!(result.is_err(), "missing correlation key must reject");
1182 assert_eq!(counter.total(), 0, "rejected submission releases its claim");
1183 }
1184
1185 #[tokio::test]
1186 async fn test_completes_on_size() {
1187 let mut svc = new_test_svc(config_size(3));
1188 for _ in 0..2 {
1189 let ex = make_exchange("orderId", "A", "item");
1190 let r = svc.ready().await.unwrap().call(ex).await.unwrap();
1191 assert!(matches!(r.input.body, Body::Empty));
1192 }
1193 let ex = make_exchange("orderId", "A", "last");
1194 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1195 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1196 assert_eq!(
1197 result.property(CAMEL_AGGREGATED_SIZE),
1198 Some(&serde_json::json!(3u64))
1199 );
1200 }
1201
1202 #[tokio::test]
1203 async fn test_collect_all_produces_json_array() {
1204 let mut svc = new_test_svc(config_size(2));
1205 svc.ready()
1206 .await
1207 .unwrap()
1208 .call(make_exchange("orderId", "A", "alpha"))
1209 .await
1210 .unwrap();
1211 let result = svc
1212 .ready()
1213 .await
1214 .unwrap()
1215 .call(make_exchange("orderId", "A", "beta"))
1216 .await
1217 .unwrap();
1218 let Body::Json(v) = &result.input.body else {
1219 panic!("expected Body::Json")
1220 };
1221 let arr = v.as_array().unwrap();
1222 assert_eq!(arr.len(), 2);
1223 assert_eq!(arr[0], serde_json::json!("alpha"));
1224 assert_eq!(arr[1], serde_json::json!("beta"));
1225 }
1226
1227 #[tokio::test]
1228 async fn test_two_keys_independent_buckets() {
1229 let mut svc = new_test_svc(config_size(3));
1231 svc.ready()
1232 .await
1233 .unwrap()
1234 .call(make_exchange("orderId", "A", "a1"))
1235 .await
1236 .unwrap();
1237 svc.ready()
1238 .await
1239 .unwrap()
1240 .call(make_exchange("orderId", "B", "b1"))
1241 .await
1242 .unwrap();
1243 svc.ready()
1244 .await
1245 .unwrap()
1246 .call(make_exchange("orderId", "A", "a2"))
1247 .await
1248 .unwrap();
1249 let ra = svc
1251 .ready()
1252 .await
1253 .unwrap()
1254 .call(make_exchange("orderId", "A", "a3"))
1255 .await
1256 .unwrap();
1257 assert!(matches!(ra.input.body, Body::Json(_)));
1259 let rb = svc
1261 .ready()
1262 .await
1263 .unwrap()
1264 .call(make_exchange("orderId", "B", "b_check"))
1265 .await
1266 .unwrap();
1267 assert!(matches!(rb.input.body, Body::Empty));
1268 }
1269
1270 #[tokio::test]
1271 async fn test_bucket_resets_after_completion() {
1272 let mut svc = new_test_svc(config_size(2));
1273 svc.ready()
1274 .await
1275 .unwrap()
1276 .call(make_exchange("orderId", "A", "x"))
1277 .await
1278 .unwrap();
1279 svc.ready()
1280 .await
1281 .unwrap()
1282 .call(make_exchange("orderId", "A", "x"))
1283 .await
1284 .unwrap(); let r = svc
1287 .ready()
1288 .await
1289 .unwrap()
1290 .call(make_exchange("orderId", "A", "new"))
1291 .await
1292 .unwrap();
1293 assert!(matches!(r.input.body, Body::Empty)); }
1295
1296 #[tokio::test]
1297 async fn test_completion_size_1_emits_immediately() {
1298 let mut svc = new_test_svc(config_size(1));
1299 let ex = make_exchange("orderId", "A", "solo");
1300 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1301 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1302 }
1303
1304 #[tokio::test]
1305 async fn test_custom_aggregation_strategy() {
1306 use camel_api::aggregator::AggregationFn;
1307 use std::sync::Arc;
1308
1309 let f: AggregationFn = Arc::new(|mut acc: Exchange, next: Exchange| {
1310 let combined = format!(
1311 "{}+{}",
1312 acc.input.body.as_text().unwrap_or(""),
1313 next.input.body.as_text().unwrap_or("")
1314 );
1315 acc.input.body = Body::Text(combined);
1316 acc
1317 });
1318 let config = AggregatorConfig::correlate_by("key")
1319 .complete_when_size(2)
1320 .strategy(AggregationStrategy::Custom(f))
1321 .build()
1322 .unwrap();
1323 let mut svc = new_test_svc(config);
1324 svc.ready()
1325 .await
1326 .unwrap()
1327 .call(make_exchange("key", "X", "hello"))
1328 .await
1329 .unwrap();
1330 let result = svc
1331 .ready()
1332 .await
1333 .unwrap()
1334 .call(make_exchange("key", "X", "world"))
1335 .await
1336 .unwrap();
1337 assert_eq!(result.input.body.as_text(), Some("hello+world"));
1338 }
1339
1340 #[tokio::test]
1341 async fn test_completion_predicate() {
1342 let config = AggregatorConfig::correlate_by("key")
1343 .complete_when(|bucket| {
1344 bucket
1345 .iter()
1346 .any(|e| e.input.body.as_text() == Some("DONE"))
1347 })
1348 .build()
1349 .unwrap();
1350 let mut svc = new_test_svc(config);
1351 svc.ready()
1352 .await
1353 .unwrap()
1354 .call(make_exchange("key", "K", "first"))
1355 .await
1356 .unwrap();
1357 svc.ready()
1358 .await
1359 .unwrap()
1360 .call(make_exchange("key", "K", "second"))
1361 .await
1362 .unwrap();
1363 let result = svc
1364 .ready()
1365 .await
1366 .unwrap()
1367 .call(make_exchange("key", "K", "DONE"))
1368 .await
1369 .unwrap();
1370 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1371 }
1372
1373 #[tokio::test]
1374 async fn test_missing_header_returns_error() {
1375 let mut svc = new_test_svc(config_size(2));
1376 let msg = Message {
1377 headers: Default::default(),
1378 body: Body::Text("no key".into()),
1379 };
1380 let ex = Exchange::new(msg);
1381 let result = svc.ready().await.unwrap().call(ex).await;
1382 assert!(result.is_err());
1383 assert!(matches!(
1384 result.unwrap_err(),
1385 camel_api::CamelError::ProcessorError(_)
1386 ));
1387 }
1388
1389 #[tokio::test]
1390 async fn test_cloned_service_shares_state() {
1391 let svc1 = new_test_svc(config_size(2));
1392 let mut svc2 = svc1.clone();
1393 svc1.clone()
1395 .ready()
1396 .await
1397 .unwrap()
1398 .call(make_exchange("orderId", "A", "from-svc1"))
1399 .await
1400 .unwrap();
1401 let result = svc2
1403 .ready()
1404 .await
1405 .unwrap()
1406 .call(make_exchange("orderId", "A", "from-svc2"))
1407 .await
1408 .unwrap();
1409 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1410 }
1411
1412 #[tokio::test]
1413 async fn test_camel_aggregated_key_property_set() {
1414 let mut svc = new_test_svc(config_size(1));
1415 let ex = make_exchange("orderId", "ORDER-42", "body");
1416 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1417 assert_eq!(
1418 result.property(CAMEL_AGGREGATED_KEY),
1419 Some(&serde_json::json!("ORDER-42"))
1420 );
1421 }
1422
1423 #[tokio::test]
1424 async fn test_aggregator_enforces_max_buckets() {
1425 let config = AggregatorConfig::correlate_by("orderId")
1426 .complete_when_size(2)
1427 .max_buckets(3)
1428 .build()
1429 .unwrap();
1430
1431 let mut svc = new_test_svc(config);
1432
1433 for i in 0..3 {
1435 let ex = make_exchange("orderId", &format!("key-{}", i), "body");
1436 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1437 }
1438
1439 let ex = make_exchange("orderId", "key-4", "body");
1441 let result = svc.ready().await.unwrap().call(ex).await;
1442
1443 assert!(result.is_err(), "Should reject when max buckets reached");
1444 let err = result.unwrap_err().to_string();
1445 assert!(
1446 err.contains("maximum"),
1447 "Error message should contain 'maximum': {}",
1448 err
1449 );
1450 }
1451
1452 #[tokio::test]
1453 async fn test_max_buckets_allows_existing_key() {
1454 let config = AggregatorConfig::correlate_by("orderId")
1455 .complete_when_size(5) .max_buckets(2)
1457 .build()
1458 .unwrap();
1459
1460 let mut svc = new_test_svc(config);
1461
1462 let ex1 = make_exchange("orderId", "key-A", "body1");
1464 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1465 let ex2 = make_exchange("orderId", "key-B", "body2");
1466 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1467
1468 let ex3 = make_exchange("orderId", "key-A", "body3");
1470 let result = svc.ready().await.unwrap().call(ex3).await;
1471 assert!(
1472 result.is_ok(),
1473 "Should allow adding to existing bucket even at max limit"
1474 );
1475 }
1476
1477 #[tokio::test]
1481 async fn test_aggregator_enforces_max_bucket_size() {
1482 let config = AggregatorConfig::correlate_by("orderId")
1483 .complete_when(|_| false)
1485 .max_bucket_size(3)
1486 .build()
1487 .unwrap();
1488
1489 let mut svc = new_test_svc(config);
1490
1491 for _ in 0..3 {
1492 let ex = make_exchange("orderId", "hot-key", "body");
1493 let r = svc.ready().await.unwrap().call(ex).await;
1494 assert!(r.is_ok(), "first 3 exchanges accepted: {r:?}");
1495 }
1496
1497 let ex = make_exchange("orderId", "hot-key", "body");
1498 let result = svc.ready().await.unwrap().call(ex).await;
1499 assert!(result.is_err(), "4th exchange on hot key must be rejected");
1500 let err = result.unwrap_err().to_string();
1501 assert!(
1502 err.contains("maximum"),
1503 "error should mention the per-bucket maximum: {err}"
1504 );
1505
1506 let ex = make_exchange("orderId", "other-key", "body");
1508 let result = svc.ready().await.unwrap().call(ex).await;
1509 assert!(result.is_ok(), "other keys still accepted: {result:?}");
1510 }
1511
1512 #[tokio::test]
1513 async fn test_bucket_ttl_eviction() {
1514 let config = AggregatorConfig::correlate_by("orderId")
1515 .complete_when_size(10) .bucket_ttl(Duration::from_millis(50))
1517 .build()
1518 .unwrap();
1519
1520 let mut svc = new_test_svc(config);
1521
1522 let ex1 = make_exchange("orderId", "key-A", "body1");
1524 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1525
1526 tokio::time::sleep(Duration::from_millis(100)).await;
1528
1529 let ex2 = make_exchange("orderId", "key-B", "body2");
1531 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1532
1533 let ex3 = make_exchange("orderId", "key-A", "body3");
1536 let result = svc.ready().await.unwrap().call(ex3).await;
1537 assert!(result.is_ok(), "Should be able to recreate evicted bucket");
1538 }
1539
1540 #[tokio::test(start_paused = true)]
1541 async fn test_timeout_completes_bucket() {
1542 let config = AggregatorConfig::correlate_by("key")
1543 .complete_on_timeout(Duration::from_millis(100))
1544 .build()
1545 .unwrap();
1546 let mut svc = new_test_svc(config);
1547 let ex = make_exchange("key", "A", "data");
1548 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1549 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_some());
1550
1551 tokio::time::sleep(Duration::from_millis(200)).await;
1552
1553 assert_eq!(
1554 svc.buckets.lock().unwrap().len(),
1555 0,
1556 "bucket should be removed after timeout"
1557 );
1558 }
1559
1560 #[tokio::test(start_paused = true)]
1561 async fn test_timeout_resets_on_new_exchange() {
1562 let config = AggregatorConfig::correlate_by("key")
1563 .complete_on_timeout(Duration::from_millis(150))
1564 .build()
1565 .unwrap();
1566 let mut svc = new_test_svc(config);
1567
1568 let ex1 = make_exchange("key", "A", "first");
1569 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1570
1571 tokio::time::sleep(Duration::from_millis(100)).await;
1572
1573 let ex2 = make_exchange("key", "A", "second");
1574 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1575
1576 tokio::time::sleep(Duration::from_millis(100)).await;
1577
1578 assert_eq!(
1579 svc.buckets.lock().unwrap().len(),
1580 1,
1581 "bucket should still exist — timeout was reset"
1582 );
1583
1584 tokio::time::sleep(Duration::from_millis(100)).await;
1585
1586 assert_eq!(
1587 svc.buckets.lock().unwrap().len(),
1588 0,
1589 "bucket should be gone after timeout fires"
1590 );
1591 }
1592
1593 #[tokio::test]
1594 async fn test_composable_size_and_timeout() {
1595 let config = AggregatorConfig::correlate_by("key")
1596 .complete_on_size_or_timeout(2, Duration::from_millis(200))
1597 .build()
1598 .unwrap();
1599 let mut svc = new_test_svc(config);
1600
1601 let ex1 = make_exchange("key", "A", "first");
1602 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1603 assert!(svc.buckets.lock().unwrap().contains_key("\"A\""));
1604
1605 let ex2 = make_exchange("key", "A", "second");
1606 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1607 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1608 assert_eq!(
1609 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1610 Some(&serde_json::json!("size"))
1611 );
1612 }
1613
1614 #[tokio::test(start_paused = true)]
1615 async fn test_discard_on_timeout() {
1616 let config = AggregatorConfig::correlate_by("key")
1617 .complete_on_timeout(Duration::from_millis(50))
1618 .discard_on_timeout(true)
1619 .build()
1620 .unwrap();
1621 let (tx, mut rx) = mpsc::channel(256);
1622 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1623 let cancel = CancellationToken::new();
1624 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1625
1626 let ex = make_exchange("key", "A", "data");
1627 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1628
1629 tokio::time::sleep(Duration::from_millis(100)).await;
1630
1631 assert!(
1632 rx.try_recv().is_err(),
1633 "no emit expected with discard_on_timeout"
1634 );
1635 assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1636 assert!(
1637 svc.timeout_tasks.lock().unwrap().is_empty(),
1638 "timeout task should be cleaned up"
1639 );
1640 }
1641
1642 #[tokio::test]
1643 async fn test_force_completion_on_stop() {
1644 let config = AggregatorConfig::correlate_by("key")
1645 .complete_when_size(10)
1646 .force_completion_on_stop(true)
1647 .build()
1648 .unwrap();
1649 let (tx, mut rx) = mpsc::channel(256);
1650 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1651 let cancel = CancellationToken::new();
1652 let svc = AggregatorService::new(config, tx, registry, cancel);
1653
1654 let mut call_svc = svc.clone();
1655 let ex = make_exchange("key", "A", "data");
1656 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1657
1658 svc.force_complete_all();
1659
1660 let result = rx.try_recv().expect("should emit on force-complete");
1661 let result = result.exchange;
1662 assert!(
1663 result.input.body.as_text().is_some() || matches!(result.input.body, Body::Json(_))
1664 );
1665 assert_eq!(
1666 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1667 Some(&serde_json::json!("stop"))
1668 );
1669 }
1670
1671 #[tokio::test]
1672 async fn test_completion_reason_property_size() {
1673 let config = AggregatorConfig::correlate_by("key")
1674 .complete_when_size(1)
1675 .build()
1676 .unwrap();
1677 let mut svc = new_test_svc(config);
1678 let ex = make_exchange("key", "X", "body");
1679 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1680 assert_eq!(
1681 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1682 Some(&serde_json::json!("size"))
1683 );
1684 }
1685
1686 #[tokio::test]
1687 async fn test_completion_reason_property_predicate() {
1688 let config = AggregatorConfig::correlate_by("key")
1689 .complete_when(|_| true)
1690 .build()
1691 .unwrap();
1692 let mut svc = new_test_svc(config);
1693 let ex = make_exchange("key", "X", "body");
1694 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1695 assert_eq!(
1696 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1697 Some(&serde_json::json!("predicate"))
1698 );
1699 }
1700
1701 #[tokio::test(start_paused = true)]
1702 async fn test_size_completes_before_timeout() {
1703 let config = AggregatorConfig::correlate_by("key")
1704 .complete_on_size_or_timeout(2, Duration::from_millis(200))
1705 .build()
1706 .unwrap();
1707 let mut svc = new_test_svc(config);
1708
1709 let ex1 = make_exchange("key", "A", "first");
1710 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1711
1712 let ex2 = make_exchange("key", "A", "second");
1713 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1714
1715 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1716 assert_eq!(
1717 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1718 Some(&serde_json::json!("size"))
1719 );
1720 assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1721
1722 tokio::time::sleep(Duration::from_millis(300)).await;
1723 assert_eq!(
1724 svc.buckets.lock().unwrap().len(),
1725 0,
1726 "no re-fire after timeout"
1727 );
1728 }
1729
1730 #[tokio::test(start_paused = true)]
1731 async fn test_concurrent_timeout_fire_and_new_exchange() {
1732 let config = AggregatorConfig::correlate_by("key")
1733 .complete_on_size_or_timeout(2, Duration::from_millis(100))
1734 .build()
1735 .unwrap();
1736 let (tx, mut rx) = mpsc::channel(256);
1737 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1738 let cancel = CancellationToken::new();
1739 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1740
1741 let ex = make_exchange("key", "A", "data");
1742 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1743
1744 tokio::time::sleep(Duration::from_millis(150)).await;
1746
1747 let ex2 = make_exchange("key", "A", "data2");
1749 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1750 assert!(
1751 result.property(CAMEL_AGGREGATOR_PENDING).is_some(),
1752 "should be pending in new bucket"
1753 );
1754
1755 let mut late_count = 0;
1757 while rx.try_recv().is_ok() {
1758 late_count += 1;
1759 }
1760 assert_eq!(
1761 late_count, 1,
1762 "exactly 1 late emit from the timed-out bucket"
1763 );
1764 }
1765
1766 #[tokio::test(start_paused = true)]
1767 async fn test_late_channel_full_drops_with_warning() {
1768 let config = AggregatorConfig::correlate_by("key")
1769 .complete_on_timeout(Duration::from_millis(50))
1770 .build()
1771 .unwrap();
1772 let (tx, mut rx) = mpsc::channel(1);
1773 rx.close();
1774 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1775 let cancel = CancellationToken::new();
1776 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1777
1778 let ex = make_exchange("key", "A", "data");
1779 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1780
1781 tokio::time::sleep(Duration::from_millis(100)).await;
1782 assert_eq!(
1783 svc.buckets.lock().unwrap().len(),
1784 0,
1785 "bucket removed despite channel closed"
1786 );
1787 }
1788
1789 #[tokio::test]
1795 async fn test_da3_force_complete_all_drops_on_saturated_channel() {
1796 let config = AggregatorConfig::correlate_by("k")
1797 .complete_when_size(10)
1798 .force_completion_on_stop(true)
1799 .build()
1800 .unwrap();
1801 let (late_tx, mut late_rx) = mpsc::channel::<AggregateEmission>(1);
1803 late_tx
1807 .try_send(AggregateEmission {
1808 exchange: make_exchange("k", "99", "dummy"),
1809 claims: Vec::new(),
1810 })
1811 .expect("pre-fill succeeds");
1812
1813 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1814 let cancel = CancellationToken::new();
1815 let mut svc = AggregatorService::new(config, late_tx, registry, cancel);
1816
1817 for v in ["1", "2", "3"] {
1820 let ex = make_exchange("k", v, "body");
1821 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1822 }
1823 assert_eq!(svc.buckets.lock().unwrap().len(), 3);
1824
1825 svc.force_complete_all();
1827
1828 let pre_fill = late_rx
1830 .try_recv()
1831 .expect("pre-fill should still be in channel");
1832 assert_eq!(
1833 pre_fill.exchange.input.headers.get("k"),
1834 Some(&serde_json::json!("99"))
1835 );
1836
1837 assert!(matches!(
1840 late_rx.try_recv(),
1841 Err(mpsc::error::TryRecvError::Empty)
1842 ));
1843
1844 assert!(svc.buckets.lock().unwrap().is_empty());
1846 }
1847
1848 #[tokio::test]
1849 async fn test_aggregate_stream_bodies_creates_valid_json() {
1850 use bytes::Bytes;
1851 use camel_api::{Body, StreamBody, StreamMetadata};
1852 use futures::stream;
1853 use tokio::sync::Mutex;
1854
1855 let chunks = vec![Ok(Bytes::from("test"))];
1856 let stream_body = StreamBody {
1857 stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1858 metadata: StreamMetadata {
1859 origin: Some("file:///test.txt".to_string()),
1860 ..Default::default()
1861 },
1862 };
1863
1864 let ex1 = Exchange::new(Message {
1865 headers: Default::default(),
1866 body: Body::Stream(stream_body),
1867 });
1868
1869 let exchanges = vec![ex1];
1870 let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1871
1872 let exchange = result.expect("Expected Ok result");
1873 assert!(
1874 matches!(exchange.input.body, Body::Json(_)),
1875 "Expected Json body"
1876 );
1877
1878 if let Body::Json(value) = exchange.input.body {
1879 let json_str = serde_json::to_string(&value).unwrap();
1880 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1881
1882 assert!(parsed.is_array(), "Result should be an array");
1883 let arr = parsed.as_array().unwrap();
1884 assert!(arr[0].is_object(), "First element should be an object");
1885 assert!(
1886 arr[0]["_stream"].is_object(),
1887 "Should contain _stream object"
1888 );
1889 assert_eq!(arr[0]["_stream"]["origin"], "file:///test.txt");
1890 assert_eq!(
1891 arr[0]["_stream"]["placeholder"], true,
1892 "placeholder flag should be true"
1893 );
1894 }
1895 }
1896
1897 #[tokio::test]
1898 async fn test_aggregate_stream_bodies_with_none_origin() {
1899 use bytes::Bytes;
1900 use camel_api::{Body, StreamBody, StreamMetadata};
1901 use futures::stream;
1902 use tokio::sync::Mutex;
1903
1904 let chunks = vec![Ok(Bytes::from("test"))];
1905 let stream_body = StreamBody {
1906 stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1907 metadata: StreamMetadata {
1908 origin: None,
1909 ..Default::default()
1910 },
1911 };
1912
1913 let ex1 = Exchange::new(Message {
1914 headers: Default::default(),
1915 body: Body::Stream(stream_body),
1916 });
1917
1918 let exchanges = vec![ex1];
1919 let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1920
1921 let exchange = result.expect("Expected Ok result");
1922 assert!(
1923 matches!(exchange.input.body, Body::Json(_)),
1924 "Expected Json body"
1925 );
1926
1927 if let Body::Json(value) = exchange.input.body {
1928 let json_str = serde_json::to_string(&value).unwrap();
1929 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1930
1931 assert!(parsed.is_array(), "Result should be an array");
1932 let arr = parsed.as_array().unwrap();
1933 assert!(arr[0].is_object(), "First element should be an object");
1934 assert!(
1935 arr[0]["_stream"].is_object(),
1936 "Should contain _stream object"
1937 );
1938 assert_eq!(
1939 arr[0]["_stream"]["origin"],
1940 serde_json::Value::Null,
1941 "origin should be null when None"
1942 );
1943 assert_eq!(
1944 arr[0]["_stream"]["placeholder"], true,
1945 "placeholder flag should be true"
1946 );
1947 }
1948 }
1949
1950 #[tokio::test]
1951 async fn timeout_completion_clears_handle_from_map() {
1952 let config = AggregatorConfig::correlate_by("key")
1957 .complete_on_timeout(Duration::from_millis(50))
1958 .build()
1959 .unwrap();
1960 let (tx, _rx) = mpsc::channel(256);
1961 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1962 let cancel = CancellationToken::new();
1963 let svc = AggregatorService::new(config, tx, registry, cancel);
1964
1965 let mut call_svc = svc.clone();
1967 let ex = make_exchange("key", "A", "data");
1968 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1969 assert!(
1970 !svc.timeout_handles.lock().unwrap().is_empty(),
1971 "handle should exist while timeout pending"
1972 );
1973
1974 tokio::time::sleep(Duration::from_millis(200)).await;
1976
1977 assert!(
1978 svc.timeout_handles.lock().unwrap().is_empty(),
1979 "handle should be cleared from map after natural timeout completion (was leak)"
1980 );
1981 }
1982
1983 #[tokio::test]
1984 async fn aggregator_shutdown_via_trait_dispatch() {
1985 let config = AggregatorConfig::correlate_by("key")
1988 .complete_when_size(10)
1989 .build()
1990 .unwrap();
1991 let (tx, _rx) = mpsc::channel(256);
1992 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1993 let cancel = CancellationToken::new();
1994 let svc = AggregatorService::new(config, tx, registry, cancel);
1995
1996 let step: Arc<dyn StepLifecycle> = Arc::new(svc);
1997 step.shutdown(StepShutdownReason::RouteStop)
1998 .await
1999 .expect("first shutdown should succeed");
2000 step.shutdown(StepShutdownReason::RouteStop)
2001 .await
2002 .expect("second shutdown (idempotent) should succeed");
2003 }
2004
2005 #[tokio::test(start_paused = true)]
2006 async fn test_shutdown_awaits_timeout_handles() {
2007 let config = AggregatorConfig::correlate_by("key")
2008 .complete_on_timeout(Duration::from_millis(100))
2009 .build()
2010 .unwrap();
2011 let (tx, _rx) = mpsc::channel(256);
2012 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2013 let cancel = CancellationToken::new();
2014 let svc = AggregatorService::new(config, tx, registry, cancel);
2015
2016 let mut call_svc = svc.clone();
2018 let ex = make_exchange("key", "A", "data");
2019 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
2020
2021 assert!(
2023 !svc.timeout_handles.lock().unwrap().is_empty(),
2024 "should have a timeout handle"
2025 );
2026
2027 svc.shutdown_inner().await;
2030
2031 assert!(
2032 svc.timeout_handles.lock().unwrap().is_empty(),
2033 "all handles should be cleaned up after shutdown"
2034 );
2035 }
2036
2037 #[tokio::test]
2044 async fn test_unique_key_flood_stays_bounded_by_default() {
2045 let config = AggregatorConfig::correlate_by("orderId")
2047 .complete_when_size(1_000_000) .build()
2049 .unwrap();
2050 let mut svc = new_test_svc(config);
2051
2052 for i in 0..10_000usize {
2055 let ex = make_exchange("orderId", &format!("key-{i}"), "body");
2056 let result = svc.ready().await.unwrap().call(ex).await;
2057 assert!(result.is_ok(), "key {i} should be accepted under the cap");
2058 }
2059 let ex = make_exchange("orderId", "key-10001", "body");
2060 let result = svc.ready().await.unwrap().call(ex).await;
2061 assert!(
2062 result.is_err(),
2063 "10_001st unique key must be rejected by the max_buckets cap"
2064 );
2065 let err = result.unwrap_err().to_string();
2066 assert!(
2067 err.contains("maximum") || err.contains("max"),
2068 "error should mention cap: {err}"
2069 );
2070 }
2071
2072 #[tokio::test]
2078 async fn test_background_sweep_spawns_on_first_poll_not_construction() {
2079 let config = AggregatorConfig::correlate_by("key")
2080 .complete_when_size(10_000)
2081 .bucket_ttl(Duration::from_millis(50))
2082 .build()
2083 .unwrap();
2084 let cancel = CancellationToken::new();
2085 let (tx, _rx) = mpsc::channel(8);
2086 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2087 let mut svc = AggregatorService::new(config, tx, registry, cancel.clone());
2088
2089 assert!(
2091 svc.sweep_handle
2092 .lock()
2093 .unwrap_or_else(|e| e.into_inner())
2094 .is_none(),
2095 "sweep must NOT be spawned at construction (runtime-free new)"
2096 );
2097
2098 let _ = svc.ready().await.unwrap();
2101 let sweep_present = svc
2102 .sweep_handle
2103 .lock()
2104 .unwrap_or_else(|e| e.into_inner())
2105 .is_some();
2106 assert!(
2107 sweep_present,
2108 "sweep handle should be Some after first poll when bucket_ttl is set"
2109 );
2110
2111 cancel.cancel();
2113 tokio::time::sleep(Duration::from_millis(50)).await;
2115 }
2116
2117 struct QueueDepthRecorder(Mutex<Vec<(String, usize)>>);
2120 impl MetricsCollector for QueueDepthRecorder {
2121 fn record_exchange_duration(&self, _: &str, _: std::time::Duration) {}
2122 fn increment_errors(&self, _: &str, _: &str) {}
2123 fn increment_exchanges(&self, _: &str) {}
2124 fn set_queue_depth(&self, queue: &str, depth: usize) {
2125 self.0
2126 .lock()
2127 .unwrap_or_else(|e| e.into_inner())
2128 .push((queue.to_string(), depth));
2129 }
2130 fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
2131 }
2132
2133 async fn await_depth_sample(
2136 recorder: &QueueDepthRecorder,
2137 label: &str,
2138 pred: impl Fn(usize) -> bool,
2139 ) {
2140 let deadline = std::time::Instant::now() + Duration::from_secs(2);
2141 loop {
2142 let matched = recorder
2143 .0
2144 .lock()
2145 .unwrap_or_else(|e| e.into_inner())
2146 .iter()
2147 .any(|(q, d)| q == label && pred(*d));
2148 if matched {
2149 return;
2150 }
2151 assert!(
2152 std::time::Instant::now() < deadline,
2153 "no queue-depth sample for '{label}' matched within 2s"
2154 );
2155 tokio::time::sleep(Duration::from_millis(20)).await;
2156 }
2157 }
2158
2159 #[tokio::test]
2164 async fn test_sweep_reports_queue_depth() {
2165 let config = AggregatorConfig::correlate_by("orderId")
2166 .complete_when_size(3)
2167 .bucket_ttl(Duration::from_millis(100))
2171 .build()
2172 .unwrap();
2173 let cancel = CancellationToken::new();
2174 let (tx, _rx) = mpsc::channel(8);
2175 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2176 let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
2177 let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
2178 Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
2179 "aggregator:t",
2180 );
2181
2182 let _ = svc.ready().await.unwrap();
2184
2185 let ex = make_exchange("orderId", "g1", "partial");
2187 let _ = svc.ready().await.unwrap().call(ex).await;
2188
2189 await_depth_sample(&recorder, "aggregator:t", |d| d > 0).await;
2190
2191 let ex = make_exchange("orderId", "g1", "b");
2193 let _ = svc.ready().await.unwrap().call(ex).await;
2194 let ex = make_exchange("orderId", "g1", "c");
2195 let _ = svc.ready().await.unwrap().call(ex).await;
2196
2197 await_depth_sample(&recorder, "aggregator:t", |d| d == 0).await;
2198
2199 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2200 }
2201
2202 #[tokio::test]
2207 async fn test_no_ttl_aggregator_reports_queue_depth() {
2208 use camel_api::aggregator::CorrelationStrategy;
2209
2210 let config = AggregatorConfig {
2211 header_name: "orderId".into(),
2212 completion: CompletionMode::Single(CompletionCondition::Size(3)),
2213 correlation: CorrelationStrategy::HeaderName("orderId".into()),
2214 strategy: AggregationStrategy::CollectAll,
2215 max_buckets: Some(100),
2216 max_bucket_size: Some(100),
2217 bucket_ttl: None,
2218 force_completion_on_stop: false,
2219 discard_on_timeout: false,
2220 max_timeout_tasks: 64,
2221 };
2222 let cancel = CancellationToken::new();
2223 let (tx, _rx) = mpsc::channel(8);
2224 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2225 let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
2226 let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
2227 Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
2228 "aggregator:nottl",
2229 );
2230
2231 let _ = svc.ready().await.unwrap();
2232 assert!(
2233 svc.sweep_handle
2234 .lock()
2235 .unwrap_or_else(|e| e.into_inner())
2236 .is_some(),
2237 "metrics-only config (bucket_ttl = None) must still spawn the sweep"
2238 );
2239
2240 let ex = make_exchange("orderId", "g1", "partial");
2242 let _ = svc.ready().await.unwrap().call(ex).await;
2243 await_depth_sample(&recorder, "aggregator:nottl", |d| d > 0).await;
2244
2245 let ex = make_exchange("orderId", "g1", "b");
2247 let _ = svc.ready().await.unwrap().call(ex).await;
2248 let ex = make_exchange("orderId", "g1", "c");
2249 let _ = svc.ready().await.unwrap().call(ex).await;
2250 await_depth_sample(&recorder, "aggregator:nottl", |d| d == 0).await;
2251
2252 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2253 }
2254
2255 #[tokio::test]
2260 async fn test_transient_clone_drop_keeps_sweep_sampling() {
2261 let config = AggregatorConfig::correlate_by("orderId")
2262 .complete_when_size(3)
2263 .bucket_ttl(Duration::from_millis(100))
2264 .build()
2265 .unwrap();
2266 let cancel = CancellationToken::new();
2267 let (tx, _rx) = mpsc::channel(8);
2268 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2269 let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
2270 let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
2271 Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
2272 "aggregator:clone",
2273 );
2274
2275 let _ = svc.ready().await.unwrap();
2276
2277 drop(svc.clone());
2279 assert!(
2280 svc.sweep_handle
2281 .lock()
2282 .unwrap_or_else(|e| e.into_inner())
2283 .is_some(),
2284 "transient clone drop must not abort the shared sweep"
2285 );
2286
2287 let ex = make_exchange("orderId", "g1", "partial");
2289 let _ = svc.ready().await.unwrap().call(ex).await;
2290 await_depth_sample(&recorder, "aggregator:clone", |d| d > 0).await;
2291
2292 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2293 }
2294
2295 #[tokio::test]
2298 async fn test_aggregator_timeout_task_cap_no_panic_under_flood() {
2299 use camel_api::aggregator::CorrelationStrategy;
2302
2303 let config = AggregatorConfig {
2304 header_name: "k".into(),
2305 completion: CompletionMode::Any(vec![
2306 CompletionCondition::Size(999),
2307 CompletionCondition::Timeout(Duration::from_secs(30)),
2308 ]),
2309 correlation: CorrelationStrategy::HeaderName("k".into()),
2310 strategy: AggregationStrategy::CollectAll,
2311 max_buckets: Some(50),
2312 max_bucket_size: Some(50),
2313 bucket_ttl: Some(Duration::from_secs(30)),
2314 force_completion_on_stop: false,
2315 discard_on_timeout: false,
2316 max_timeout_tasks: 2,
2317 };
2318 let (late_tx, mut late_rx) = mpsc::channel(64);
2319 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2320 let cancel = CancellationToken::new();
2321 let svc = AggregatorService::new(config, late_tx, registry, cancel);
2322
2323 for i in 0..20u64 {
2325 let mut ex = Exchange::new(Message {
2326 headers: HashMap::from([("k".to_string(), serde_json::json!(i))]),
2327 body: Body::Text(i.to_string()),
2328 });
2329 ex.input
2330 .headers
2331 .insert("k".to_string(), serde_json::json!(i));
2332 let outcome = tokio::time::timeout(Duration::from_secs(2), async {
2333 let mut s = svc.clone();
2334 use tower::ServiceExt;
2335 s.ready().await.unwrap().call(ex).await
2336 })
2337 .await;
2338 assert!(outcome.is_ok(), "call {} hung/panicked under task cap", i);
2339 let res = outcome.unwrap().unwrap();
2341 assert_eq!(
2342 res.properties
2343 .get(CAMEL_AGGREGATOR_PENDING)
2344 .and_then(|v| v.as_bool()),
2345 Some(true),
2346 "exchange {} should be pending",
2347 i
2348 );
2349 }
2350 let _ = late_rx.try_recv();
2352 }
2353
2354 #[tokio::test]
2362 async fn test_release_unarmed_buckets_discards_cap_exceeded_keeps_armed() {
2363 let config = AggregatorConfig::correlate_by("k")
2364 .complete_on_timeout(Duration::from_millis(400))
2365 .max_timeout_tasks(1)
2366 .build()
2367 .unwrap();
2368 let (late_tx, mut late_rx) = mpsc::channel(8);
2369 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2370 let cancel = CancellationToken::new();
2371 let svc = AggregatorService::new(config, late_tx, registry, cancel);
2372
2373 let a = make_exchange("k", "a", "body-a");
2376 let mut sa = svc.clone();
2377 let _ = sa.ready().await.unwrap().call(a).await.unwrap();
2378 let b = make_exchange("k", "b", "body-b");
2379 let mut sb = svc.clone();
2380 let _ = sb.ready().await.unwrap().call(b).await.unwrap();
2381
2382 {
2383 let armed = svc.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
2384 assert_eq!(armed.len(), 1, "cap=1 must arm exactly one timeout task");
2385 assert!(
2386 armed.contains_key("\"a\""),
2387 "first key must hold the armed slot, got {armed:?}"
2388 );
2389 }
2390
2391 svc.release_unarmed_buckets();
2393 {
2394 let buckets = svc.buckets.lock().unwrap_or_else(|e| e.into_inner());
2395 assert!(
2396 buckets.contains_key("\"a\""),
2397 "armed bucket must survive the release"
2398 );
2399 assert!(
2400 !buckets.contains_key("\"b\""),
2401 "cap-exceeded (unarmed) bucket must be released, not orphaned"
2402 );
2403 }
2404
2405 let emitted = tokio::time::timeout(Duration::from_secs(2), late_rx.recv())
2408 .await
2409 .expect("armed bucket must emit on its timeout")
2410 .expect("late channel must stay open");
2411 assert_eq!(
2412 emitted
2413 .exchange
2414 .properties
2415 .get(CAMEL_AGGREGATED_COMPLETION_REASON),
2416 Some(&serde_json::json!("timeout"))
2417 );
2418
2419 assert!(
2423 tokio::time::timeout(Duration::from_millis(600), late_rx.recv())
2424 .await
2425 .is_err(),
2426 "released unarmed bucket must not emit"
2427 );
2428
2429 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2430 }
2431
2432 #[tokio::test]
2433 async fn evaluate_completion_predicate_or_combines_any() {
2434 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2435 use camel_language_api::Language;
2436 use std::collections::HashMap;
2437
2438 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2440 registry.lock().unwrap().insert(
2441 "simple".to_string(),
2442 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2443 );
2444
2445 let incoming = make_exchange("k", "X", "hello");
2446
2447 let mode = CompletionMode::Any(vec![
2449 CompletionCondition::PredicateExpr {
2450 expr: "${body} == 'NOPE'".to_string(),
2451 language: "simple".to_string(),
2452 },
2453 CompletionCondition::PredicateExpr {
2454 expr: "${body} == 'hello'".to_string(),
2455 language: "simple".to_string(),
2456 },
2457 ]);
2458
2459 let satisfied = evaluate_completion_predicate(&mode, &incoming, ®istry)
2460 .await
2461 .expect("eval must succeed");
2462 assert!(satisfied, "second predicate matches → OR true");
2463 }
2464
2465 #[tokio::test]
2466 async fn evaluate_completion_predicate_no_predicate_skips_registry() {
2467 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2468 use std::collections::HashMap;
2469
2470 let mode = CompletionMode::Single(CompletionCondition::Size(3));
2474 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2475 let incoming = make_exchange("k", "X", "hello");
2476 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
2477 assert!(result.is_ok(), "fast path must not error: {:?}", result);
2478 assert!(!result.unwrap(), "no predicate → not satisfied");
2479 }
2480
2481 #[tokio::test]
2482 async fn evaluate_completion_predicate_unregistered_language_errors() {
2483 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2484
2485 let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
2486 expr: "${body} == 'DONE'".to_string(),
2487 language: "nonexistent-lang".to_string(),
2488 });
2489 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2490 let incoming = make_exchange("k", "X", "hello");
2491 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
2492 assert!(result.is_err(), "unregistered language must error");
2493 }
2494
2495 #[tokio::test]
2496 async fn evaluate_completion_predicate_all_miss_returns_false() {
2497 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2498 use camel_language_api::Language;
2499 use std::collections::HashMap;
2500
2501 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2502 registry.lock().unwrap().insert(
2503 "simple".to_string(),
2504 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2505 );
2506
2507 let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
2508 expr: "${body} == 'NOPE'".to_string(),
2509 language: "simple".to_string(),
2510 });
2511 let incoming = make_exchange("k", "X", "hello");
2512 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
2513 assert!(result.is_ok(), "eval must succeed: {:?}", result);
2514 assert!(!result.unwrap(), "predicate does not match");
2515 }
2516
2517 #[tokio::test]
2518 async fn completion_predicate_non_bool_is_error() {
2519 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2520 use camel_language_api::Language;
2521 use std::collections::HashMap;
2522
2523 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2524 registry.lock().unwrap().insert(
2525 "simple".to_string(),
2526 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2527 );
2528
2529 let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
2533 expr: "${body}".to_string(),
2534 language: "simple".to_string(),
2535 });
2536 let incoming = make_exchange("k", "X", "x");
2537 let err = evaluate_completion_predicate(&mode, &incoming, ®istry)
2538 .await
2539 .expect_err("non-bool predicate must error, not coerce to false");
2540 assert!(
2544 matches!(&err, CamelError::ProcessorError(msg) if msg.contains("bool")),
2545 "error must carry the type-mismatch text naming bool: {err:?}"
2546 );
2547 }
2548
2549 #[tokio::test]
2550 async fn completion_predicate_eval_error_propagates() {
2551 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2552 use camel_language_api::Language;
2553 use std::collections::HashMap;
2554
2555 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2556 registry.lock().unwrap().insert(
2557 "simple".to_string(),
2558 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2559 );
2560
2561 let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
2564 expr: "${body} > 1".to_string(),
2565 language: "simple".to_string(),
2566 });
2567 let incoming = make_exchange("k", "X", "hello");
2568 let err = evaluate_completion_predicate(&mode, &incoming, ®istry)
2569 .await
2570 .expect_err("evaluation error must propagate, not coerce to false");
2571 assert!(
2572 matches!(err, CamelError::ProcessorError(_)),
2573 "eval error must be a ProcessorError: {err:?}"
2574 );
2575 }
2576
2577 #[tokio::test]
2578 async fn completion_predicate_expr_completes_on_match() {
2579 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2580 use camel_language_api::Language;
2581 use std::collections::HashMap;
2582
2583 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2585 registry.lock().unwrap().insert(
2586 "simple".to_string(),
2587 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2588 );
2589
2590 let mut config = AggregatorConfig::correlate_by("key")
2595 .complete_when_size(999) .build()
2597 .expect("config builds");
2598 config.completion = CompletionMode::Single(CompletionCondition::PredicateExpr {
2599 expr: "${body} == 'DONE'".to_string(),
2600 language: "simple".to_string(),
2601 });
2602
2603 let mut svc = new_test_svc_with_registry(config, registry);
2604
2605 let r = svc
2607 .ready()
2608 .await
2609 .unwrap()
2610 .call(make_exchange("key", "K", "first"))
2611 .await
2612 .unwrap();
2613 assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_some());
2614
2615 let r = svc
2617 .ready()
2618 .await
2619 .unwrap()
2620 .call(make_exchange("key", "K", "DONE"))
2621 .await
2622 .unwrap();
2623 assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_none());
2624 assert_eq!(
2625 r.property(CAMEL_AGGREGATED_COMPLETION_REASON),
2626 Some(&serde_json::json!("predicate"))
2627 );
2628 }
2629
2630 #[tokio::test]
2642 async fn test_da1_strategy_receives_two_exchanges_first_message_preserved() {
2643 use camel_api::aggregator::{AggregationFn, AggregationStrategy};
2644 use std::sync::Arc;
2645
2646 let recorded: Arc<std::sync::Mutex<Vec<(String, String)>>> =
2647 Arc::new(std::sync::Mutex::new(Vec::new()));
2648 let recorded_for_closure = Arc::clone(&recorded);
2649
2650 let f: AggregationFn = Arc::new(move |old: Exchange, new: Exchange| {
2651 let old_body = old.input.body.as_text().unwrap_or("").to_string();
2652 let new_body = new.input.body.as_text().unwrap_or("").to_string();
2653 recorded_for_closure
2654 .lock()
2655 .expect("recorded mutex poisoned")
2656 .push((old_body, new_body));
2657 new
2658 });
2659
2660 let config = AggregatorConfig::correlate_by("k")
2661 .complete_when_size(2)
2662 .strategy(AggregationStrategy::Custom(f))
2663 .build()
2664 .unwrap();
2665 let mut svc = new_test_svc(config);
2666
2667 let first = svc
2669 .ready()
2670 .await
2671 .unwrap()
2672 .call(make_exchange("k", "1", "A"))
2673 .await
2674 .unwrap();
2675 assert!(
2676 first.property(CAMEL_AGGREGATOR_PENDING).is_some(),
2677 "first message must leave the bucket pending (size < 2)"
2678 );
2679 assert!(
2680 recorded.lock().expect("recorded mutex poisoned").is_empty(),
2681 "strategy must NOT be invoked on the first message of a bucket"
2682 );
2683
2684 let _completed = svc
2687 .ready()
2688 .await
2689 .unwrap()
2690 .call(make_exchange("k", "1", "B"))
2691 .await
2692 .unwrap();
2693
2694 let recorded = recorded.lock().expect("recorded mutex poisoned");
2695 assert_eq!(
2696 recorded.len(),
2697 1,
2698 "strategy must be invoked exactly once across the two-message bucket, got {recorded:?}"
2699 );
2700 assert_eq!(
2701 recorded[0],
2702 ("A".to_string(), "B".to_string()),
2703 "strategy must observe the first message as `old` and the second as `new`, \
2704 with both bodies preserved unchanged"
2705 );
2706 }
2707
2708 #[tokio::test]
2711 async fn sweep_shutdown_cancels_task() {
2712 let config = AggregatorConfig::correlate_by("key")
2713 .complete_when_size(10)
2714 .bucket_ttl(Duration::from_millis(100))
2715 .build()
2716 .unwrap();
2717 let (tx, _rx) = mpsc::channel(256);
2718 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2719 let cancel = CancellationToken::new();
2720 let mut svc = AggregatorService::new(config, tx, registry, cancel);
2721
2722 let _ = svc.ready().await.unwrap();
2723
2724 assert!(
2725 svc.sweep_handle
2726 .lock()
2727 .unwrap_or_else(|e| e.into_inner())
2728 .is_some(),
2729 "sweep handle should be Some after poll_ready"
2730 );
2731
2732 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2733
2734 assert!(
2735 svc.sweep_handle
2736 .lock()
2737 .unwrap_or_else(|e| e.into_inner())
2738 .is_none(),
2739 "sweep handle should be None after shutdown (taken + aborted)"
2740 );
2741 assert!(
2742 svc.sweep_cancel
2743 .lock()
2744 .unwrap_or_else(|e| e.into_inner())
2745 .is_cancelled(),
2746 "sweep_cancel token should be cancelled after shutdown"
2747 );
2748
2749 tokio::time::sleep(Duration::from_millis(50)).await;
2750 }
2751
2752 #[tokio::test]
2753 async fn sweep_start_respawns_after_shutdown() {
2754 let config = AggregatorConfig::correlate_by("key")
2755 .complete_when_size(10)
2756 .bucket_ttl(Duration::from_millis(100))
2757 .build()
2758 .unwrap();
2759 let (tx, _rx) = mpsc::channel(256);
2760 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2761 let cancel = CancellationToken::new();
2762 let mut svc = AggregatorService::new(config, tx, registry, cancel);
2763
2764 let _ = svc.ready().await.unwrap();
2765 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2766
2767 svc.start().await.unwrap();
2768 let _ = svc.ready().await.unwrap();
2769
2770 assert!(
2771 svc.sweep_handle
2772 .lock()
2773 .unwrap_or_else(|e| e.into_inner())
2774 .is_some(),
2775 "sweep handle should be Some after start + poll_ready"
2776 );
2777 assert!(
2778 !svc.sweep_cancel
2779 .lock()
2780 .unwrap_or_else(|e| e.into_inner())
2781 .is_cancelled(),
2782 "sweep_cancel should be a fresh uncancelled token after start"
2783 );
2784 }
2785
2786 #[tokio::test]
2787 async fn sweep_shutdown_hotswap_cancels_task() {
2788 let config = AggregatorConfig::correlate_by("key")
2789 .complete_when_size(10)
2790 .bucket_ttl(Duration::from_millis(100))
2791 .build()
2792 .unwrap();
2793 let (tx, _rx) = mpsc::channel(256);
2794 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2795 let cancel = CancellationToken::new();
2796 let mut svc = AggregatorService::new(config, tx, registry, cancel);
2797
2798 let _ = svc.ready().await.unwrap();
2799
2800 assert!(
2801 svc.sweep_handle
2802 .lock()
2803 .unwrap_or_else(|e| e.into_inner())
2804 .is_some(),
2805 "sweep handle should be Some after poll_ready"
2806 );
2807
2808 svc.shutdown(StepShutdownReason::HotSwap).await.unwrap();
2809
2810 assert!(
2811 svc.sweep_handle
2812 .lock()
2813 .unwrap_or_else(|e| e.into_inner())
2814 .is_none(),
2815 "sweep handle should be None after HotSwap shutdown"
2816 );
2817 assert!(
2818 svc.sweep_cancel
2819 .lock()
2820 .unwrap_or_else(|e| e.into_inner())
2821 .is_cancelled(),
2822 "sweep_cancel token should be cancelled after HotSwap shutdown"
2823 );
2824
2825 tokio::time::sleep(Duration::from_millis(50)).await;
2826 }
2827
2828 #[tokio::test]
2836 async fn constant_key_skips_reserialization() {
2837 let mut svc = new_test_svc(config_size(3));
2838 let mut result = None;
2839 for body in ["first", "second", "third"] {
2840 result = Some(
2841 svc.ready()
2842 .await
2843 .unwrap()
2844 .call(make_exchange("orderId", "A", body))
2845 .await
2846 .unwrap(),
2847 );
2848 }
2849 let result = result.unwrap();
2850 assert_eq!(
2851 result.property(CAMEL_AGGREGATED_SIZE),
2852 Some(&serde_json::json!(3u64)),
2853 "all 3 fragments must aggregate out of a single bucket"
2854 );
2855 assert!(svc.buckets.lock().unwrap().is_empty());
2856 assert_eq!(
2857 svc.key_serializations
2858 .load(std::sync::atomic::Ordering::Relaxed),
2859 1,
2860 "constant scalar key must serialize exactly once"
2861 );
2862 }
2863
2864 #[tokio::test]
2868 async fn divergent_keys_keep_serde_semantics() {
2869 let mut svc = new_test_svc(config_size(10)); for key in ["k1", "k2", "k1"] {
2871 svc.ready()
2872 .await
2873 .unwrap()
2874 .call(make_exchange("orderId", key, "body"))
2875 .await
2876 .unwrap();
2877 }
2878 let guard = svc.buckets.lock().unwrap();
2879 assert_eq!(guard.len(), 2, "k1/k2/k1 → exactly two buckets");
2880 for key in ["k1", "k2"] {
2881 let expected = serde_json::to_string(&serde_json::json!(key)).unwrap();
2882 assert!(
2883 guard.contains_key(expected.as_str()),
2884 "bucket name must be byte-identical to serde_json::to_string: \
2885 expected {expected}, have {:?}",
2886 guard.keys().collect::<Vec<_>>()
2887 );
2888 }
2889 }
2890
2891 #[tokio::test]
2899 async fn object_keys_bypass_cache() {
2900 let mut svc = new_test_svc(config_size(10)); let obj_a = serde_json::json!({"a": 1, "b": 2});
2902 let obj_b = serde_json::json!({"b": 2, "a": 1});
2903 for obj in [&obj_a, &obj_b, &obj_a] {
2904 let mut msg = Message {
2905 headers: Default::default(),
2906 body: Body::Text("body".into()),
2907 };
2908 msg.headers.insert("orderId".to_string(), obj.clone());
2909 svc.ready()
2910 .await
2911 .unwrap()
2912 .call(Exchange::new(msg))
2913 .await
2914 .unwrap();
2915 }
2916 let expected_keys: std::collections::HashSet<String> = [&obj_a, &obj_b]
2917 .into_iter()
2918 .map(|o| serde_json::to_string(o).unwrap())
2919 .collect();
2920 let guard = svc.buckets.lock().unwrap();
2921 let actual_keys: std::collections::HashSet<String> = guard.keys().cloned().collect();
2922 assert_eq!(
2923 actual_keys, expected_keys,
2924 "object keys must bucket exactly per serde_json::to_string (cache bypassed)"
2925 );
2926 assert_eq!(
2927 svc.key_serializations
2928 .load(std::sync::atomic::Ordering::Relaxed),
2929 3,
2930 "each object fragment must serialize (memo never consulted for objects)"
2931 );
2932 }
2933
2934 #[tokio::test]
2940 async fn float_zero_sign_keys_stay_distinct() {
2941 let mut svc = new_test_svc(config_size(10)); for key in [0.0_f64, -0.0, 0.0] {
2943 let mut msg = Message {
2944 headers: Default::default(),
2945 body: Body::Text("body".into()),
2946 };
2947 msg.headers
2948 .insert("orderId".to_string(), serde_json::json!(key));
2949 svc.ready()
2950 .await
2951 .unwrap()
2952 .call(Exchange::new(msg))
2953 .await
2954 .unwrap();
2955 }
2956 let guard = svc.buckets.lock().unwrap();
2957 assert_eq!(guard.len(), 2, "±0.0 must stay in distinct buckets");
2958 for key in [0.0_f64, -0.0] {
2959 let expected = serde_json::to_string(&serde_json::json!(key)).unwrap();
2960 assert!(
2961 guard.contains_key(expected.as_str()),
2962 "bucket name must be byte-identical to serde_json::to_string: \
2963 expected {expected}, have {:?}",
2964 guard.keys().collect::<Vec<_>>()
2965 );
2966 }
2967 assert_eq!(
2968 svc.key_serializations
2969 .load(std::sync::atomic::Ordering::Relaxed),
2970 3,
2971 "number keys serialize per fragment (never memoized)"
2972 );
2973 }
2974}