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