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 }
489}
490
491fn iter_predicate_exprs(mode: &CompletionMode) -> Vec<(&String, &String)> {
493 let mut out = Vec::new();
494 match mode {
495 CompletionMode::Single(CompletionCondition::PredicateExpr { expr, language }) => {
496 out.push((expr, language));
497 }
498 CompletionMode::Any(conds) => {
499 for c in conds {
500 if let CompletionCondition::PredicateExpr { expr, language } = c {
501 out.push((expr, language));
502 }
503 }
504 }
505 _ => {}
506 }
507 out
508}
509
510async fn evaluate_completion_predicate(
515 mode: &CompletionMode,
516 incoming: &Exchange,
517 registry: &SharedLanguageRegistry,
518) -> Result<bool, CamelError> {
519 let mut satisfied = false;
520 for (expr, language) in iter_predicate_exprs(mode) {
521 let expression = {
522 let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
523 let lang = reg.get(language).ok_or_else(|| {
524 CamelError::ProcessorError(format!(
525 "Aggregator: language '{}' not found in registry",
526 language
527 ))
528 })?;
529 lang.create_expression(expr)
530 .map_err(|e| CamelError::ProcessorError(e.to_string()))?
531 }; let value = expression
533 .evaluate(incoming)
534 .await
535 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
536 if value.as_bool().unwrap_or(false) {
537 satisfied = true;
538 }
539 }
540 Ok(satisfied)
541}
542
543fn check_sync_completion(
544 mode: &CompletionMode,
545 exchanges: &[Exchange],
546 predicate_satisfied: bool,
547) -> (bool, CompletionReason) {
548 match mode {
549 CompletionMode::Single(cond) => check_single(cond, exchanges, predicate_satisfied),
550 CompletionMode::Any(conditions) => {
551 for cond in conditions {
552 if let CompletionCondition::Timeout(_) = cond {
553 continue;
554 }
555 let (done, reason) = check_single(cond, exchanges, predicate_satisfied);
556 if done {
557 return (true, reason);
558 }
559 }
560 (false, CompletionReason::Size)
561 }
562 }
563}
564
565fn check_single(
566 cond: &CompletionCondition,
567 exchanges: &[Exchange],
568 predicate_satisfied: bool,
569) -> (bool, CompletionReason) {
570 match cond {
571 CompletionCondition::Size(n) => (exchanges.len() >= *n, CompletionReason::Size),
572 CompletionCondition::Predicate(pred) => (pred(exchanges), CompletionReason::Predicate),
573 CompletionCondition::PredicateExpr { .. } => {
574 (predicate_satisfied, CompletionReason::Predicate)
575 }
576 CompletionCondition::Timeout(_) => (false, CompletionReason::Timeout),
577 }
578}
579
580fn extract_timeout_duration(mode: &CompletionMode) -> Option<Duration> {
581 match mode {
582 CompletionMode::Single(CompletionCondition::Timeout(d)) => Some(*d),
583 CompletionMode::Any(conditions) => conditions.iter().find_map(|c| {
584 if let CompletionCondition::Timeout(d) = c {
585 Some(*d)
586 } else {
587 None
588 }
589 }),
590 _ => None,
591 }
592}
593
594fn cancel_timeout_task(key: &str, timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>) {
595 let mut guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
596 if let Some(token) = guard.remove(key) {
597 token.cancel();
598 }
599}
600
601fn cancel_timeout_task_with_handle(
603 key: &str,
604 timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>,
605 timeout_handles: &Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
606) {
607 cancel_timeout_task(key, timeout_tasks);
608 let mut guard = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
609 guard.remove(key);
610}
611
612#[allow(clippy::too_many_arguments)]
613fn spawn_timeout_task(
614 key: String,
615 timeout: Duration,
616 cancel: CancellationToken,
617 buckets: Arc<Mutex<HashMap<String, Bucket>>>,
618 timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
619 timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
620 late_tx: mpsc::Sender<Exchange>,
621 strategy: AggregationStrategy,
622 discard: bool,
623 _route_cancel: CancellationToken,
624) -> JoinHandle<()> {
625 let cancel_clone = cancel.clone();
626 tokio::spawn(async move {
627 tokio::select! {
628 _ = tokio::time::sleep(timeout) => {
629 let should_proceed = {
630 let mut tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
631 if cancel_clone.is_cancelled() {
632 false
633 } else {
634 tt_guard.remove(&key);
635 true
636 }
637 };
638 if !should_proceed {
639 return;
640 }
641 {
644 let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
645 hh.remove(&key);
646 }
647 let bucket_exchanges = {
648 let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
649 guard.remove(&key).map(|b| b.exchanges)
650 };
651 if let Some(exchanges) = bucket_exchanges
652 && !discard
653 {
654 match aggregate(exchanges, &strategy) {
655 Ok(mut result) => {
656 result.set_property(
657 CAMEL_AGGREGATED_COMPLETION_REASON,
658 serde_json::json!(CompletionReason::Timeout.as_str()),
659 );
660 if late_tx.try_send(result).is_err() {
661 tracing::warn!(
662 key = %key,
663 "aggregator timeout emit dropped: late channel full"
664 );
665 }
666 }
667 Err(e) => {
668 tracing::warn!(
670 key = %key,
671 error = %e,
672 "aggregation failed in timeout task"
673 );
674 }
675 }
676 }
677 }
678 _ = cancel_clone.cancelled() => {}
679 }
680 })
681}
682
683fn aggregate(
684 exchanges: Vec<Exchange>,
685 strategy: &AggregationStrategy,
686) -> Result<Exchange, CamelError> {
687 match strategy {
688 AggregationStrategy::CollectAll => {
689 let bodies: Vec<serde_json::Value> = exchanges
690 .into_iter()
691 .map(|e| match e.input.body {
692 Body::Json(v) => v,
693 Body::Text(s) => serde_json::Value::String(s),
694 Body::Xml(s) => serde_json::Value::String(s),
695 Body::Bytes(b) => {
696 serde_json::Value::String(String::from_utf8_lossy(&b).into_owned())
697 }
698 Body::Empty => serde_json::Value::Null,
699 Body::Stream(s) => serde_json::json!({
700 "_stream": {
701 "origin": s.metadata.origin,
702 "placeholder": true,
703 "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
704 }
705 }),
706 })
707 .collect();
708 Ok(Exchange::new(Message {
709 headers: Default::default(),
710 body: Body::Json(serde_json::Value::Array(bodies)),
711 }))
712 }
713 AggregationStrategy::Custom(f) => {
714 let mut iter = exchanges.into_iter();
715 let first = iter.next().ok_or_else(|| {
716 CamelError::ProcessorError("Aggregator: empty bucket".to_string())
717 })?;
718 Ok(iter.fold(first, |acc, next| f(acc, next)))
719 }
720 }
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use std::collections::HashMap;
727
728 use camel_api::{
729 StepLifecycle, StepShutdownReason,
730 aggregator::{AggregationStrategy, AggregatorConfig},
731 body::Body,
732 exchange::Exchange,
733 message::Message,
734 };
735 use tokio::sync::mpsc;
736 use tokio_util::sync::CancellationToken;
737 use tower::ServiceExt;
738
739 fn make_exchange(header: &str, value: &str, body: &str) -> Exchange {
740 let mut msg = Message {
741 headers: Default::default(),
742 body: Body::Text(body.to_string()),
743 };
744 msg.headers
745 .insert(header.to_string(), serde_json::json!(value));
746 Exchange::new(msg)
747 }
748
749 fn config_size(n: usize) -> AggregatorConfig {
750 AggregatorConfig::correlate_by("orderId")
751 .complete_when_size(n)
752 .build()
753 .unwrap()
754 }
755
756 fn new_test_svc(config: AggregatorConfig) -> AggregatorService {
757 let (tx, _rx) = mpsc::channel(256);
758 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
759 let cancel = CancellationToken::new();
760 AggregatorService::new(config, tx, registry, cancel)
761 }
762
763 fn new_test_svc_with_registry(
767 config: AggregatorConfig,
768 registry: SharedLanguageRegistry,
769 ) -> AggregatorService {
770 let (tx, _rx) = mpsc::channel(256);
771 let cancel = CancellationToken::new();
772 AggregatorService::new(config, tx, registry, cancel)
773 }
774
775 #[tokio::test]
776 async fn test_pending_exchange_not_yet_complete() {
777 let mut svc = new_test_svc(config_size(3));
778 let ex = make_exchange("orderId", "A", "first");
779 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
780 assert!(matches!(result.input.body, Body::Empty));
781 assert_eq!(
782 result.property(CAMEL_AGGREGATOR_PENDING),
783 Some(&serde_json::json!(true))
784 );
785 }
786
787 #[tokio::test]
788 async fn test_completes_on_size() {
789 let mut svc = new_test_svc(config_size(3));
790 for _ in 0..2 {
791 let ex = make_exchange("orderId", "A", "item");
792 let r = svc.ready().await.unwrap().call(ex).await.unwrap();
793 assert!(matches!(r.input.body, Body::Empty));
794 }
795 let ex = make_exchange("orderId", "A", "last");
796 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
797 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
798 assert_eq!(
799 result.property(CAMEL_AGGREGATED_SIZE),
800 Some(&serde_json::json!(3u64))
801 );
802 }
803
804 #[tokio::test]
805 async fn test_collect_all_produces_json_array() {
806 let mut svc = new_test_svc(config_size(2));
807 svc.ready()
808 .await
809 .unwrap()
810 .call(make_exchange("orderId", "A", "alpha"))
811 .await
812 .unwrap();
813 let result = svc
814 .ready()
815 .await
816 .unwrap()
817 .call(make_exchange("orderId", "A", "beta"))
818 .await
819 .unwrap();
820 let Body::Json(v) = &result.input.body else {
821 panic!("expected Body::Json")
822 };
823 let arr = v.as_array().unwrap();
824 assert_eq!(arr.len(), 2);
825 assert_eq!(arr[0], serde_json::json!("alpha"));
826 assert_eq!(arr[1], serde_json::json!("beta"));
827 }
828
829 #[tokio::test]
830 async fn test_two_keys_independent_buckets() {
831 let mut svc = new_test_svc(config_size(3));
833 svc.ready()
834 .await
835 .unwrap()
836 .call(make_exchange("orderId", "A", "a1"))
837 .await
838 .unwrap();
839 svc.ready()
840 .await
841 .unwrap()
842 .call(make_exchange("orderId", "B", "b1"))
843 .await
844 .unwrap();
845 svc.ready()
846 .await
847 .unwrap()
848 .call(make_exchange("orderId", "A", "a2"))
849 .await
850 .unwrap();
851 let ra = svc
853 .ready()
854 .await
855 .unwrap()
856 .call(make_exchange("orderId", "A", "a3"))
857 .await
858 .unwrap();
859 assert!(matches!(ra.input.body, Body::Json(_)));
861 let rb = svc
863 .ready()
864 .await
865 .unwrap()
866 .call(make_exchange("orderId", "B", "b_check"))
867 .await
868 .unwrap();
869 assert!(matches!(rb.input.body, Body::Empty));
870 }
871
872 #[tokio::test]
873 async fn test_bucket_resets_after_completion() {
874 let mut svc = new_test_svc(config_size(2));
875 svc.ready()
876 .await
877 .unwrap()
878 .call(make_exchange("orderId", "A", "x"))
879 .await
880 .unwrap();
881 svc.ready()
882 .await
883 .unwrap()
884 .call(make_exchange("orderId", "A", "x"))
885 .await
886 .unwrap(); let r = svc
889 .ready()
890 .await
891 .unwrap()
892 .call(make_exchange("orderId", "A", "new"))
893 .await
894 .unwrap();
895 assert!(matches!(r.input.body, Body::Empty)); }
897
898 #[tokio::test]
899 async fn test_completion_size_1_emits_immediately() {
900 let mut svc = new_test_svc(config_size(1));
901 let ex = make_exchange("orderId", "A", "solo");
902 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
903 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
904 }
905
906 #[tokio::test]
907 async fn test_custom_aggregation_strategy() {
908 use camel_api::aggregator::AggregationFn;
909 use std::sync::Arc;
910
911 let f: AggregationFn = Arc::new(|mut acc: Exchange, next: Exchange| {
912 let combined = format!(
913 "{}+{}",
914 acc.input.body.as_text().unwrap_or(""),
915 next.input.body.as_text().unwrap_or("")
916 );
917 acc.input.body = Body::Text(combined);
918 acc
919 });
920 let config = AggregatorConfig::correlate_by("key")
921 .complete_when_size(2)
922 .strategy(AggregationStrategy::Custom(f))
923 .build()
924 .unwrap();
925 let mut svc = new_test_svc(config);
926 svc.ready()
927 .await
928 .unwrap()
929 .call(make_exchange("key", "X", "hello"))
930 .await
931 .unwrap();
932 let result = svc
933 .ready()
934 .await
935 .unwrap()
936 .call(make_exchange("key", "X", "world"))
937 .await
938 .unwrap();
939 assert_eq!(result.input.body.as_text(), Some("hello+world"));
940 }
941
942 #[tokio::test]
943 async fn test_completion_predicate() {
944 let config = AggregatorConfig::correlate_by("key")
945 .complete_when(|bucket| {
946 bucket
947 .iter()
948 .any(|e| e.input.body.as_text() == Some("DONE"))
949 })
950 .build()
951 .unwrap();
952 let mut svc = new_test_svc(config);
953 svc.ready()
954 .await
955 .unwrap()
956 .call(make_exchange("key", "K", "first"))
957 .await
958 .unwrap();
959 svc.ready()
960 .await
961 .unwrap()
962 .call(make_exchange("key", "K", "second"))
963 .await
964 .unwrap();
965 let result = svc
966 .ready()
967 .await
968 .unwrap()
969 .call(make_exchange("key", "K", "DONE"))
970 .await
971 .unwrap();
972 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
973 }
974
975 #[tokio::test]
976 async fn test_missing_header_returns_error() {
977 let mut svc = new_test_svc(config_size(2));
978 let msg = Message {
979 headers: Default::default(),
980 body: Body::Text("no key".into()),
981 };
982 let ex = Exchange::new(msg);
983 let result = svc.ready().await.unwrap().call(ex).await;
984 assert!(result.is_err());
985 assert!(matches!(
986 result.unwrap_err(),
987 camel_api::CamelError::ProcessorError(_)
988 ));
989 }
990
991 #[tokio::test]
992 async fn test_cloned_service_shares_state() {
993 let svc1 = new_test_svc(config_size(2));
994 let mut svc2 = svc1.clone();
995 svc1.clone()
997 .ready()
998 .await
999 .unwrap()
1000 .call(make_exchange("orderId", "A", "from-svc1"))
1001 .await
1002 .unwrap();
1003 let result = svc2
1005 .ready()
1006 .await
1007 .unwrap()
1008 .call(make_exchange("orderId", "A", "from-svc2"))
1009 .await
1010 .unwrap();
1011 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1012 }
1013
1014 #[tokio::test]
1015 async fn test_camel_aggregated_key_property_set() {
1016 let mut svc = new_test_svc(config_size(1));
1017 let ex = make_exchange("orderId", "ORDER-42", "body");
1018 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1019 assert_eq!(
1020 result.property(CAMEL_AGGREGATED_KEY),
1021 Some(&serde_json::json!("ORDER-42"))
1022 );
1023 }
1024
1025 #[tokio::test]
1026 async fn test_aggregator_enforces_max_buckets() {
1027 let config = AggregatorConfig::correlate_by("orderId")
1028 .complete_when_size(2)
1029 .max_buckets(3)
1030 .build()
1031 .unwrap();
1032
1033 let mut svc = new_test_svc(config);
1034
1035 for i in 0..3 {
1037 let ex = make_exchange("orderId", &format!("key-{}", i), "body");
1038 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1039 }
1040
1041 let ex = make_exchange("orderId", "key-4", "body");
1043 let result = svc.ready().await.unwrap().call(ex).await;
1044
1045 assert!(result.is_err(), "Should reject when max buckets reached");
1046 let err = result.unwrap_err().to_string();
1047 assert!(
1048 err.contains("maximum"),
1049 "Error message should contain 'maximum': {}",
1050 err
1051 );
1052 }
1053
1054 #[tokio::test]
1055 async fn test_max_buckets_allows_existing_key() {
1056 let config = AggregatorConfig::correlate_by("orderId")
1057 .complete_when_size(5) .max_buckets(2)
1059 .build()
1060 .unwrap();
1061
1062 let mut svc = new_test_svc(config);
1063
1064 let ex1 = make_exchange("orderId", "key-A", "body1");
1066 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1067 let ex2 = make_exchange("orderId", "key-B", "body2");
1068 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1069
1070 let ex3 = make_exchange("orderId", "key-A", "body3");
1072 let result = svc.ready().await.unwrap().call(ex3).await;
1073 assert!(
1074 result.is_ok(),
1075 "Should allow adding to existing bucket even at max limit"
1076 );
1077 }
1078
1079 #[tokio::test]
1080 async fn test_bucket_ttl_eviction() {
1081 let config = AggregatorConfig::correlate_by("orderId")
1082 .complete_when_size(10) .bucket_ttl(Duration::from_millis(50))
1084 .build()
1085 .unwrap();
1086
1087 let mut svc = new_test_svc(config);
1088
1089 let ex1 = make_exchange("orderId", "key-A", "body1");
1091 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1092
1093 tokio::time::sleep(Duration::from_millis(100)).await;
1095
1096 let ex2 = make_exchange("orderId", "key-B", "body2");
1098 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1099
1100 let ex3 = make_exchange("orderId", "key-A", "body3");
1103 let result = svc.ready().await.unwrap().call(ex3).await;
1104 assert!(result.is_ok(), "Should be able to recreate evicted bucket");
1105 }
1106
1107 #[tokio::test(start_paused = true)]
1108 async fn test_timeout_completes_bucket() {
1109 let config = AggregatorConfig::correlate_by("key")
1110 .complete_on_timeout(Duration::from_millis(100))
1111 .build()
1112 .unwrap();
1113 let mut svc = new_test_svc(config);
1114 let ex = make_exchange("key", "A", "data");
1115 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1116 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_some());
1117
1118 tokio::time::sleep(Duration::from_millis(200)).await;
1119
1120 assert_eq!(
1121 svc.buckets.lock().unwrap().len(),
1122 0,
1123 "bucket should be removed after timeout"
1124 );
1125 }
1126
1127 #[tokio::test(start_paused = true)]
1128 async fn test_timeout_resets_on_new_exchange() {
1129 let config = AggregatorConfig::correlate_by("key")
1130 .complete_on_timeout(Duration::from_millis(150))
1131 .build()
1132 .unwrap();
1133 let mut svc = new_test_svc(config);
1134
1135 let ex1 = make_exchange("key", "A", "first");
1136 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1137
1138 tokio::time::sleep(Duration::from_millis(100)).await;
1139
1140 let ex2 = make_exchange("key", "A", "second");
1141 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1142
1143 tokio::time::sleep(Duration::from_millis(100)).await;
1144
1145 assert_eq!(
1146 svc.buckets.lock().unwrap().len(),
1147 1,
1148 "bucket should still exist — timeout was reset"
1149 );
1150
1151 tokio::time::sleep(Duration::from_millis(100)).await;
1152
1153 assert_eq!(
1154 svc.buckets.lock().unwrap().len(),
1155 0,
1156 "bucket should be gone after timeout fires"
1157 );
1158 }
1159
1160 #[tokio::test]
1161 async fn test_composable_size_and_timeout() {
1162 let config = AggregatorConfig::correlate_by("key")
1163 .complete_on_size_or_timeout(2, Duration::from_millis(200))
1164 .build()
1165 .unwrap();
1166 let mut svc = new_test_svc(config);
1167
1168 let ex1 = make_exchange("key", "A", "first");
1169 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1170 assert!(svc.buckets.lock().unwrap().contains_key("\"A\""));
1171
1172 let ex2 = make_exchange("key", "A", "second");
1173 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1174 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1175 assert_eq!(
1176 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1177 Some(&serde_json::json!("size"))
1178 );
1179 }
1180
1181 #[tokio::test(start_paused = true)]
1182 async fn test_discard_on_timeout() {
1183 let config = AggregatorConfig::correlate_by("key")
1184 .complete_on_timeout(Duration::from_millis(50))
1185 .discard_on_timeout(true)
1186 .build()
1187 .unwrap();
1188 let (tx, mut rx) = mpsc::channel(256);
1189 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1190 let cancel = CancellationToken::new();
1191 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1192
1193 let ex = make_exchange("key", "A", "data");
1194 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1195
1196 tokio::time::sleep(Duration::from_millis(100)).await;
1197
1198 assert!(
1199 rx.try_recv().is_err(),
1200 "no emit expected with discard_on_timeout"
1201 );
1202 assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1203 assert!(
1204 svc.timeout_tasks.lock().unwrap().is_empty(),
1205 "timeout task should be cleaned up"
1206 );
1207 }
1208
1209 #[tokio::test]
1210 async fn test_force_completion_on_stop() {
1211 let config = AggregatorConfig::correlate_by("key")
1212 .complete_when_size(10)
1213 .force_completion_on_stop(true)
1214 .build()
1215 .unwrap();
1216 let (tx, mut rx) = mpsc::channel(256);
1217 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1218 let cancel = CancellationToken::new();
1219 let svc = AggregatorService::new(config, tx, registry, cancel);
1220
1221 let mut call_svc = svc.clone();
1222 let ex = make_exchange("key", "A", "data");
1223 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1224
1225 svc.force_complete_all();
1226
1227 let result = rx.try_recv().expect("should emit on force-complete");
1228 assert!(
1229 result.input.body.as_text().is_some() || matches!(result.input.body, Body::Json(_))
1230 );
1231 assert_eq!(
1232 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1233 Some(&serde_json::json!("stop"))
1234 );
1235 }
1236
1237 #[tokio::test]
1238 async fn test_completion_reason_property_size() {
1239 let config = AggregatorConfig::correlate_by("key")
1240 .complete_when_size(1)
1241 .build()
1242 .unwrap();
1243 let mut svc = new_test_svc(config);
1244 let ex = make_exchange("key", "X", "body");
1245 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1246 assert_eq!(
1247 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1248 Some(&serde_json::json!("size"))
1249 );
1250 }
1251
1252 #[tokio::test]
1253 async fn test_completion_reason_property_predicate() {
1254 let config = AggregatorConfig::correlate_by("key")
1255 .complete_when(|_| true)
1256 .build()
1257 .unwrap();
1258 let mut svc = new_test_svc(config);
1259 let ex = make_exchange("key", "X", "body");
1260 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1261 assert_eq!(
1262 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1263 Some(&serde_json::json!("predicate"))
1264 );
1265 }
1266
1267 #[tokio::test(start_paused = true)]
1268 async fn test_size_completes_before_timeout() {
1269 let config = AggregatorConfig::correlate_by("key")
1270 .complete_on_size_or_timeout(2, Duration::from_millis(200))
1271 .build()
1272 .unwrap();
1273 let mut svc = new_test_svc(config);
1274
1275 let ex1 = make_exchange("key", "A", "first");
1276 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1277
1278 let ex2 = make_exchange("key", "A", "second");
1279 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1280
1281 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1282 assert_eq!(
1283 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1284 Some(&serde_json::json!("size"))
1285 );
1286 assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1287
1288 tokio::time::sleep(Duration::from_millis(300)).await;
1289 assert_eq!(
1290 svc.buckets.lock().unwrap().len(),
1291 0,
1292 "no re-fire after timeout"
1293 );
1294 }
1295
1296 #[tokio::test(start_paused = true)]
1297 async fn test_concurrent_timeout_fire_and_new_exchange() {
1298 let config = AggregatorConfig::correlate_by("key")
1299 .complete_on_size_or_timeout(2, Duration::from_millis(100))
1300 .build()
1301 .unwrap();
1302 let (tx, mut rx) = mpsc::channel(256);
1303 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1304 let cancel = CancellationToken::new();
1305 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1306
1307 let ex = make_exchange("key", "A", "data");
1308 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1309
1310 tokio::time::sleep(Duration::from_millis(150)).await;
1312
1313 let ex2 = make_exchange("key", "A", "data2");
1315 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1316 assert!(
1317 result.property(CAMEL_AGGREGATOR_PENDING).is_some(),
1318 "should be pending in new bucket"
1319 );
1320
1321 let mut late_count = 0;
1323 while rx.try_recv().is_ok() {
1324 late_count += 1;
1325 }
1326 assert_eq!(
1327 late_count, 1,
1328 "exactly 1 late emit from the timed-out bucket"
1329 );
1330 }
1331
1332 #[tokio::test(start_paused = true)]
1333 async fn test_late_channel_full_drops_with_warning() {
1334 let config = AggregatorConfig::correlate_by("key")
1335 .complete_on_timeout(Duration::from_millis(50))
1336 .build()
1337 .unwrap();
1338 let (tx, mut rx) = mpsc::channel(1);
1339 rx.close();
1340 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1341 let cancel = CancellationToken::new();
1342 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1343
1344 let ex = make_exchange("key", "A", "data");
1345 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1346
1347 tokio::time::sleep(Duration::from_millis(100)).await;
1348 assert_eq!(
1349 svc.buckets.lock().unwrap().len(),
1350 0,
1351 "bucket removed despite channel closed"
1352 );
1353 }
1354
1355 #[tokio::test]
1356 async fn test_aggregate_stream_bodies_creates_valid_json() {
1357 use bytes::Bytes;
1358 use camel_api::{Body, StreamBody, StreamMetadata};
1359 use futures::stream;
1360 use tokio::sync::Mutex;
1361
1362 let chunks = vec![Ok(Bytes::from("test"))];
1363 let stream_body = StreamBody {
1364 stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1365 metadata: StreamMetadata {
1366 origin: Some("file:///test.txt".to_string()),
1367 ..Default::default()
1368 },
1369 };
1370
1371 let ex1 = Exchange::new(Message {
1372 headers: Default::default(),
1373 body: Body::Stream(stream_body),
1374 });
1375
1376 let exchanges = vec![ex1];
1377 let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1378
1379 let exchange = result.expect("Expected Ok result");
1380 assert!(
1381 matches!(exchange.input.body, Body::Json(_)),
1382 "Expected Json body"
1383 );
1384
1385 if let Body::Json(value) = exchange.input.body {
1386 let json_str = serde_json::to_string(&value).unwrap();
1387 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1388
1389 assert!(parsed.is_array(), "Result should be an array");
1390 let arr = parsed.as_array().unwrap();
1391 assert!(arr[0].is_object(), "First element should be an object");
1392 assert!(
1393 arr[0]["_stream"].is_object(),
1394 "Should contain _stream object"
1395 );
1396 assert_eq!(arr[0]["_stream"]["origin"], "file:///test.txt");
1397 assert_eq!(
1398 arr[0]["_stream"]["placeholder"], true,
1399 "placeholder flag should be true"
1400 );
1401 }
1402 }
1403
1404 #[tokio::test]
1405 async fn test_aggregate_stream_bodies_with_none_origin() {
1406 use bytes::Bytes;
1407 use camel_api::{Body, StreamBody, StreamMetadata};
1408 use futures::stream;
1409 use tokio::sync::Mutex;
1410
1411 let chunks = vec![Ok(Bytes::from("test"))];
1412 let stream_body = StreamBody {
1413 stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1414 metadata: StreamMetadata {
1415 origin: None,
1416 ..Default::default()
1417 },
1418 };
1419
1420 let ex1 = Exchange::new(Message {
1421 headers: Default::default(),
1422 body: Body::Stream(stream_body),
1423 });
1424
1425 let exchanges = vec![ex1];
1426 let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1427
1428 let exchange = result.expect("Expected Ok result");
1429 assert!(
1430 matches!(exchange.input.body, Body::Json(_)),
1431 "Expected Json body"
1432 );
1433
1434 if let Body::Json(value) = exchange.input.body {
1435 let json_str = serde_json::to_string(&value).unwrap();
1436 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1437
1438 assert!(parsed.is_array(), "Result should be an array");
1439 let arr = parsed.as_array().unwrap();
1440 assert!(arr[0].is_object(), "First element should be an object");
1441 assert!(
1442 arr[0]["_stream"].is_object(),
1443 "Should contain _stream object"
1444 );
1445 assert_eq!(
1446 arr[0]["_stream"]["origin"],
1447 serde_json::Value::Null,
1448 "origin should be null when None"
1449 );
1450 assert_eq!(
1451 arr[0]["_stream"]["placeholder"], true,
1452 "placeholder flag should be true"
1453 );
1454 }
1455 }
1456
1457 #[tokio::test]
1458 async fn timeout_completion_clears_handle_from_map() {
1459 let config = AggregatorConfig::correlate_by("key")
1464 .complete_on_timeout(Duration::from_millis(50))
1465 .build()
1466 .unwrap();
1467 let (tx, _rx) = mpsc::channel(256);
1468 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1469 let cancel = CancellationToken::new();
1470 let svc = AggregatorService::new(config, tx, registry, cancel);
1471
1472 let mut call_svc = svc.clone();
1474 let ex = make_exchange("key", "A", "data");
1475 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1476 assert!(
1477 !svc.timeout_handles.lock().unwrap().is_empty(),
1478 "handle should exist while timeout pending"
1479 );
1480
1481 tokio::time::sleep(Duration::from_millis(200)).await;
1483
1484 assert!(
1485 svc.timeout_handles.lock().unwrap().is_empty(),
1486 "handle should be cleared from map after natural timeout completion (was leak)"
1487 );
1488 }
1489
1490 #[tokio::test]
1491 async fn aggregator_shutdown_via_trait_dispatch() {
1492 let config = AggregatorConfig::correlate_by("key")
1495 .complete_when_size(10)
1496 .build()
1497 .unwrap();
1498 let (tx, _rx) = mpsc::channel(256);
1499 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1500 let cancel = CancellationToken::new();
1501 let svc = AggregatorService::new(config, tx, registry, cancel);
1502
1503 let step: Arc<dyn StepLifecycle> = Arc::new(svc);
1504 step.shutdown(StepShutdownReason::RouteStop)
1505 .await
1506 .expect("first shutdown should succeed");
1507 step.shutdown(StepShutdownReason::RouteStop)
1508 .await
1509 .expect("second shutdown (idempotent) should succeed");
1510 }
1511
1512 #[tokio::test(start_paused = true)]
1513 async fn test_shutdown_awaits_timeout_handles() {
1514 let config = AggregatorConfig::correlate_by("key")
1515 .complete_on_timeout(Duration::from_millis(100))
1516 .build()
1517 .unwrap();
1518 let (tx, _rx) = mpsc::channel(256);
1519 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1520 let cancel = CancellationToken::new();
1521 let svc = AggregatorService::new(config, tx, registry, cancel);
1522
1523 let mut call_svc = svc.clone();
1525 let ex = make_exchange("key", "A", "data");
1526 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1527
1528 assert!(
1530 !svc.timeout_handles.lock().unwrap().is_empty(),
1531 "should have a timeout handle"
1532 );
1533
1534 svc.shutdown_inner().await;
1537
1538 assert!(
1539 svc.timeout_handles.lock().unwrap().is_empty(),
1540 "all handles should be cleaned up after shutdown"
1541 );
1542 }
1543
1544 #[tokio::test]
1551 async fn test_unique_key_flood_stays_bounded_by_default() {
1552 let config = AggregatorConfig::correlate_by("orderId")
1554 .complete_when_size(1_000_000) .build()
1556 .unwrap();
1557 let mut svc = new_test_svc(config);
1558
1559 for i in 0..10_000usize {
1562 let ex = make_exchange("orderId", &format!("key-{i}"), "body");
1563 let result = svc.ready().await.unwrap().call(ex).await;
1564 assert!(result.is_ok(), "key {i} should be accepted under the cap");
1565 }
1566 let ex = make_exchange("orderId", "key-10001", "body");
1567 let result = svc.ready().await.unwrap().call(ex).await;
1568 assert!(
1569 result.is_err(),
1570 "10_001st unique key must be rejected by the max_buckets cap"
1571 );
1572 let err = result.unwrap_err().to_string();
1573 assert!(
1574 err.contains("maximum") || err.contains("max"),
1575 "error should mention cap: {err}"
1576 );
1577 }
1578
1579 #[tokio::test]
1585 async fn test_background_sweep_spawns_on_first_poll_not_construction() {
1586 let config = AggregatorConfig::correlate_by("key")
1587 .complete_when_size(10_000)
1588 .bucket_ttl(Duration::from_millis(50))
1589 .build()
1590 .unwrap();
1591 let cancel = CancellationToken::new();
1592 let (tx, _rx) = mpsc::channel(8);
1593 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1594 let mut svc = AggregatorService::new(config, tx, registry, cancel.clone());
1595
1596 assert!(
1598 svc.sweep_handle
1599 .lock()
1600 .unwrap_or_else(|e| e.into_inner())
1601 .is_none(),
1602 "sweep must NOT be spawned at construction (runtime-free new)"
1603 );
1604
1605 let _ = svc.ready().await.unwrap();
1608 let sweep_present = svc
1609 .sweep_handle
1610 .lock()
1611 .unwrap_or_else(|e| e.into_inner())
1612 .is_some();
1613 assert!(
1614 sweep_present,
1615 "sweep handle should be Some after first poll when bucket_ttl is set"
1616 );
1617
1618 cancel.cancel();
1620 tokio::time::sleep(Duration::from_millis(50)).await;
1622 }
1623
1624 #[tokio::test]
1627 async fn test_aggregator_timeout_task_cap_no_panic_under_flood() {
1628 use camel_api::aggregator::CorrelationStrategy;
1631
1632 let config = AggregatorConfig {
1633 header_name: "k".into(),
1634 completion: CompletionMode::Any(vec![
1635 CompletionCondition::Size(999),
1636 CompletionCondition::Timeout(Duration::from_secs(30)),
1637 ]),
1638 correlation: CorrelationStrategy::HeaderName("k".into()),
1639 strategy: AggregationStrategy::CollectAll,
1640 max_buckets: Some(50),
1641 bucket_ttl: Some(Duration::from_secs(30)),
1642 force_completion_on_stop: false,
1643 discard_on_timeout: false,
1644 max_timeout_tasks: 2,
1645 };
1646 let (late_tx, mut late_rx) = mpsc::channel(64);
1647 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1648 let cancel = CancellationToken::new();
1649 let svc = AggregatorService::new(config, late_tx, registry, cancel);
1650
1651 for i in 0..20u64 {
1653 let mut ex = Exchange::new(Message {
1654 headers: HashMap::from([("k".to_string(), serde_json::json!(i))]),
1655 body: Body::Text(i.to_string()),
1656 });
1657 ex.input
1658 .headers
1659 .insert("k".to_string(), serde_json::json!(i));
1660 let outcome = tokio::time::timeout(Duration::from_secs(2), async {
1661 let mut s = svc.clone();
1662 use tower::ServiceExt;
1663 s.ready().await.unwrap().call(ex).await
1664 })
1665 .await;
1666 assert!(outcome.is_ok(), "call {} hung/panicked under task cap", i);
1667 let res = outcome.unwrap().unwrap();
1669 assert_eq!(
1670 res.properties
1671 .get(CAMEL_AGGREGATOR_PENDING)
1672 .and_then(|v| v.as_bool()),
1673 Some(true),
1674 "exchange {} should be pending",
1675 i
1676 );
1677 }
1678 let _ = late_rx.try_recv();
1680 }
1681
1682 #[tokio::test]
1683 async fn evaluate_completion_predicate_or_combines_any() {
1684 use camel_api::aggregator::{CompletionCondition, CompletionMode};
1685 use camel_language_api::Language;
1686 use std::collections::HashMap;
1687
1688 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1690 registry.lock().unwrap().insert(
1691 "simple".to_string(),
1692 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
1693 );
1694
1695 let incoming = make_exchange("k", "X", "hello");
1696
1697 let mode = CompletionMode::Any(vec![
1699 CompletionCondition::PredicateExpr {
1700 expr: "${body} == 'NOPE'".to_string(),
1701 language: "simple".to_string(),
1702 },
1703 CompletionCondition::PredicateExpr {
1704 expr: "${body} == 'hello'".to_string(),
1705 language: "simple".to_string(),
1706 },
1707 ]);
1708
1709 let satisfied = evaluate_completion_predicate(&mode, &incoming, ®istry)
1710 .await
1711 .expect("eval must succeed");
1712 assert!(satisfied, "second predicate matches → OR true");
1713 }
1714
1715 #[tokio::test]
1716 async fn evaluate_completion_predicate_no_predicate_skips_registry() {
1717 use camel_api::aggregator::{CompletionCondition, CompletionMode};
1718 use std::collections::HashMap;
1719
1720 let mode = CompletionMode::Single(CompletionCondition::Size(3));
1724 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1725 let incoming = make_exchange("k", "X", "hello");
1726 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
1727 assert!(result.is_ok(), "fast path must not error: {:?}", result);
1728 assert!(!result.unwrap(), "no predicate → not satisfied");
1729 }
1730
1731 #[tokio::test]
1732 async fn evaluate_completion_predicate_unregistered_language_errors() {
1733 use camel_api::aggregator::{CompletionCondition, CompletionMode};
1734
1735 let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
1736 expr: "${body} == 'DONE'".to_string(),
1737 language: "nonexistent-lang".to_string(),
1738 });
1739 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1740 let incoming = make_exchange("k", "X", "hello");
1741 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
1742 assert!(result.is_err(), "unregistered language must error");
1743 }
1744
1745 #[tokio::test]
1746 async fn evaluate_completion_predicate_all_miss_returns_false() {
1747 use camel_api::aggregator::{CompletionCondition, CompletionMode};
1748 use camel_language_api::Language;
1749 use std::collections::HashMap;
1750
1751 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1752 registry.lock().unwrap().insert(
1753 "simple".to_string(),
1754 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
1755 );
1756
1757 let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
1758 expr: "${body} == 'NOPE'".to_string(),
1759 language: "simple".to_string(),
1760 });
1761 let incoming = make_exchange("k", "X", "hello");
1762 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
1763 assert!(result.is_ok(), "eval must succeed: {:?}", result);
1764 assert!(!result.unwrap(), "predicate does not match");
1765 }
1766
1767 #[tokio::test]
1768 async fn completion_predicate_expr_completes_on_match() {
1769 use camel_api::aggregator::{CompletionCondition, CompletionMode};
1770 use camel_language_api::Language;
1771 use std::collections::HashMap;
1772
1773 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1775 registry.lock().unwrap().insert(
1776 "simple".to_string(),
1777 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
1778 );
1779
1780 let mut config = AggregatorConfig::correlate_by("key")
1785 .complete_when_size(999) .build()
1787 .expect("config builds");
1788 config.completion = CompletionMode::Single(CompletionCondition::PredicateExpr {
1789 expr: "${body} == 'DONE'".to_string(),
1790 language: "simple".to_string(),
1791 });
1792
1793 let mut svc = new_test_svc_with_registry(config, registry);
1794
1795 let r = svc
1797 .ready()
1798 .await
1799 .unwrap()
1800 .call(make_exchange("key", "K", "first"))
1801 .await
1802 .unwrap();
1803 assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_some());
1804
1805 let r = svc
1807 .ready()
1808 .await
1809 .unwrap()
1810 .call(make_exchange("key", "K", "DONE"))
1811 .await
1812 .unwrap();
1813 assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_none());
1814 assert_eq!(
1815 r.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1816 Some(&serde_json::json!("predicate"))
1817 );
1818 }
1819}