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, StepLifecycle, StepShutdownReason,
16 aggregator::{
17 AggregationStrategy, AggregatorConfig, CompletionCondition, CompletionMode,
18 CompletionReason, CorrelationStrategy,
19 },
20 body::Body,
21 exchange::Exchange,
22 message::Message,
23};
24use camel_language_api::Language;
25
26pub type SharedLanguageRegistry = Arc<std::sync::Mutex<HashMap<String, Arc<dyn Language>>>>;
27
28pub const CAMEL_AGGREGATOR_PENDING: &str = "CamelAggregatorPending";
29pub const CAMEL_AGGREGATED_SIZE: &str = "CamelAggregatedSize";
30pub const CAMEL_AGGREGATED_KEY: &str = "CamelAggregatedKey";
31pub const CAMEL_AGGREGATED_COMPLETION_REASON: &str = "CamelAggregatedCompletionReason";
32
33struct Bucket {
35 exchanges: Vec<Exchange>,
36 last_updated: Instant,
37}
38
39impl Bucket {
40 fn new() -> Self {
41 Self {
42 exchanges: Vec::new(),
43 last_updated: Instant::now(),
44 }
45 }
46
47 fn push(&mut self, exchange: Exchange) {
48 self.exchanges.push(exchange);
49 self.last_updated = Instant::now();
50 }
51
52 fn is_expired(&self, ttl: Duration) -> bool {
53 Instant::now().duration_since(self.last_updated) >= ttl
54 }
55}
56
57#[derive(Clone)]
58pub struct AggregatorService {
59 config: AggregatorConfig,
60 buckets: Arc<Mutex<HashMap<String, Bucket>>>,
61 timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
62 timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
63 late_tx: mpsc::Sender<Exchange>,
64 language_registry: SharedLanguageRegistry,
65 route_cancel: CancellationToken,
66 sweep_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
74}
75
76impl std::fmt::Debug for AggregatorService {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.debug_struct("AggregatorService").finish_non_exhaustive()
79 }
80}
81
82impl Drop for AggregatorService {
83 fn drop(&mut self) {
84 if let Some(handle) = self
91 .sweep_handle
92 .lock()
93 .unwrap_or_else(|e| e.into_inner())
94 .take()
95 {
96 handle.abort();
97 }
98 }
99}
100
101impl AggregatorService {
102 pub fn new(
110 config: AggregatorConfig,
111 late_tx: mpsc::Sender<Exchange>,
112 language_registry: SharedLanguageRegistry,
113 route_cancel: CancellationToken,
114 ) -> Self {
115 config.validate().expect(
117 "AggregatorService::new: config failed validation \
120 (need max_buckets, completionTimeout, or bucket_ttl)",
121 );
122
123 let has_timeout = has_timeout_condition(&config.completion);
127 if !has_timeout && config.bucket_ttl.is_none() {
128 tracing::warn!(
129 "Aggregator configured with Size/Predicate completion and no \
130 bucket_ttl: buckets accumulate until max_buckets is reached"
131 );
132 }
133
134 let buckets: Arc<Mutex<HashMap<String, Bucket>>> = Arc::new(Mutex::new(HashMap::new()));
137
138 Self {
147 config,
148 buckets,
149 timeout_tasks: Arc::new(Mutex::new(HashMap::new())),
150 timeout_handles: Arc::new(Mutex::new(HashMap::new())),
151 late_tx,
152 language_registry,
153 route_cancel,
154 sweep_handle: Arc::new(Mutex::new(None)),
155 }
156 }
157
158 pub fn config(&self) -> &AggregatorConfig {
159 &self.config
160 }
161
162 pub fn has_timeout(&self) -> bool {
163 has_timeout_condition(&self.config.completion)
164 }
165
166 pub fn force_complete_all(&self) {
167 let mut buckets_guard = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
168 let keys: Vec<String> = buckets_guard.keys().cloned().collect();
169
170 for key in keys {
171 if let Some(bucket) = buckets_guard.remove(&key) {
172 if self.config.force_completion_on_stop {
173 cancel_timeout_task_with_handle(
174 &key,
175 &self.timeout_tasks,
176 &self.timeout_handles,
177 );
178 match aggregate(bucket.exchanges, &self.config.strategy) {
179 Ok(mut result) => {
180 result.set_property(
181 CAMEL_AGGREGATED_COMPLETION_REASON,
182 serde_json::json!(CompletionReason::Stop.as_str()),
183 );
184 if self.late_tx.try_send(result).is_err() {
185 tracing::warn!(
186 key = %key,
187 "aggregator force-complete emit dropped: late channel full"
188 );
189 }
190 }
191 Err(e) => {
192 tracing::warn!(
194 key = %key,
195 error = %e,
196 "aggregation failed in force_complete_all"
197 );
198 }
199 }
200 } else {
201 cancel_timeout_task_with_handle(
202 &key,
203 &self.timeout_tasks,
204 &self.timeout_handles,
205 );
206 }
207 }
208 }
209 }
210
211 pub(crate) async fn shutdown_inner(&self) {
214 {
216 let mut guard = self.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
217 for token in guard.values() {
218 token.cancel();
219 }
220 guard.clear();
221 };
222
223 let handles: Vec<JoinHandle<()>> = {
225 let mut guard = self
226 .timeout_handles
227 .lock()
228 .unwrap_or_else(|e| e.into_inner());
229 guard.drain().map(|(_, handle)| handle).collect()
230 };
231
232 if handles.is_empty() {
233 return;
234 }
235
236 let _ = tokio::time::timeout(Duration::from_secs(5), async {
238 for handle in handles {
239 let _ = handle.await;
240 }
241 })
242 .await;
243 }
244}
245
246#[async_trait]
247impl StepLifecycle for AggregatorService {
248 fn name(&self) -> &'static str {
249 "aggregator"
250 }
251
252 async fn shutdown(&self, reason: StepShutdownReason) -> Result<(), CamelError> {
253 tracing::debug!(reason = ?reason, "Aggregator shutdown via StepLifecycle");
254 self.shutdown_inner().await;
255 Ok(())
256 }
257}
258
259pub fn has_timeout_condition(mode: &CompletionMode) -> bool {
260 match mode {
261 CompletionMode::Single(CompletionCondition::Timeout(_)) => true,
262 CompletionMode::Any(conditions) => conditions
263 .iter()
264 .any(|c| matches!(c, CompletionCondition::Timeout(_))),
265 _ => false,
266 }
267}
268
269impl Service<Exchange> for AggregatorService {
270 type Response = Exchange;
271 type Error = CamelError;
272 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
273
274 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
275 if let Some(ttl) = self.config.bucket_ttl {
279 let mut g = self.sweep_handle.lock().unwrap_or_else(|e| e.into_inner());
280 if g.is_none() {
281 let interval = std::cmp::max(ttl / 2, Duration::from_millis(50));
282 let buckets = Arc::clone(&self.buckets);
283 let cancel = self.route_cancel.clone();
284 *g = Some(tokio::spawn(async move {
285 loop {
286 tokio::select! {
287 _ = cancel.cancelled() => break,
288 _ = tokio::time::sleep(interval) => {
289 let mut guard =
290 buckets.lock().unwrap_or_else(|e| e.into_inner());
291 guard.retain(|_, b| !b.is_expired(ttl));
292 }
293 }
294 }
295 }));
296 }
297 }
298 Poll::Ready(Ok(()))
299 }
300
301 fn call(&mut self, exchange: Exchange) -> Self::Future {
302 let config = self.config.clone();
303 let buckets = Arc::clone(&self.buckets);
304 let timeout_tasks = Arc::clone(&self.timeout_tasks);
305 let timeout_handles = Arc::clone(&self.timeout_handles);
306 let late_tx = self.late_tx.clone();
307 let language_registry = Arc::clone(&self.language_registry);
308 let route_cancel = self.route_cancel.clone();
309
310 Box::pin(async move {
311 let key_value =
312 extract_correlation_key(&exchange, &config.correlation, &language_registry).await?;
313
314 let key_str = serde_json::to_string(&key_value)
315 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
316
317 let predicate_satisfied =
321 evaluate_completion_predicate(&config.completion, &exchange, &language_registry)
322 .await?;
323
324 let completed_bucket = {
325 let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
326
327 if let Some(ttl) = config.bucket_ttl {
328 guard.retain(|_, bucket| !bucket.is_expired(ttl));
329 }
330
331 if let Some(max) = config.max_buckets
332 && !guard.contains_key(&key_str)
333 && guard.len() >= max
334 {
335 tracing::warn!(
336 max_buckets = max,
337 correlation_key = %key_str,
338 "Aggregator reached max buckets limit, rejecting new correlation key"
339 );
340 return Err(CamelError::ProcessorError(format!(
341 "Aggregator reached maximum {} buckets",
342 max
343 )));
344 }
345
346 let bucket = guard.entry(key_str.clone()).or_insert_with(Bucket::new);
347 bucket.push(exchange);
348
349 let (is_complete, reason) = check_sync_completion(
350 &config.completion,
351 &bucket.exchanges,
352 predicate_satisfied,
353 );
354
355 if is_complete {
356 let exchanges = guard.remove(&key_str).map(|b| b.exchanges);
357 (exchanges, reason)
358 } else {
359 (None, CompletionReason::Size) }
361 };
362
363 if completed_bucket.0.is_none() && has_timeout_condition(&config.completion) {
364 let timeout_dur = extract_timeout_duration(&config.completion);
365 if let Some(timeout) = timeout_dur {
366 let live_count = timeout_handles
372 .lock()
373 .unwrap_or_else(|e| e.into_inner())
374 .len();
375 if live_count >= config.max_timeout_tasks {
376 tracing::warn!(
377 live_timeout_tasks = live_count,
378 max_timeout_tasks = config.max_timeout_tasks,
379 correlation_key = %key_str,
380 "Aggregator timeout-task cap reached; bucket will rely on \
381 bucket_ttl eviction instead of a dedicated timeout task"
382 );
383 } else {
384 {
386 let tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
387 if let Some(existing) = tt_guard.get(&key_str) {
388 existing.cancel();
389 }
390 }
391 {
393 let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
394 if let Some(old) = hh.remove(&key_str) {
395 old.abort();
396 }
397 }
398 let cancel = CancellationToken::new();
399 timeout_tasks
400 .lock()
401 .unwrap_or_else(|e| e.into_inner())
402 .insert(key_str.clone(), cancel.clone());
403 let handle = spawn_timeout_task(
404 key_str.clone(),
405 timeout,
406 cancel,
407 buckets.clone(),
408 timeout_tasks.clone(),
409 timeout_handles.clone(),
410 late_tx,
411 config.strategy.clone(),
412 config.discard_on_timeout,
413 route_cancel,
414 );
415 timeout_handles
416 .lock()
417 .unwrap_or_else(|e| e.into_inner())
418 .insert(key_str.clone(), handle);
419 }
420 }
421 }
422
423 if let Some(exchanges) = completed_bucket.0 {
424 cancel_timeout_task_with_handle(&key_str, &timeout_tasks, &timeout_handles);
425 let reason = completed_bucket.1;
426 let size = exchanges.len();
427 let mut result = aggregate(exchanges, &config.strategy)?;
428 result.set_property(CAMEL_AGGREGATED_SIZE, serde_json::json!(size as u64));
429 result.set_property(CAMEL_AGGREGATED_KEY, key_value);
430 result.set_property(
431 CAMEL_AGGREGATED_COMPLETION_REASON,
432 serde_json::json!(reason.as_str()),
433 );
434 Ok(result)
435 } else {
436 let mut pending = Exchange::new(Message {
437 headers: Default::default(),
438 body: Body::Empty,
439 });
440 pending.set_property(CAMEL_AGGREGATOR_PENDING, serde_json::json!(true));
441 Ok(pending)
442 }
443 })
444 }
445}
446
447async fn extract_correlation_key(
448 exchange: &Exchange,
449 strategy: &CorrelationStrategy,
450 registry: &SharedLanguageRegistry,
451) -> Result<serde_json::Value, CamelError> {
452 match strategy {
453 CorrelationStrategy::HeaderName(h) => {
454 exchange.input.headers.get(h).cloned().ok_or_else(|| {
455 CamelError::ProcessorError(format!(
456 "Aggregator: missing correlation key header '{}'",
457 h
458 ))
459 })
460 }
461 CorrelationStrategy::Expression { expr, language } => {
462 let expression = {
463 let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
464 let lang = reg.get(language).ok_or_else(|| {
465 CamelError::ProcessorError(format!(
466 "Aggregator: language '{}' not found in registry",
467 language
468 ))
469 })?;
470 lang.create_expression(expr)
471 .map_err(|e| CamelError::ProcessorError(e.to_string()))?
472 };
473 let value = expression
474 .evaluate(exchange)
475 .await
476 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
477 if value.is_null() {
478 return Err(CamelError::ProcessorError(format!(
479 "Aggregator: correlation expression '{}' evaluated to null",
480 expr
481 )));
482 }
483 Ok(value)
484 }
485 CorrelationStrategy::Fn(f) => f(exchange).map(serde_json::Value::String).ok_or_else(|| {
486 CamelError::ProcessorError("Aggregator: correlation function returned None".to_string())
487 }),
488 _ => Err(CamelError::ProcessorError(
490 "Aggregator: unsupported correlation strategy".to_string(),
491 )),
492 }
493}
494
495fn iter_predicate_exprs(mode: &CompletionMode) -> Vec<(&String, &String)> {
497 let mut out = Vec::new();
498 match mode {
499 CompletionMode::Single(CompletionCondition::PredicateExpr { expr, language }) => {
500 out.push((expr, language));
501 }
502 CompletionMode::Any(conds) => {
503 for c in conds {
504 if let CompletionCondition::PredicateExpr { expr, language } = c {
505 out.push((expr, language));
506 }
507 }
508 }
509 _ => {}
510 }
511 out
512}
513
514async fn evaluate_completion_predicate(
519 mode: &CompletionMode,
520 incoming: &Exchange,
521 registry: &SharedLanguageRegistry,
522) -> Result<bool, CamelError> {
523 let mut satisfied = false;
524 for (expr, language) in iter_predicate_exprs(mode) {
525 let expression = {
526 let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
527 let lang = reg.get(language).ok_or_else(|| {
528 CamelError::ProcessorError(format!(
529 "Aggregator: language '{}' not found in registry",
530 language
531 ))
532 })?;
533 lang.create_expression(expr)
534 .map_err(|e| CamelError::ProcessorError(e.to_string()))?
535 }; let value = expression
537 .evaluate(incoming)
538 .await
539 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
540 if value.as_bool().unwrap_or(false) {
541 satisfied = true;
542 }
543 }
544 Ok(satisfied)
545}
546
547fn check_sync_completion(
548 mode: &CompletionMode,
549 exchanges: &[Exchange],
550 predicate_satisfied: bool,
551) -> (bool, CompletionReason) {
552 match mode {
553 CompletionMode::Single(cond) => check_single(cond, exchanges, predicate_satisfied),
554 CompletionMode::Any(conditions) => {
555 for cond in conditions {
556 if let CompletionCondition::Timeout(_) = cond {
557 continue;
558 }
559 let (done, reason) = check_single(cond, exchanges, predicate_satisfied);
560 if done {
561 return (true, reason);
562 }
563 }
564 (false, CompletionReason::Size)
565 }
566 _ => (false, CompletionReason::Size),
568 }
569}
570
571fn check_single(
572 cond: &CompletionCondition,
573 exchanges: &[Exchange],
574 predicate_satisfied: bool,
575) -> (bool, CompletionReason) {
576 match cond {
577 CompletionCondition::Size(n) => (exchanges.len() >= *n, CompletionReason::Size),
578 CompletionCondition::Predicate(pred) => (pred(exchanges), CompletionReason::Predicate),
579 CompletionCondition::PredicateExpr { .. } => {
580 (predicate_satisfied, CompletionReason::Predicate)
581 }
582 CompletionCondition::Timeout(_) => (false, CompletionReason::Timeout),
583 _ => (false, CompletionReason::Size),
585 }
586}
587
588fn extract_timeout_duration(mode: &CompletionMode) -> Option<Duration> {
589 match mode {
590 CompletionMode::Single(CompletionCondition::Timeout(d)) => Some(*d),
591 CompletionMode::Any(conditions) => conditions.iter().find_map(|c| {
592 if let CompletionCondition::Timeout(d) = c {
593 Some(*d)
594 } else {
595 None
596 }
597 }),
598 _ => None,
599 }
600}
601
602fn cancel_timeout_task(key: &str, timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>) {
603 let mut guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
604 if let Some(token) = guard.remove(key) {
605 token.cancel();
606 }
607}
608
609fn cancel_timeout_task_with_handle(
611 key: &str,
612 timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>,
613 timeout_handles: &Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
614) {
615 cancel_timeout_task(key, timeout_tasks);
616 let mut guard = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
617 guard.remove(key);
618}
619
620#[allow(clippy::too_many_arguments)]
621fn spawn_timeout_task(
622 key: String,
623 timeout: Duration,
624 cancel: CancellationToken,
625 buckets: Arc<Mutex<HashMap<String, Bucket>>>,
626 timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
627 timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
628 late_tx: mpsc::Sender<Exchange>,
629 strategy: AggregationStrategy,
630 discard: bool,
631 _route_cancel: CancellationToken,
632) -> JoinHandle<()> {
633 let cancel_clone = cancel.clone();
634 tokio::spawn(async move {
635 tokio::select! {
636 _ = tokio::time::sleep(timeout) => {
637 let should_proceed = {
638 let mut tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
639 if cancel_clone.is_cancelled() {
640 false
641 } else {
642 tt_guard.remove(&key);
643 true
644 }
645 };
646 if !should_proceed {
647 return;
648 }
649 {
652 let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
653 hh.remove(&key);
654 }
655 let bucket_exchanges = {
656 let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
657 guard.remove(&key).map(|b| b.exchanges)
658 };
659 if let Some(exchanges) = bucket_exchanges
660 && !discard
661 {
662 match aggregate(exchanges, &strategy) {
663 Ok(mut result) => {
664 result.set_property(
665 CAMEL_AGGREGATED_COMPLETION_REASON,
666 serde_json::json!(CompletionReason::Timeout.as_str()),
667 );
668 if late_tx.try_send(result).is_err() {
669 tracing::warn!(
670 key = %key,
671 "aggregator timeout emit dropped: late channel full"
672 );
673 }
674 }
675 Err(e) => {
676 tracing::warn!(
678 key = %key,
679 error = %e,
680 "aggregation failed in timeout task"
681 );
682 }
683 }
684 }
685 }
686 _ = cancel_clone.cancelled() => {}
687 }
688 })
689}
690
691fn aggregate(
692 exchanges: Vec<Exchange>,
693 strategy: &AggregationStrategy,
694) -> Result<Exchange, CamelError> {
695 match strategy {
696 AggregationStrategy::CollectAll => {
697 let bodies: Vec<serde_json::Value> = exchanges
698 .into_iter()
699 .map(|e| match e.input.body {
700 Body::Json(v) => v,
701 Body::Text(s) => serde_json::Value::String(s),
702 Body::Xml(s) => serde_json::Value::String(s),
703 Body::Bytes(b) => {
704 serde_json::Value::String(String::from_utf8_lossy(&b).into_owned())
705 }
706 Body::Stream(s) => serde_json::json!({
707 "_stream": {
708 "origin": s.metadata.origin,
709 "placeholder": true,
710 "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
711 }
712 }),
713 _ => serde_json::Value::Null,
715 })
716 .collect();
717 Ok(Exchange::new(Message {
718 headers: Default::default(),
719 body: Body::Json(serde_json::Value::Array(bodies)),
720 }))
721 }
722 AggregationStrategy::Custom(f) => {
723 let mut iter = exchanges.into_iter();
724 let first = iter.next().ok_or_else(|| {
725 CamelError::ProcessorError("Aggregator: empty bucket".to_string())
726 })?;
727 Ok(iter.fold(first, |acc, next| f(acc, next)))
728 }
729 _ => Err(CamelError::ProcessorError(
731 "Aggregator: unsupported aggregation strategy".to_string(),
732 )),
733 }
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739 use std::collections::HashMap;
740
741 use camel_api::{
742 StepLifecycle, StepShutdownReason,
743 aggregator::{AggregationStrategy, AggregatorConfig},
744 body::Body,
745 exchange::Exchange,
746 message::Message,
747 };
748 use tokio::sync::mpsc;
749 use tokio_util::sync::CancellationToken;
750 use tower::ServiceExt;
751
752 fn make_exchange(header: &str, value: &str, body: &str) -> Exchange {
753 let mut msg = Message {
754 headers: Default::default(),
755 body: Body::Text(body.to_string()),
756 };
757 msg.headers
758 .insert(header.to_string(), serde_json::json!(value));
759 Exchange::new(msg)
760 }
761
762 fn config_size(n: usize) -> AggregatorConfig {
763 AggregatorConfig::correlate_by("orderId")
764 .complete_when_size(n)
765 .build()
766 .unwrap()
767 }
768
769 fn new_test_svc(config: AggregatorConfig) -> AggregatorService {
770 let (tx, _rx) = mpsc::channel(256);
771 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
772 let cancel = CancellationToken::new();
773 AggregatorService::new(config, tx, registry, cancel)
774 }
775
776 fn new_test_svc_with_registry(
780 config: AggregatorConfig,
781 registry: SharedLanguageRegistry,
782 ) -> AggregatorService {
783 let (tx, _rx) = mpsc::channel(256);
784 let cancel = CancellationToken::new();
785 AggregatorService::new(config, tx, registry, cancel)
786 }
787
788 #[tokio::test]
789 async fn test_pending_exchange_not_yet_complete() {
790 let mut svc = new_test_svc(config_size(3));
791 let ex = make_exchange("orderId", "A", "first");
792 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
793 assert!(matches!(result.input.body, Body::Empty));
794 assert_eq!(
795 result.property(CAMEL_AGGREGATOR_PENDING),
796 Some(&serde_json::json!(true))
797 );
798 }
799
800 #[tokio::test]
801 async fn test_completes_on_size() {
802 let mut svc = new_test_svc(config_size(3));
803 for _ in 0..2 {
804 let ex = make_exchange("orderId", "A", "item");
805 let r = svc.ready().await.unwrap().call(ex).await.unwrap();
806 assert!(matches!(r.input.body, Body::Empty));
807 }
808 let ex = make_exchange("orderId", "A", "last");
809 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
810 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
811 assert_eq!(
812 result.property(CAMEL_AGGREGATED_SIZE),
813 Some(&serde_json::json!(3u64))
814 );
815 }
816
817 #[tokio::test]
818 async fn test_collect_all_produces_json_array() {
819 let mut svc = new_test_svc(config_size(2));
820 svc.ready()
821 .await
822 .unwrap()
823 .call(make_exchange("orderId", "A", "alpha"))
824 .await
825 .unwrap();
826 let result = svc
827 .ready()
828 .await
829 .unwrap()
830 .call(make_exchange("orderId", "A", "beta"))
831 .await
832 .unwrap();
833 let Body::Json(v) = &result.input.body else {
834 panic!("expected Body::Json")
835 };
836 let arr = v.as_array().unwrap();
837 assert_eq!(arr.len(), 2);
838 assert_eq!(arr[0], serde_json::json!("alpha"));
839 assert_eq!(arr[1], serde_json::json!("beta"));
840 }
841
842 #[tokio::test]
843 async fn test_two_keys_independent_buckets() {
844 let mut svc = new_test_svc(config_size(3));
846 svc.ready()
847 .await
848 .unwrap()
849 .call(make_exchange("orderId", "A", "a1"))
850 .await
851 .unwrap();
852 svc.ready()
853 .await
854 .unwrap()
855 .call(make_exchange("orderId", "B", "b1"))
856 .await
857 .unwrap();
858 svc.ready()
859 .await
860 .unwrap()
861 .call(make_exchange("orderId", "A", "a2"))
862 .await
863 .unwrap();
864 let ra = svc
866 .ready()
867 .await
868 .unwrap()
869 .call(make_exchange("orderId", "A", "a3"))
870 .await
871 .unwrap();
872 assert!(matches!(ra.input.body, Body::Json(_)));
874 let rb = svc
876 .ready()
877 .await
878 .unwrap()
879 .call(make_exchange("orderId", "B", "b_check"))
880 .await
881 .unwrap();
882 assert!(matches!(rb.input.body, Body::Empty));
883 }
884
885 #[tokio::test]
886 async fn test_bucket_resets_after_completion() {
887 let mut svc = new_test_svc(config_size(2));
888 svc.ready()
889 .await
890 .unwrap()
891 .call(make_exchange("orderId", "A", "x"))
892 .await
893 .unwrap();
894 svc.ready()
895 .await
896 .unwrap()
897 .call(make_exchange("orderId", "A", "x"))
898 .await
899 .unwrap(); let r = svc
902 .ready()
903 .await
904 .unwrap()
905 .call(make_exchange("orderId", "A", "new"))
906 .await
907 .unwrap();
908 assert!(matches!(r.input.body, Body::Empty)); }
910
911 #[tokio::test]
912 async fn test_completion_size_1_emits_immediately() {
913 let mut svc = new_test_svc(config_size(1));
914 let ex = make_exchange("orderId", "A", "solo");
915 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
916 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
917 }
918
919 #[tokio::test]
920 async fn test_custom_aggregation_strategy() {
921 use camel_api::aggregator::AggregationFn;
922 use std::sync::Arc;
923
924 let f: AggregationFn = Arc::new(|mut acc: Exchange, next: Exchange| {
925 let combined = format!(
926 "{}+{}",
927 acc.input.body.as_text().unwrap_or(""),
928 next.input.body.as_text().unwrap_or("")
929 );
930 acc.input.body = Body::Text(combined);
931 acc
932 });
933 let config = AggregatorConfig::correlate_by("key")
934 .complete_when_size(2)
935 .strategy(AggregationStrategy::Custom(f))
936 .build()
937 .unwrap();
938 let mut svc = new_test_svc(config);
939 svc.ready()
940 .await
941 .unwrap()
942 .call(make_exchange("key", "X", "hello"))
943 .await
944 .unwrap();
945 let result = svc
946 .ready()
947 .await
948 .unwrap()
949 .call(make_exchange("key", "X", "world"))
950 .await
951 .unwrap();
952 assert_eq!(result.input.body.as_text(), Some("hello+world"));
953 }
954
955 #[tokio::test]
956 async fn test_completion_predicate() {
957 let config = AggregatorConfig::correlate_by("key")
958 .complete_when(|bucket| {
959 bucket
960 .iter()
961 .any(|e| e.input.body.as_text() == Some("DONE"))
962 })
963 .build()
964 .unwrap();
965 let mut svc = new_test_svc(config);
966 svc.ready()
967 .await
968 .unwrap()
969 .call(make_exchange("key", "K", "first"))
970 .await
971 .unwrap();
972 svc.ready()
973 .await
974 .unwrap()
975 .call(make_exchange("key", "K", "second"))
976 .await
977 .unwrap();
978 let result = svc
979 .ready()
980 .await
981 .unwrap()
982 .call(make_exchange("key", "K", "DONE"))
983 .await
984 .unwrap();
985 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
986 }
987
988 #[tokio::test]
989 async fn test_missing_header_returns_error() {
990 let mut svc = new_test_svc(config_size(2));
991 let msg = Message {
992 headers: Default::default(),
993 body: Body::Text("no key".into()),
994 };
995 let ex = Exchange::new(msg);
996 let result = svc.ready().await.unwrap().call(ex).await;
997 assert!(result.is_err());
998 assert!(matches!(
999 result.unwrap_err(),
1000 camel_api::CamelError::ProcessorError(_)
1001 ));
1002 }
1003
1004 #[tokio::test]
1005 async fn test_cloned_service_shares_state() {
1006 let svc1 = new_test_svc(config_size(2));
1007 let mut svc2 = svc1.clone();
1008 svc1.clone()
1010 .ready()
1011 .await
1012 .unwrap()
1013 .call(make_exchange("orderId", "A", "from-svc1"))
1014 .await
1015 .unwrap();
1016 let result = svc2
1018 .ready()
1019 .await
1020 .unwrap()
1021 .call(make_exchange("orderId", "A", "from-svc2"))
1022 .await
1023 .unwrap();
1024 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1025 }
1026
1027 #[tokio::test]
1028 async fn test_camel_aggregated_key_property_set() {
1029 let mut svc = new_test_svc(config_size(1));
1030 let ex = make_exchange("orderId", "ORDER-42", "body");
1031 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1032 assert_eq!(
1033 result.property(CAMEL_AGGREGATED_KEY),
1034 Some(&serde_json::json!("ORDER-42"))
1035 );
1036 }
1037
1038 #[tokio::test]
1039 async fn test_aggregator_enforces_max_buckets() {
1040 let config = AggregatorConfig::correlate_by("orderId")
1041 .complete_when_size(2)
1042 .max_buckets(3)
1043 .build()
1044 .unwrap();
1045
1046 let mut svc = new_test_svc(config);
1047
1048 for i in 0..3 {
1050 let ex = make_exchange("orderId", &format!("key-{}", i), "body");
1051 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1052 }
1053
1054 let ex = make_exchange("orderId", "key-4", "body");
1056 let result = svc.ready().await.unwrap().call(ex).await;
1057
1058 assert!(result.is_err(), "Should reject when max buckets reached");
1059 let err = result.unwrap_err().to_string();
1060 assert!(
1061 err.contains("maximum"),
1062 "Error message should contain 'maximum': {}",
1063 err
1064 );
1065 }
1066
1067 #[tokio::test]
1068 async fn test_max_buckets_allows_existing_key() {
1069 let config = AggregatorConfig::correlate_by("orderId")
1070 .complete_when_size(5) .max_buckets(2)
1072 .build()
1073 .unwrap();
1074
1075 let mut svc = new_test_svc(config);
1076
1077 let ex1 = make_exchange("orderId", "key-A", "body1");
1079 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1080 let ex2 = make_exchange("orderId", "key-B", "body2");
1081 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1082
1083 let ex3 = make_exchange("orderId", "key-A", "body3");
1085 let result = svc.ready().await.unwrap().call(ex3).await;
1086 assert!(
1087 result.is_ok(),
1088 "Should allow adding to existing bucket even at max limit"
1089 );
1090 }
1091
1092 #[tokio::test]
1093 async fn test_bucket_ttl_eviction() {
1094 let config = AggregatorConfig::correlate_by("orderId")
1095 .complete_when_size(10) .bucket_ttl(Duration::from_millis(50))
1097 .build()
1098 .unwrap();
1099
1100 let mut svc = new_test_svc(config);
1101
1102 let ex1 = make_exchange("orderId", "key-A", "body1");
1104 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1105
1106 tokio::time::sleep(Duration::from_millis(100)).await;
1108
1109 let ex2 = make_exchange("orderId", "key-B", "body2");
1111 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1112
1113 let ex3 = make_exchange("orderId", "key-A", "body3");
1116 let result = svc.ready().await.unwrap().call(ex3).await;
1117 assert!(result.is_ok(), "Should be able to recreate evicted bucket");
1118 }
1119
1120 #[tokio::test(start_paused = true)]
1121 async fn test_timeout_completes_bucket() {
1122 let config = AggregatorConfig::correlate_by("key")
1123 .complete_on_timeout(Duration::from_millis(100))
1124 .build()
1125 .unwrap();
1126 let mut svc = new_test_svc(config);
1127 let ex = make_exchange("key", "A", "data");
1128 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1129 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_some());
1130
1131 tokio::time::sleep(Duration::from_millis(200)).await;
1132
1133 assert_eq!(
1134 svc.buckets.lock().unwrap().len(),
1135 0,
1136 "bucket should be removed after timeout"
1137 );
1138 }
1139
1140 #[tokio::test(start_paused = true)]
1141 async fn test_timeout_resets_on_new_exchange() {
1142 let config = AggregatorConfig::correlate_by("key")
1143 .complete_on_timeout(Duration::from_millis(150))
1144 .build()
1145 .unwrap();
1146 let mut svc = new_test_svc(config);
1147
1148 let ex1 = make_exchange("key", "A", "first");
1149 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1150
1151 tokio::time::sleep(Duration::from_millis(100)).await;
1152
1153 let ex2 = make_exchange("key", "A", "second");
1154 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1155
1156 tokio::time::sleep(Duration::from_millis(100)).await;
1157
1158 assert_eq!(
1159 svc.buckets.lock().unwrap().len(),
1160 1,
1161 "bucket should still exist — timeout was reset"
1162 );
1163
1164 tokio::time::sleep(Duration::from_millis(100)).await;
1165
1166 assert_eq!(
1167 svc.buckets.lock().unwrap().len(),
1168 0,
1169 "bucket should be gone after timeout fires"
1170 );
1171 }
1172
1173 #[tokio::test]
1174 async fn test_composable_size_and_timeout() {
1175 let config = AggregatorConfig::correlate_by("key")
1176 .complete_on_size_or_timeout(2, Duration::from_millis(200))
1177 .build()
1178 .unwrap();
1179 let mut svc = new_test_svc(config);
1180
1181 let ex1 = make_exchange("key", "A", "first");
1182 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1183 assert!(svc.buckets.lock().unwrap().contains_key("\"A\""));
1184
1185 let ex2 = make_exchange("key", "A", "second");
1186 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1187 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1188 assert_eq!(
1189 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1190 Some(&serde_json::json!("size"))
1191 );
1192 }
1193
1194 #[tokio::test(start_paused = true)]
1195 async fn test_discard_on_timeout() {
1196 let config = AggregatorConfig::correlate_by("key")
1197 .complete_on_timeout(Duration::from_millis(50))
1198 .discard_on_timeout(true)
1199 .build()
1200 .unwrap();
1201 let (tx, mut rx) = mpsc::channel(256);
1202 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1203 let cancel = CancellationToken::new();
1204 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1205
1206 let ex = make_exchange("key", "A", "data");
1207 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1208
1209 tokio::time::sleep(Duration::from_millis(100)).await;
1210
1211 assert!(
1212 rx.try_recv().is_err(),
1213 "no emit expected with discard_on_timeout"
1214 );
1215 assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1216 assert!(
1217 svc.timeout_tasks.lock().unwrap().is_empty(),
1218 "timeout task should be cleaned up"
1219 );
1220 }
1221
1222 #[tokio::test]
1223 async fn test_force_completion_on_stop() {
1224 let config = AggregatorConfig::correlate_by("key")
1225 .complete_when_size(10)
1226 .force_completion_on_stop(true)
1227 .build()
1228 .unwrap();
1229 let (tx, mut rx) = mpsc::channel(256);
1230 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1231 let cancel = CancellationToken::new();
1232 let svc = AggregatorService::new(config, tx, registry, cancel);
1233
1234 let mut call_svc = svc.clone();
1235 let ex = make_exchange("key", "A", "data");
1236 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1237
1238 svc.force_complete_all();
1239
1240 let result = rx.try_recv().expect("should emit on force-complete");
1241 assert!(
1242 result.input.body.as_text().is_some() || matches!(result.input.body, Body::Json(_))
1243 );
1244 assert_eq!(
1245 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1246 Some(&serde_json::json!("stop"))
1247 );
1248 }
1249
1250 #[tokio::test]
1251 async fn test_completion_reason_property_size() {
1252 let config = AggregatorConfig::correlate_by("key")
1253 .complete_when_size(1)
1254 .build()
1255 .unwrap();
1256 let mut svc = new_test_svc(config);
1257 let ex = make_exchange("key", "X", "body");
1258 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1259 assert_eq!(
1260 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1261 Some(&serde_json::json!("size"))
1262 );
1263 }
1264
1265 #[tokio::test]
1266 async fn test_completion_reason_property_predicate() {
1267 let config = AggregatorConfig::correlate_by("key")
1268 .complete_when(|_| true)
1269 .build()
1270 .unwrap();
1271 let mut svc = new_test_svc(config);
1272 let ex = make_exchange("key", "X", "body");
1273 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1274 assert_eq!(
1275 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1276 Some(&serde_json::json!("predicate"))
1277 );
1278 }
1279
1280 #[tokio::test(start_paused = true)]
1281 async fn test_size_completes_before_timeout() {
1282 let config = AggregatorConfig::correlate_by("key")
1283 .complete_on_size_or_timeout(2, Duration::from_millis(200))
1284 .build()
1285 .unwrap();
1286 let mut svc = new_test_svc(config);
1287
1288 let ex1 = make_exchange("key", "A", "first");
1289 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1290
1291 let ex2 = make_exchange("key", "A", "second");
1292 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1293
1294 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1295 assert_eq!(
1296 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1297 Some(&serde_json::json!("size"))
1298 );
1299 assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1300
1301 tokio::time::sleep(Duration::from_millis(300)).await;
1302 assert_eq!(
1303 svc.buckets.lock().unwrap().len(),
1304 0,
1305 "no re-fire after timeout"
1306 );
1307 }
1308
1309 #[tokio::test(start_paused = true)]
1310 async fn test_concurrent_timeout_fire_and_new_exchange() {
1311 let config = AggregatorConfig::correlate_by("key")
1312 .complete_on_size_or_timeout(2, Duration::from_millis(100))
1313 .build()
1314 .unwrap();
1315 let (tx, mut rx) = mpsc::channel(256);
1316 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1317 let cancel = CancellationToken::new();
1318 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1319
1320 let ex = make_exchange("key", "A", "data");
1321 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1322
1323 tokio::time::sleep(Duration::from_millis(150)).await;
1325
1326 let ex2 = make_exchange("key", "A", "data2");
1328 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1329 assert!(
1330 result.property(CAMEL_AGGREGATOR_PENDING).is_some(),
1331 "should be pending in new bucket"
1332 );
1333
1334 let mut late_count = 0;
1336 while rx.try_recv().is_ok() {
1337 late_count += 1;
1338 }
1339 assert_eq!(
1340 late_count, 1,
1341 "exactly 1 late emit from the timed-out bucket"
1342 );
1343 }
1344
1345 #[tokio::test(start_paused = true)]
1346 async fn test_late_channel_full_drops_with_warning() {
1347 let config = AggregatorConfig::correlate_by("key")
1348 .complete_on_timeout(Duration::from_millis(50))
1349 .build()
1350 .unwrap();
1351 let (tx, mut rx) = mpsc::channel(1);
1352 rx.close();
1353 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1354 let cancel = CancellationToken::new();
1355 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1356
1357 let ex = make_exchange("key", "A", "data");
1358 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1359
1360 tokio::time::sleep(Duration::from_millis(100)).await;
1361 assert_eq!(
1362 svc.buckets.lock().unwrap().len(),
1363 0,
1364 "bucket removed despite channel closed"
1365 );
1366 }
1367
1368 #[tokio::test]
1374 async fn test_da3_force_complete_all_drops_on_saturated_channel() {
1375 let config = AggregatorConfig::correlate_by("k")
1376 .complete_when_size(10)
1377 .force_completion_on_stop(true)
1378 .build()
1379 .unwrap();
1380 let (late_tx, mut late_rx) = mpsc::channel::<Exchange>(1);
1382 late_tx
1386 .try_send(make_exchange("k", "99", "dummy"))
1387 .expect("pre-fill succeeds");
1388
1389 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1390 let cancel = CancellationToken::new();
1391 let mut svc = AggregatorService::new(config, late_tx, registry, cancel);
1392
1393 for v in ["1", "2", "3"] {
1396 let ex = make_exchange("k", v, "body");
1397 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1398 }
1399 assert_eq!(svc.buckets.lock().unwrap().len(), 3);
1400
1401 svc.force_complete_all();
1403
1404 let pre_fill = late_rx
1406 .try_recv()
1407 .expect("pre-fill should still be in channel");
1408 assert_eq!(
1409 pre_fill.input.headers.get("k"),
1410 Some(&serde_json::json!("99"))
1411 );
1412
1413 assert!(matches!(
1416 late_rx.try_recv(),
1417 Err(mpsc::error::TryRecvError::Empty)
1418 ));
1419
1420 assert!(svc.buckets.lock().unwrap().is_empty());
1422 }
1423
1424 #[tokio::test]
1425 async fn test_aggregate_stream_bodies_creates_valid_json() {
1426 use bytes::Bytes;
1427 use camel_api::{Body, StreamBody, StreamMetadata};
1428 use futures::stream;
1429 use tokio::sync::Mutex;
1430
1431 let chunks = vec![Ok(Bytes::from("test"))];
1432 let stream_body = StreamBody {
1433 stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1434 metadata: StreamMetadata {
1435 origin: Some("file:///test.txt".to_string()),
1436 ..Default::default()
1437 },
1438 };
1439
1440 let ex1 = Exchange::new(Message {
1441 headers: Default::default(),
1442 body: Body::Stream(stream_body),
1443 });
1444
1445 let exchanges = vec![ex1];
1446 let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1447
1448 let exchange = result.expect("Expected Ok result");
1449 assert!(
1450 matches!(exchange.input.body, Body::Json(_)),
1451 "Expected Json body"
1452 );
1453
1454 if let Body::Json(value) = exchange.input.body {
1455 let json_str = serde_json::to_string(&value).unwrap();
1456 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1457
1458 assert!(parsed.is_array(), "Result should be an array");
1459 let arr = parsed.as_array().unwrap();
1460 assert!(arr[0].is_object(), "First element should be an object");
1461 assert!(
1462 arr[0]["_stream"].is_object(),
1463 "Should contain _stream object"
1464 );
1465 assert_eq!(arr[0]["_stream"]["origin"], "file:///test.txt");
1466 assert_eq!(
1467 arr[0]["_stream"]["placeholder"], true,
1468 "placeholder flag should be true"
1469 );
1470 }
1471 }
1472
1473 #[tokio::test]
1474 async fn test_aggregate_stream_bodies_with_none_origin() {
1475 use bytes::Bytes;
1476 use camel_api::{Body, StreamBody, StreamMetadata};
1477 use futures::stream;
1478 use tokio::sync::Mutex;
1479
1480 let chunks = vec![Ok(Bytes::from("test"))];
1481 let stream_body = StreamBody {
1482 stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1483 metadata: StreamMetadata {
1484 origin: None,
1485 ..Default::default()
1486 },
1487 };
1488
1489 let ex1 = Exchange::new(Message {
1490 headers: Default::default(),
1491 body: Body::Stream(stream_body),
1492 });
1493
1494 let exchanges = vec![ex1];
1495 let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1496
1497 let exchange = result.expect("Expected Ok result");
1498 assert!(
1499 matches!(exchange.input.body, Body::Json(_)),
1500 "Expected Json body"
1501 );
1502
1503 if let Body::Json(value) = exchange.input.body {
1504 let json_str = serde_json::to_string(&value).unwrap();
1505 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1506
1507 assert!(parsed.is_array(), "Result should be an array");
1508 let arr = parsed.as_array().unwrap();
1509 assert!(arr[0].is_object(), "First element should be an object");
1510 assert!(
1511 arr[0]["_stream"].is_object(),
1512 "Should contain _stream object"
1513 );
1514 assert_eq!(
1515 arr[0]["_stream"]["origin"],
1516 serde_json::Value::Null,
1517 "origin should be null when None"
1518 );
1519 assert_eq!(
1520 arr[0]["_stream"]["placeholder"], true,
1521 "placeholder flag should be true"
1522 );
1523 }
1524 }
1525
1526 #[tokio::test]
1527 async fn timeout_completion_clears_handle_from_map() {
1528 let config = AggregatorConfig::correlate_by("key")
1533 .complete_on_timeout(Duration::from_millis(50))
1534 .build()
1535 .unwrap();
1536 let (tx, _rx) = mpsc::channel(256);
1537 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1538 let cancel = CancellationToken::new();
1539 let svc = AggregatorService::new(config, tx, registry, cancel);
1540
1541 let mut call_svc = svc.clone();
1543 let ex = make_exchange("key", "A", "data");
1544 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1545 assert!(
1546 !svc.timeout_handles.lock().unwrap().is_empty(),
1547 "handle should exist while timeout pending"
1548 );
1549
1550 tokio::time::sleep(Duration::from_millis(200)).await;
1552
1553 assert!(
1554 svc.timeout_handles.lock().unwrap().is_empty(),
1555 "handle should be cleared from map after natural timeout completion (was leak)"
1556 );
1557 }
1558
1559 #[tokio::test]
1560 async fn aggregator_shutdown_via_trait_dispatch() {
1561 let config = AggregatorConfig::correlate_by("key")
1564 .complete_when_size(10)
1565 .build()
1566 .unwrap();
1567 let (tx, _rx) = mpsc::channel(256);
1568 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1569 let cancel = CancellationToken::new();
1570 let svc = AggregatorService::new(config, tx, registry, cancel);
1571
1572 let step: Arc<dyn StepLifecycle> = Arc::new(svc);
1573 step.shutdown(StepShutdownReason::RouteStop)
1574 .await
1575 .expect("first shutdown should succeed");
1576 step.shutdown(StepShutdownReason::RouteStop)
1577 .await
1578 .expect("second shutdown (idempotent) should succeed");
1579 }
1580
1581 #[tokio::test(start_paused = true)]
1582 async fn test_shutdown_awaits_timeout_handles() {
1583 let config = AggregatorConfig::correlate_by("key")
1584 .complete_on_timeout(Duration::from_millis(100))
1585 .build()
1586 .unwrap();
1587 let (tx, _rx) = mpsc::channel(256);
1588 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1589 let cancel = CancellationToken::new();
1590 let svc = AggregatorService::new(config, tx, registry, cancel);
1591
1592 let mut call_svc = svc.clone();
1594 let ex = make_exchange("key", "A", "data");
1595 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1596
1597 assert!(
1599 !svc.timeout_handles.lock().unwrap().is_empty(),
1600 "should have a timeout handle"
1601 );
1602
1603 svc.shutdown_inner().await;
1606
1607 assert!(
1608 svc.timeout_handles.lock().unwrap().is_empty(),
1609 "all handles should be cleaned up after shutdown"
1610 );
1611 }
1612
1613 #[tokio::test]
1620 async fn test_unique_key_flood_stays_bounded_by_default() {
1621 let config = AggregatorConfig::correlate_by("orderId")
1623 .complete_when_size(1_000_000) .build()
1625 .unwrap();
1626 let mut svc = new_test_svc(config);
1627
1628 for i in 0..10_000usize {
1631 let ex = make_exchange("orderId", &format!("key-{i}"), "body");
1632 let result = svc.ready().await.unwrap().call(ex).await;
1633 assert!(result.is_ok(), "key {i} should be accepted under the cap");
1634 }
1635 let ex = make_exchange("orderId", "key-10001", "body");
1636 let result = svc.ready().await.unwrap().call(ex).await;
1637 assert!(
1638 result.is_err(),
1639 "10_001st unique key must be rejected by the max_buckets cap"
1640 );
1641 let err = result.unwrap_err().to_string();
1642 assert!(
1643 err.contains("maximum") || err.contains("max"),
1644 "error should mention cap: {err}"
1645 );
1646 }
1647
1648 #[tokio::test]
1654 async fn test_background_sweep_spawns_on_first_poll_not_construction() {
1655 let config = AggregatorConfig::correlate_by("key")
1656 .complete_when_size(10_000)
1657 .bucket_ttl(Duration::from_millis(50))
1658 .build()
1659 .unwrap();
1660 let cancel = CancellationToken::new();
1661 let (tx, _rx) = mpsc::channel(8);
1662 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1663 let mut svc = AggregatorService::new(config, tx, registry, cancel.clone());
1664
1665 assert!(
1667 svc.sweep_handle
1668 .lock()
1669 .unwrap_or_else(|e| e.into_inner())
1670 .is_none(),
1671 "sweep must NOT be spawned at construction (runtime-free new)"
1672 );
1673
1674 let _ = svc.ready().await.unwrap();
1677 let sweep_present = svc
1678 .sweep_handle
1679 .lock()
1680 .unwrap_or_else(|e| e.into_inner())
1681 .is_some();
1682 assert!(
1683 sweep_present,
1684 "sweep handle should be Some after first poll when bucket_ttl is set"
1685 );
1686
1687 cancel.cancel();
1689 tokio::time::sleep(Duration::from_millis(50)).await;
1691 }
1692
1693 #[tokio::test]
1696 async fn test_aggregator_timeout_task_cap_no_panic_under_flood() {
1697 use camel_api::aggregator::CorrelationStrategy;
1700
1701 let config = AggregatorConfig {
1702 header_name: "k".into(),
1703 completion: CompletionMode::Any(vec![
1704 CompletionCondition::Size(999),
1705 CompletionCondition::Timeout(Duration::from_secs(30)),
1706 ]),
1707 correlation: CorrelationStrategy::HeaderName("k".into()),
1708 strategy: AggregationStrategy::CollectAll,
1709 max_buckets: Some(50),
1710 bucket_ttl: Some(Duration::from_secs(30)),
1711 force_completion_on_stop: false,
1712 discard_on_timeout: false,
1713 max_timeout_tasks: 2,
1714 };
1715 let (late_tx, mut late_rx) = mpsc::channel(64);
1716 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1717 let cancel = CancellationToken::new();
1718 let svc = AggregatorService::new(config, late_tx, registry, cancel);
1719
1720 for i in 0..20u64 {
1722 let mut ex = Exchange::new(Message {
1723 headers: HashMap::from([("k".to_string(), serde_json::json!(i))]),
1724 body: Body::Text(i.to_string()),
1725 });
1726 ex.input
1727 .headers
1728 .insert("k".to_string(), serde_json::json!(i));
1729 let outcome = tokio::time::timeout(Duration::from_secs(2), async {
1730 let mut s = svc.clone();
1731 use tower::ServiceExt;
1732 s.ready().await.unwrap().call(ex).await
1733 })
1734 .await;
1735 assert!(outcome.is_ok(), "call {} hung/panicked under task cap", i);
1736 let res = outcome.unwrap().unwrap();
1738 assert_eq!(
1739 res.properties
1740 .get(CAMEL_AGGREGATOR_PENDING)
1741 .and_then(|v| v.as_bool()),
1742 Some(true),
1743 "exchange {} should be pending",
1744 i
1745 );
1746 }
1747 let _ = late_rx.try_recv();
1749 }
1750
1751 #[tokio::test]
1752 async fn evaluate_completion_predicate_or_combines_any() {
1753 use camel_api::aggregator::{CompletionCondition, CompletionMode};
1754 use camel_language_api::Language;
1755 use std::collections::HashMap;
1756
1757 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1759 registry.lock().unwrap().insert(
1760 "simple".to_string(),
1761 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
1762 );
1763
1764 let incoming = make_exchange("k", "X", "hello");
1765
1766 let mode = CompletionMode::Any(vec![
1768 CompletionCondition::PredicateExpr {
1769 expr: "${body} == 'NOPE'".to_string(),
1770 language: "simple".to_string(),
1771 },
1772 CompletionCondition::PredicateExpr {
1773 expr: "${body} == 'hello'".to_string(),
1774 language: "simple".to_string(),
1775 },
1776 ]);
1777
1778 let satisfied = evaluate_completion_predicate(&mode, &incoming, ®istry)
1779 .await
1780 .expect("eval must succeed");
1781 assert!(satisfied, "second predicate matches → OR true");
1782 }
1783
1784 #[tokio::test]
1785 async fn evaluate_completion_predicate_no_predicate_skips_registry() {
1786 use camel_api::aggregator::{CompletionCondition, CompletionMode};
1787 use std::collections::HashMap;
1788
1789 let mode = CompletionMode::Single(CompletionCondition::Size(3));
1793 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1794 let incoming = make_exchange("k", "X", "hello");
1795 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
1796 assert!(result.is_ok(), "fast path must not error: {:?}", result);
1797 assert!(!result.unwrap(), "no predicate → not satisfied");
1798 }
1799
1800 #[tokio::test]
1801 async fn evaluate_completion_predicate_unregistered_language_errors() {
1802 use camel_api::aggregator::{CompletionCondition, CompletionMode};
1803
1804 let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
1805 expr: "${body} == 'DONE'".to_string(),
1806 language: "nonexistent-lang".to_string(),
1807 });
1808 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1809 let incoming = make_exchange("k", "X", "hello");
1810 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
1811 assert!(result.is_err(), "unregistered language must error");
1812 }
1813
1814 #[tokio::test]
1815 async fn evaluate_completion_predicate_all_miss_returns_false() {
1816 use camel_api::aggregator::{CompletionCondition, CompletionMode};
1817 use camel_language_api::Language;
1818 use std::collections::HashMap;
1819
1820 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1821 registry.lock().unwrap().insert(
1822 "simple".to_string(),
1823 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
1824 );
1825
1826 let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
1827 expr: "${body} == 'NOPE'".to_string(),
1828 language: "simple".to_string(),
1829 });
1830 let incoming = make_exchange("k", "X", "hello");
1831 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
1832 assert!(result.is_ok(), "eval must succeed: {:?}", result);
1833 assert!(!result.unwrap(), "predicate does not match");
1834 }
1835
1836 #[tokio::test]
1837 async fn completion_predicate_expr_completes_on_match() {
1838 use camel_api::aggregator::{CompletionCondition, CompletionMode};
1839 use camel_language_api::Language;
1840 use std::collections::HashMap;
1841
1842 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1844 registry.lock().unwrap().insert(
1845 "simple".to_string(),
1846 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
1847 );
1848
1849 let mut config = AggregatorConfig::correlate_by("key")
1854 .complete_when_size(999) .build()
1856 .expect("config builds");
1857 config.completion = CompletionMode::Single(CompletionCondition::PredicateExpr {
1858 expr: "${body} == 'DONE'".to_string(),
1859 language: "simple".to_string(),
1860 });
1861
1862 let mut svc = new_test_svc_with_registry(config, registry);
1863
1864 let r = svc
1866 .ready()
1867 .await
1868 .unwrap()
1869 .call(make_exchange("key", "K", "first"))
1870 .await
1871 .unwrap();
1872 assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_some());
1873
1874 let r = svc
1876 .ready()
1877 .await
1878 .unwrap()
1879 .call(make_exchange("key", "K", "DONE"))
1880 .await
1881 .unwrap();
1882 assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_none());
1883 assert_eq!(
1884 r.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1885 Some(&serde_json::json!("predicate"))
1886 );
1887 }
1888
1889 #[tokio::test]
1901 async fn test_da1_strategy_receives_two_exchanges_first_message_preserved() {
1902 use camel_api::aggregator::{AggregationFn, AggregationStrategy};
1903 use std::sync::Arc;
1904
1905 let recorded: Arc<std::sync::Mutex<Vec<(String, String)>>> =
1906 Arc::new(std::sync::Mutex::new(Vec::new()));
1907 let recorded_for_closure = Arc::clone(&recorded);
1908
1909 let f: AggregationFn = Arc::new(move |old: Exchange, new: Exchange| {
1910 let old_body = old.input.body.as_text().unwrap_or("").to_string();
1911 let new_body = new.input.body.as_text().unwrap_or("").to_string();
1912 recorded_for_closure
1913 .lock()
1914 .expect("recorded mutex poisoned")
1915 .push((old_body, new_body));
1916 new
1917 });
1918
1919 let config = AggregatorConfig::correlate_by("k")
1920 .complete_when_size(2)
1921 .strategy(AggregationStrategy::Custom(f))
1922 .build()
1923 .unwrap();
1924 let mut svc = new_test_svc(config);
1925
1926 let first = svc
1928 .ready()
1929 .await
1930 .unwrap()
1931 .call(make_exchange("k", "1", "A"))
1932 .await
1933 .unwrap();
1934 assert!(
1935 first.property(CAMEL_AGGREGATOR_PENDING).is_some(),
1936 "first message must leave the bucket pending (size < 2)"
1937 );
1938 assert!(
1939 recorded.lock().expect("recorded mutex poisoned").is_empty(),
1940 "strategy must NOT be invoked on the first message of a bucket"
1941 );
1942
1943 let _completed = svc
1946 .ready()
1947 .await
1948 .unwrap()
1949 .call(make_exchange("k", "1", "B"))
1950 .await
1951 .unwrap();
1952
1953 let recorded = recorded.lock().expect("recorded mutex poisoned");
1954 assert_eq!(
1955 recorded.len(),
1956 1,
1957 "strategy must be invoked exactly once across the two-message bucket, got {recorded:?}"
1958 );
1959 assert_eq!(
1960 recorded[0],
1961 ("A".to_string(), "B".to_string()),
1962 "strategy must observe the first message as `old` and the second as `new`, \
1963 with both bodies preserved unchanged"
1964 );
1965 }
1966}