1use std::collections::HashMap;
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::{Arc, Mutex};
5use std::task::{Context, Poll};
6use std::time::{Duration, Instant};
7
8use tokio::sync::mpsc;
9use tokio::task::JoinHandle;
10use tokio_util::sync::CancellationToken;
11use tower::Service;
12
13use async_trait::async_trait;
14use camel_api::{
15 CamelError, MetricsCollector, StepLifecycle, StepShutdownReason,
16 aggregator::{
17 AggregationStrategy, AggregatorConfig, CompletionCondition, CompletionMode,
18 CompletionReason, CorrelationStrategy,
19 },
20 body::Body,
21 exchange::Exchange,
22 message::Message,
23};
24use camel_language_api::Language;
25
26pub type SharedLanguageRegistry = Arc<std::sync::Mutex<HashMap<String, Arc<dyn Language>>>>;
27
28const QUEUE_DEPTH_SAMPLE_INTERVAL: Duration = Duration::from_millis(250);
32
33pub const CAMEL_AGGREGATOR_PENDING: &str = "CamelAggregatorPending";
34pub const CAMEL_AGGREGATED_SIZE: &str = "CamelAggregatedSize";
35pub const CAMEL_AGGREGATED_KEY: &str = "CamelAggregatedKey";
36pub const CAMEL_AGGREGATED_COMPLETION_REASON: &str = "CamelAggregatedCompletionReason";
37
38struct Bucket {
40 exchanges: Vec<Exchange>,
41 last_updated: Instant,
42}
43
44impl Bucket {
45 fn new() -> Self {
46 Self {
47 exchanges: Vec::new(),
48 last_updated: Instant::now(),
49 }
50 }
51
52 fn push(&mut self, exchange: Exchange) {
53 self.exchanges.push(exchange);
54 self.last_updated = Instant::now();
55 }
56
57 fn is_expired(&self, ttl: Duration) -> bool {
58 Instant::now().duration_since(self.last_updated) >= ttl
59 }
60}
61
62#[derive(Clone)]
63pub struct AggregatorService {
64 config: AggregatorConfig,
65 buckets: Arc<Mutex<HashMap<String, Bucket>>>,
66 timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
67 timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
68 late_tx: mpsc::Sender<Exchange>,
69 language_registry: SharedLanguageRegistry,
70 sweep_cancel: Arc<Mutex<CancellationToken>>,
77 sweep_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
85 queue_metrics: Option<(Arc<dyn MetricsCollector>, String)>,
90 clone_guard: Arc<()>,
94}
95
96impl std::fmt::Debug for AggregatorService {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.debug_struct("AggregatorService").finish_non_exhaustive()
99 }
100}
101
102impl Drop for AggregatorService {
103 fn drop(&mut self) {
104 if Arc::strong_count(&self.clone_guard) > 1 {
114 return;
115 }
116 if let Some(handle) = self
117 .sweep_handle
118 .lock()
119 .unwrap_or_else(|e| e.into_inner())
120 .take()
121 {
122 handle.abort();
123 }
124 }
125}
126
127impl AggregatorService {
128 pub fn new(
139 config: AggregatorConfig,
140 late_tx: mpsc::Sender<Exchange>,
141 language_registry: SharedLanguageRegistry,
142 route_cancel: CancellationToken,
143 ) -> Self {
144 config.validate().expect(
146 "AggregatorService::new: config failed validation \
149 (need max_buckets, completionTimeout, or bucket_ttl)",
150 );
151
152 let has_timeout = has_timeout_condition(&config.completion);
156 if !has_timeout && config.bucket_ttl.is_none() {
157 tracing::warn!(
158 "Aggregator configured with Size/Predicate completion and no \
159 bucket_ttl: buckets accumulate until max_buckets is reached"
160 );
161 }
162
163 let buckets: Arc<Mutex<HashMap<String, Bucket>>> = Arc::new(Mutex::new(HashMap::new()));
166
167 Self {
176 config,
177 buckets,
178 timeout_tasks: Arc::new(Mutex::new(HashMap::new())),
179 timeout_handles: Arc::new(Mutex::new(HashMap::new())),
180 late_tx,
181 language_registry,
182 sweep_cancel: Arc::new(Mutex::new(route_cancel)),
183 sweep_handle: Arc::new(Mutex::new(None)),
184 queue_metrics: None,
185 clone_guard: Arc::new(()),
186 }
187 }
188
189 pub fn with_queue_metrics(
194 mut self,
195 metrics: Arc<dyn MetricsCollector>,
196 label: impl Into<String>,
197 ) -> Self {
198 self.queue_metrics = Some((metrics, label.into()));
199 self
200 }
201
202 pub fn config(&self) -> &AggregatorConfig {
203 &self.config
204 }
205
206 pub fn has_timeout(&self) -> bool {
207 has_timeout_condition(&self.config.completion)
208 }
209
210 pub fn force_complete_all(&self) {
211 let mut buckets_guard = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
212 let keys: Vec<String> = buckets_guard.keys().cloned().collect();
213
214 for key in keys {
215 if let Some(bucket) = buckets_guard.remove(&key) {
216 if self.config.force_completion_on_stop {
217 cancel_timeout_task_with_handle(
218 &key,
219 &self.timeout_tasks,
220 &self.timeout_handles,
221 );
222 match aggregate(bucket.exchanges, &self.config.strategy) {
223 Ok(mut result) => {
224 result.set_property(
225 CAMEL_AGGREGATED_COMPLETION_REASON,
226 serde_json::json!(CompletionReason::Stop.as_str()),
227 );
228 if self.late_tx.try_send(result).is_err() {
229 tracing::warn!(
230 key = %key,
231 "aggregator force-complete emit dropped: late channel full"
232 );
233 }
234 }
235 Err(e) => {
236 tracing::warn!(
238 key = %key,
239 error = %e,
240 "aggregation failed in force_complete_all"
241 );
242 }
243 }
244 } else {
245 cancel_timeout_task_with_handle(
246 &key,
247 &self.timeout_tasks,
248 &self.timeout_handles,
249 );
250 }
251 }
252 }
253 }
254
255 pub(crate) async fn shutdown_inner(&self) {
258 {
260 let mut guard = self.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
261 for token in guard.values() {
262 token.cancel();
263 }
264 guard.clear();
265 };
266
267 let handles: Vec<JoinHandle<()>> = {
269 let mut guard = self
270 .timeout_handles
271 .lock()
272 .unwrap_or_else(|e| e.into_inner());
273 guard.drain().map(|(_, handle)| handle).collect()
274 };
275
276 if handles.is_empty() {
277 return;
278 }
279
280 let _ = tokio::time::timeout(Duration::from_secs(5), async {
282 for handle in handles {
283 let _ = handle.await;
284 }
285 })
286 .await;
287 }
288}
289
290#[async_trait]
291impl StepLifecycle for AggregatorService {
292 fn name(&self) -> &'static str {
293 "aggregator"
294 }
295
296 async fn shutdown(&self, reason: StepShutdownReason) -> Result<(), CamelError> {
297 tracing::debug!(reason = ?reason, "Aggregator shutdown via StepLifecycle");
298
299 self.sweep_cancel
302 .lock()
303 .unwrap_or_else(|e| e.into_inner())
304 .cancel();
305
306 if let Some(handle) = self
309 .sweep_handle
310 .lock()
311 .unwrap_or_else(|e| e.into_inner())
312 .take()
313 {
314 handle.abort();
315 }
316
317 self.shutdown_inner().await;
318 Ok(())
319 }
320
321 async fn start(&self) -> Result<(), CamelError> {
325 *self.sweep_cancel.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
326
327 if let Some(handle) = self
328 .sweep_handle
329 .lock()
330 .unwrap_or_else(|e| e.into_inner())
331 .take()
332 {
333 handle.abort();
334 }
335
336 Ok(())
337 }
338}
339
340pub fn has_timeout_condition(mode: &CompletionMode) -> bool {
341 match mode {
342 CompletionMode::Single(CompletionCondition::Timeout(_)) => true,
343 CompletionMode::Any(conditions) => conditions
344 .iter()
345 .any(|c| matches!(c, CompletionCondition::Timeout(_))),
346 _ => false,
347 }
348}
349
350impl Service<Exchange> for AggregatorService {
351 type Response = Exchange;
352 type Error = CamelError;
353 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
354
355 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), CamelError>> {
356 let ttl = self.config.bucket_ttl;
365 if ttl.is_none() && self.queue_metrics.is_none() {
366 return Poll::Ready(Ok(()));
367 }
368 let mut g = self.sweep_handle.lock().unwrap_or_else(|e| e.into_inner());
369 if g.is_none() {
370 let interval = match ttl {
371 Some(ttl) => std::cmp::max(ttl / 2, Duration::from_millis(50)),
372 None => QUEUE_DEPTH_SAMPLE_INTERVAL,
373 };
374 let buckets = Arc::clone(&self.buckets);
375 let cancel = self
376 .sweep_cancel
377 .lock()
378 .unwrap_or_else(|e| e.into_inner())
379 .clone();
380 let queue_metrics = self.queue_metrics.clone();
381 *g = Some(tokio::spawn(async move {
382 loop {
383 tokio::select! {
384 _ = cancel.cancelled() => break,
385 _ = tokio::time::sleep(interval) => {
386 let mut guard =
387 buckets.lock().unwrap_or_else(|e| e.into_inner());
388 if let Some(ttl) = ttl {
389 guard.retain(|_, b| !b.is_expired(ttl));
390 }
391 if let Some((metrics, label)) = queue_metrics.as_ref() {
394 metrics.set_queue_depth(label, guard.len());
395 }
396 }
397 }
398 }
399 }));
400 }
401 Poll::Ready(Ok(()))
402 }
403
404 fn call(&mut self, exchange: Exchange) -> Self::Future {
405 let config = self.config.clone();
406 let buckets = Arc::clone(&self.buckets);
407 let timeout_tasks = Arc::clone(&self.timeout_tasks);
408 let timeout_handles = Arc::clone(&self.timeout_handles);
409 let late_tx = self.late_tx.clone();
410 let language_registry = Arc::clone(&self.language_registry);
411
412 Box::pin(async move {
413 let key_value =
414 extract_correlation_key(&exchange, &config.correlation, &language_registry).await?;
415
416 let key_str = serde_json::to_string(&key_value)
417 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
418
419 let predicate_satisfied =
423 evaluate_completion_predicate(&config.completion, &exchange, &language_registry)
424 .await?;
425
426 let completed_bucket = {
427 let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
428
429 if let Some(ttl) = config.bucket_ttl {
430 guard.retain(|_, bucket| !bucket.is_expired(ttl));
431 }
432
433 if let Some(max) = config.max_buckets
434 && !guard.contains_key(&key_str)
435 && guard.len() >= max
436 {
437 tracing::warn!(
438 max_buckets = max,
439 correlation_key = %key_str,
440 "Aggregator reached max buckets limit, rejecting new correlation key"
441 );
442 return Err(CamelError::ProcessorError(format!(
443 "Aggregator reached maximum {} buckets",
444 max
445 )));
446 }
447
448 if let Some(max_size) = config.max_bucket_size
452 && let Some(existing) = guard.get(&key_str)
453 && existing.exchanges.len() >= max_size
454 {
455 tracing::warn!(
456 max_bucket_size = max_size,
457 correlation_key = %key_str,
458 "Aggregator reached per-bucket size limit, rejecting exchange"
459 );
460 return Err(CamelError::ProcessorError(format!(
461 "Aggregator bucket reached maximum {max_size} exchanges"
462 )));
463 }
464
465 let bucket = guard.entry(key_str.clone()).or_insert_with(Bucket::new);
466 bucket.push(exchange);
467
468 let (is_complete, reason) = check_sync_completion(
469 &config.completion,
470 &bucket.exchanges,
471 predicate_satisfied,
472 );
473
474 if is_complete {
475 let exchanges = guard.remove(&key_str).map(|b| b.exchanges);
476 (exchanges, reason)
477 } else {
478 (None, CompletionReason::Size) }
480 };
481
482 if completed_bucket.0.is_none() && has_timeout_condition(&config.completion) {
483 let timeout_dur = extract_timeout_duration(&config.completion);
484 if let Some(timeout) = timeout_dur {
485 let live_count = timeout_handles
491 .lock()
492 .unwrap_or_else(|e| e.into_inner())
493 .len();
494 if live_count >= config.max_timeout_tasks {
495 tracing::warn!(
496 live_timeout_tasks = live_count,
497 max_timeout_tasks = config.max_timeout_tasks,
498 correlation_key = %key_str,
499 "Aggregator timeout-task cap reached; bucket will rely on \
500 bucket_ttl eviction instead of a dedicated timeout task"
501 );
502 } else {
503 {
505 let tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
506 if let Some(existing) = tt_guard.get(&key_str) {
507 existing.cancel();
508 }
509 }
510 {
512 let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
513 if let Some(old) = hh.remove(&key_str) {
514 old.abort();
515 }
516 }
517 let cancel = CancellationToken::new();
518 timeout_tasks
519 .lock()
520 .unwrap_or_else(|e| e.into_inner())
521 .insert(key_str.clone(), cancel.clone());
522 let handle = spawn_timeout_task(
523 key_str.clone(),
524 timeout,
525 cancel,
526 buckets.clone(),
527 timeout_tasks.clone(),
528 timeout_handles.clone(),
529 late_tx,
530 config.strategy.clone(),
531 config.discard_on_timeout,
532 );
533 timeout_handles
534 .lock()
535 .unwrap_or_else(|e| e.into_inner())
536 .insert(key_str.clone(), handle);
537 }
538 }
539 }
540
541 if let Some(exchanges) = completed_bucket.0 {
542 cancel_timeout_task_with_handle(&key_str, &timeout_tasks, &timeout_handles);
543 let reason = completed_bucket.1;
544 let size = exchanges.len();
545 let mut result = aggregate(exchanges, &config.strategy)?;
546 result.set_property(CAMEL_AGGREGATED_SIZE, serde_json::json!(size as u64));
547 result.set_property(CAMEL_AGGREGATED_KEY, key_value);
548 result.set_property(
549 CAMEL_AGGREGATED_COMPLETION_REASON,
550 serde_json::json!(reason.as_str()),
551 );
552 Ok(result)
553 } else {
554 let mut pending = Exchange::new(Message {
555 headers: Default::default(),
556 body: Body::Empty,
557 });
558 pending.set_property(CAMEL_AGGREGATOR_PENDING, serde_json::json!(true));
559 Ok(pending)
560 }
561 })
562 }
563}
564
565async fn extract_correlation_key(
566 exchange: &Exchange,
567 strategy: &CorrelationStrategy,
568 registry: &SharedLanguageRegistry,
569) -> Result<serde_json::Value, CamelError> {
570 match strategy {
571 CorrelationStrategy::HeaderName(h) => {
572 exchange.input.headers.get(h).cloned().ok_or_else(|| {
573 CamelError::ProcessorError(format!(
574 "Aggregator: missing correlation key header '{}'",
575 h
576 ))
577 })
578 }
579 CorrelationStrategy::Expression { expr, language } => {
580 let expression = {
581 let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
582 let lang = reg.get(language).ok_or_else(|| {
583 CamelError::ProcessorError(format!(
584 "Aggregator: language '{}' not found in registry",
585 language
586 ))
587 })?;
588 lang.create_expression(expr)
589 .map_err(|e| CamelError::ProcessorError(e.to_string()))?
590 };
591 let value = expression
592 .evaluate(exchange)
593 .await
594 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
595 if value.is_null() {
596 return Err(CamelError::ProcessorError(format!(
597 "Aggregator: correlation expression '{}' evaluated to null",
598 expr
599 )));
600 }
601 Ok(value)
602 }
603 CorrelationStrategy::Fn(f) => f(exchange).map(serde_json::Value::String).ok_or_else(|| {
604 CamelError::ProcessorError("Aggregator: correlation function returned None".to_string())
605 }),
606 _ => Err(CamelError::ProcessorError(
608 "Aggregator: unsupported correlation strategy".to_string(),
609 )),
610 }
611}
612
613fn iter_predicate_exprs(mode: &CompletionMode) -> Vec<(&String, &String)> {
615 let mut out = Vec::new();
616 match mode {
617 CompletionMode::Single(CompletionCondition::PredicateExpr { expr, language }) => {
618 out.push((expr, language));
619 }
620 CompletionMode::Any(conds) => {
621 for c in conds {
622 if let CompletionCondition::PredicateExpr { expr, language } = c {
623 out.push((expr, language));
624 }
625 }
626 }
627 _ => {}
628 }
629 out
630}
631
632async fn evaluate_completion_predicate(
637 mode: &CompletionMode,
638 incoming: &Exchange,
639 registry: &SharedLanguageRegistry,
640) -> Result<bool, CamelError> {
641 let mut satisfied = false;
642 for (expr, language) in iter_predicate_exprs(mode) {
643 let expression = {
644 let reg = registry.lock().unwrap_or_else(|e| e.into_inner());
645 let lang = reg.get(language).ok_or_else(|| {
646 CamelError::ProcessorError(format!(
647 "Aggregator: language '{}' not found in registry",
648 language
649 ))
650 })?;
651 lang.create_expression(expr)
652 .map_err(|e| CamelError::ProcessorError(e.to_string()))?
653 }; let value = expression
655 .evaluate(incoming)
656 .await
657 .map_err(|e| CamelError::ProcessorError(e.to_string()))?;
658 if value.as_bool().unwrap_or(false) {
659 satisfied = true;
660 }
661 }
662 Ok(satisfied)
663}
664
665fn check_sync_completion(
666 mode: &CompletionMode,
667 exchanges: &[Exchange],
668 predicate_satisfied: bool,
669) -> (bool, CompletionReason) {
670 match mode {
671 CompletionMode::Single(cond) => check_single(cond, exchanges, predicate_satisfied),
672 CompletionMode::Any(conditions) => {
673 for cond in conditions {
674 if let CompletionCondition::Timeout(_) = cond {
675 continue;
676 }
677 let (done, reason) = check_single(cond, exchanges, predicate_satisfied);
678 if done {
679 return (true, reason);
680 }
681 }
682 (false, CompletionReason::Size)
683 }
684 _ => (false, CompletionReason::Size),
686 }
687}
688
689fn check_single(
690 cond: &CompletionCondition,
691 exchanges: &[Exchange],
692 predicate_satisfied: bool,
693) -> (bool, CompletionReason) {
694 match cond {
695 CompletionCondition::Size(n) => (exchanges.len() >= *n, CompletionReason::Size),
696 CompletionCondition::Predicate(pred) => (pred(exchanges), CompletionReason::Predicate),
697 CompletionCondition::PredicateExpr { .. } => {
698 (predicate_satisfied, CompletionReason::Predicate)
699 }
700 CompletionCondition::Timeout(_) => (false, CompletionReason::Timeout),
701 _ => (false, CompletionReason::Size),
703 }
704}
705
706fn extract_timeout_duration(mode: &CompletionMode) -> Option<Duration> {
707 match mode {
708 CompletionMode::Single(CompletionCondition::Timeout(d)) => Some(*d),
709 CompletionMode::Any(conditions) => conditions.iter().find_map(|c| {
710 if let CompletionCondition::Timeout(d) = c {
711 Some(*d)
712 } else {
713 None
714 }
715 }),
716 _ => None,
717 }
718}
719
720fn cancel_timeout_task(key: &str, timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>) {
721 let mut guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
722 if let Some(token) = guard.remove(key) {
723 token.cancel();
724 }
725}
726
727fn cancel_timeout_task_with_handle(
729 key: &str,
730 timeout_tasks: &Arc<Mutex<HashMap<String, CancellationToken>>>,
731 timeout_handles: &Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
732) {
733 cancel_timeout_task(key, timeout_tasks);
734 let mut guard = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
735 guard.remove(key);
736}
737
738#[allow(clippy::too_many_arguments)]
739fn spawn_timeout_task(
740 key: String,
741 timeout: Duration,
742 cancel: CancellationToken,
743 buckets: Arc<Mutex<HashMap<String, Bucket>>>,
744 timeout_tasks: Arc<Mutex<HashMap<String, CancellationToken>>>,
745 timeout_handles: Arc<Mutex<HashMap<String, JoinHandle<()>>>>,
746 late_tx: mpsc::Sender<Exchange>,
747 strategy: AggregationStrategy,
748 discard: bool,
749) -> JoinHandle<()> {
750 let cancel_clone = cancel.clone();
751 tokio::spawn(async move {
752 tokio::select! {
753 _ = tokio::time::sleep(timeout) => {
754 let should_proceed = {
755 let mut tt_guard = timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
756 if cancel_clone.is_cancelled() {
757 false
758 } else {
759 tt_guard.remove(&key);
760 true
761 }
762 };
763 if !should_proceed {
764 return;
765 }
766 {
769 let mut hh = timeout_handles.lock().unwrap_or_else(|e| e.into_inner());
770 hh.remove(&key);
771 }
772 let bucket_exchanges = {
773 let mut guard = buckets.lock().unwrap_or_else(|e| e.into_inner());
774 guard.remove(&key).map(|b| b.exchanges)
775 };
776 if let Some(exchanges) = bucket_exchanges
777 && !discard
778 {
779 match aggregate(exchanges, &strategy) {
780 Ok(mut result) => {
781 result.set_property(
782 CAMEL_AGGREGATED_COMPLETION_REASON,
783 serde_json::json!(CompletionReason::Timeout.as_str()),
784 );
785 if late_tx.try_send(result).is_err() {
786 tracing::warn!(
787 key = %key,
788 "aggregator timeout emit dropped: late channel full"
789 );
790 }
791 }
792 Err(e) => {
793 tracing::warn!(
795 key = %key,
796 error = %e,
797 "aggregation failed in timeout task"
798 );
799 }
800 }
801 }
802 }
803 _ = cancel_clone.cancelled() => {}
804 }
805 })
806}
807
808fn aggregate(
809 exchanges: Vec<Exchange>,
810 strategy: &AggregationStrategy,
811) -> Result<Exchange, CamelError> {
812 match strategy {
813 AggregationStrategy::CollectAll => {
814 let bodies: Vec<serde_json::Value> = exchanges
815 .into_iter()
816 .map(|e| match e.input.body {
817 Body::Json(v) => v,
818 Body::Text(s) => serde_json::Value::String(s),
819 Body::Xml(s) => serde_json::Value::String(s),
820 Body::Bytes(b) => {
821 serde_json::Value::String(String::from_utf8_lossy(&b).into_owned())
822 }
823 Body::Stream(s) => serde_json::json!({
824 "_stream": {
825 "origin": s.metadata.origin,
826 "placeholder": true,
827 "hint": "Materialize exchange body with .into_bytes() before aggregation if content needed"
828 }
829 }),
830 _ => serde_json::Value::Null,
832 })
833 .collect();
834 Ok(Exchange::new(Message {
835 headers: Default::default(),
836 body: Body::Json(serde_json::Value::Array(bodies)),
837 }))
838 }
839 AggregationStrategy::Custom(f) => {
840 let mut iter = exchanges.into_iter();
841 let first = iter.next().ok_or_else(|| {
842 CamelError::ProcessorError("Aggregator: empty bucket".to_string())
843 })?;
844 Ok(iter.fold(first, |acc, next| f(acc, next)))
845 }
846 _ => Err(CamelError::ProcessorError(
848 "Aggregator: unsupported aggregation strategy".to_string(),
849 )),
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856 use std::collections::HashMap;
857
858 use camel_api::{
859 StepLifecycle, StepShutdownReason,
860 aggregator::{AggregationStrategy, AggregatorConfig},
861 body::Body,
862 exchange::Exchange,
863 message::Message,
864 };
865 use tokio::sync::mpsc;
866 use tokio_util::sync::CancellationToken;
867 use tower::ServiceExt;
868
869 fn make_exchange(header: &str, value: &str, body: &str) -> Exchange {
870 let mut msg = Message {
871 headers: Default::default(),
872 body: Body::Text(body.to_string()),
873 };
874 msg.headers
875 .insert(header.to_string(), serde_json::json!(value));
876 Exchange::new(msg)
877 }
878
879 fn config_size(n: usize) -> AggregatorConfig {
880 AggregatorConfig::correlate_by("orderId")
881 .complete_when_size(n)
882 .build()
883 .unwrap()
884 }
885
886 fn new_test_svc(config: AggregatorConfig) -> AggregatorService {
887 let (tx, _rx) = mpsc::channel(256);
888 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
889 let cancel = CancellationToken::new();
890 AggregatorService::new(config, tx, registry, cancel)
891 }
892
893 fn new_test_svc_with_registry(
897 config: AggregatorConfig,
898 registry: SharedLanguageRegistry,
899 ) -> AggregatorService {
900 let (tx, _rx) = mpsc::channel(256);
901 let cancel = CancellationToken::new();
902 AggregatorService::new(config, tx, registry, cancel)
903 }
904
905 #[tokio::test]
906 async fn test_pending_exchange_not_yet_complete() {
907 let mut svc = new_test_svc(config_size(3));
908 let ex = make_exchange("orderId", "A", "first");
909 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
910 assert!(matches!(result.input.body, Body::Empty));
911 assert_eq!(
912 result.property(CAMEL_AGGREGATOR_PENDING),
913 Some(&serde_json::json!(true))
914 );
915 }
916
917 #[tokio::test]
918 async fn test_completes_on_size() {
919 let mut svc = new_test_svc(config_size(3));
920 for _ in 0..2 {
921 let ex = make_exchange("orderId", "A", "item");
922 let r = svc.ready().await.unwrap().call(ex).await.unwrap();
923 assert!(matches!(r.input.body, Body::Empty));
924 }
925 let ex = make_exchange("orderId", "A", "last");
926 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
927 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
928 assert_eq!(
929 result.property(CAMEL_AGGREGATED_SIZE),
930 Some(&serde_json::json!(3u64))
931 );
932 }
933
934 #[tokio::test]
935 async fn test_collect_all_produces_json_array() {
936 let mut svc = new_test_svc(config_size(2));
937 svc.ready()
938 .await
939 .unwrap()
940 .call(make_exchange("orderId", "A", "alpha"))
941 .await
942 .unwrap();
943 let result = svc
944 .ready()
945 .await
946 .unwrap()
947 .call(make_exchange("orderId", "A", "beta"))
948 .await
949 .unwrap();
950 let Body::Json(v) = &result.input.body else {
951 panic!("expected Body::Json")
952 };
953 let arr = v.as_array().unwrap();
954 assert_eq!(arr.len(), 2);
955 assert_eq!(arr[0], serde_json::json!("alpha"));
956 assert_eq!(arr[1], serde_json::json!("beta"));
957 }
958
959 #[tokio::test]
960 async fn test_two_keys_independent_buckets() {
961 let mut svc = new_test_svc(config_size(3));
963 svc.ready()
964 .await
965 .unwrap()
966 .call(make_exchange("orderId", "A", "a1"))
967 .await
968 .unwrap();
969 svc.ready()
970 .await
971 .unwrap()
972 .call(make_exchange("orderId", "B", "b1"))
973 .await
974 .unwrap();
975 svc.ready()
976 .await
977 .unwrap()
978 .call(make_exchange("orderId", "A", "a2"))
979 .await
980 .unwrap();
981 let ra = svc
983 .ready()
984 .await
985 .unwrap()
986 .call(make_exchange("orderId", "A", "a3"))
987 .await
988 .unwrap();
989 assert!(matches!(ra.input.body, Body::Json(_)));
991 let rb = svc
993 .ready()
994 .await
995 .unwrap()
996 .call(make_exchange("orderId", "B", "b_check"))
997 .await
998 .unwrap();
999 assert!(matches!(rb.input.body, Body::Empty));
1000 }
1001
1002 #[tokio::test]
1003 async fn test_bucket_resets_after_completion() {
1004 let mut svc = new_test_svc(config_size(2));
1005 svc.ready()
1006 .await
1007 .unwrap()
1008 .call(make_exchange("orderId", "A", "x"))
1009 .await
1010 .unwrap();
1011 svc.ready()
1012 .await
1013 .unwrap()
1014 .call(make_exchange("orderId", "A", "x"))
1015 .await
1016 .unwrap(); let r = svc
1019 .ready()
1020 .await
1021 .unwrap()
1022 .call(make_exchange("orderId", "A", "new"))
1023 .await
1024 .unwrap();
1025 assert!(matches!(r.input.body, Body::Empty)); }
1027
1028 #[tokio::test]
1029 async fn test_completion_size_1_emits_immediately() {
1030 let mut svc = new_test_svc(config_size(1));
1031 let ex = make_exchange("orderId", "A", "solo");
1032 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1033 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1034 }
1035
1036 #[tokio::test]
1037 async fn test_custom_aggregation_strategy() {
1038 use camel_api::aggregator::AggregationFn;
1039 use std::sync::Arc;
1040
1041 let f: AggregationFn = Arc::new(|mut acc: Exchange, next: Exchange| {
1042 let combined = format!(
1043 "{}+{}",
1044 acc.input.body.as_text().unwrap_or(""),
1045 next.input.body.as_text().unwrap_or("")
1046 );
1047 acc.input.body = Body::Text(combined);
1048 acc
1049 });
1050 let config = AggregatorConfig::correlate_by("key")
1051 .complete_when_size(2)
1052 .strategy(AggregationStrategy::Custom(f))
1053 .build()
1054 .unwrap();
1055 let mut svc = new_test_svc(config);
1056 svc.ready()
1057 .await
1058 .unwrap()
1059 .call(make_exchange("key", "X", "hello"))
1060 .await
1061 .unwrap();
1062 let result = svc
1063 .ready()
1064 .await
1065 .unwrap()
1066 .call(make_exchange("key", "X", "world"))
1067 .await
1068 .unwrap();
1069 assert_eq!(result.input.body.as_text(), Some("hello+world"));
1070 }
1071
1072 #[tokio::test]
1073 async fn test_completion_predicate() {
1074 let config = AggregatorConfig::correlate_by("key")
1075 .complete_when(|bucket| {
1076 bucket
1077 .iter()
1078 .any(|e| e.input.body.as_text() == Some("DONE"))
1079 })
1080 .build()
1081 .unwrap();
1082 let mut svc = new_test_svc(config);
1083 svc.ready()
1084 .await
1085 .unwrap()
1086 .call(make_exchange("key", "K", "first"))
1087 .await
1088 .unwrap();
1089 svc.ready()
1090 .await
1091 .unwrap()
1092 .call(make_exchange("key", "K", "second"))
1093 .await
1094 .unwrap();
1095 let result = svc
1096 .ready()
1097 .await
1098 .unwrap()
1099 .call(make_exchange("key", "K", "DONE"))
1100 .await
1101 .unwrap();
1102 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1103 }
1104
1105 #[tokio::test]
1106 async fn test_missing_header_returns_error() {
1107 let mut svc = new_test_svc(config_size(2));
1108 let msg = Message {
1109 headers: Default::default(),
1110 body: Body::Text("no key".into()),
1111 };
1112 let ex = Exchange::new(msg);
1113 let result = svc.ready().await.unwrap().call(ex).await;
1114 assert!(result.is_err());
1115 assert!(matches!(
1116 result.unwrap_err(),
1117 camel_api::CamelError::ProcessorError(_)
1118 ));
1119 }
1120
1121 #[tokio::test]
1122 async fn test_cloned_service_shares_state() {
1123 let svc1 = new_test_svc(config_size(2));
1124 let mut svc2 = svc1.clone();
1125 svc1.clone()
1127 .ready()
1128 .await
1129 .unwrap()
1130 .call(make_exchange("orderId", "A", "from-svc1"))
1131 .await
1132 .unwrap();
1133 let result = svc2
1135 .ready()
1136 .await
1137 .unwrap()
1138 .call(make_exchange("orderId", "A", "from-svc2"))
1139 .await
1140 .unwrap();
1141 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1142 }
1143
1144 #[tokio::test]
1145 async fn test_camel_aggregated_key_property_set() {
1146 let mut svc = new_test_svc(config_size(1));
1147 let ex = make_exchange("orderId", "ORDER-42", "body");
1148 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1149 assert_eq!(
1150 result.property(CAMEL_AGGREGATED_KEY),
1151 Some(&serde_json::json!("ORDER-42"))
1152 );
1153 }
1154
1155 #[tokio::test]
1156 async fn test_aggregator_enforces_max_buckets() {
1157 let config = AggregatorConfig::correlate_by("orderId")
1158 .complete_when_size(2)
1159 .max_buckets(3)
1160 .build()
1161 .unwrap();
1162
1163 let mut svc = new_test_svc(config);
1164
1165 for i in 0..3 {
1167 let ex = make_exchange("orderId", &format!("key-{}", i), "body");
1168 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1169 }
1170
1171 let ex = make_exchange("orderId", "key-4", "body");
1173 let result = svc.ready().await.unwrap().call(ex).await;
1174
1175 assert!(result.is_err(), "Should reject when max buckets reached");
1176 let err = result.unwrap_err().to_string();
1177 assert!(
1178 err.contains("maximum"),
1179 "Error message should contain 'maximum': {}",
1180 err
1181 );
1182 }
1183
1184 #[tokio::test]
1185 async fn test_max_buckets_allows_existing_key() {
1186 let config = AggregatorConfig::correlate_by("orderId")
1187 .complete_when_size(5) .max_buckets(2)
1189 .build()
1190 .unwrap();
1191
1192 let mut svc = new_test_svc(config);
1193
1194 let ex1 = make_exchange("orderId", "key-A", "body1");
1196 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1197 let ex2 = make_exchange("orderId", "key-B", "body2");
1198 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1199
1200 let ex3 = make_exchange("orderId", "key-A", "body3");
1202 let result = svc.ready().await.unwrap().call(ex3).await;
1203 assert!(
1204 result.is_ok(),
1205 "Should allow adding to existing bucket even at max limit"
1206 );
1207 }
1208
1209 #[tokio::test]
1213 async fn test_aggregator_enforces_max_bucket_size() {
1214 let config = AggregatorConfig::correlate_by("orderId")
1215 .complete_when(|_| false)
1217 .max_bucket_size(3)
1218 .build()
1219 .unwrap();
1220
1221 let mut svc = new_test_svc(config);
1222
1223 for _ in 0..3 {
1224 let ex = make_exchange("orderId", "hot-key", "body");
1225 let r = svc.ready().await.unwrap().call(ex).await;
1226 assert!(r.is_ok(), "first 3 exchanges accepted: {r:?}");
1227 }
1228
1229 let ex = make_exchange("orderId", "hot-key", "body");
1230 let result = svc.ready().await.unwrap().call(ex).await;
1231 assert!(result.is_err(), "4th exchange on hot key must be rejected");
1232 let err = result.unwrap_err().to_string();
1233 assert!(
1234 err.contains("maximum"),
1235 "error should mention the per-bucket maximum: {err}"
1236 );
1237
1238 let ex = make_exchange("orderId", "other-key", "body");
1240 let result = svc.ready().await.unwrap().call(ex).await;
1241 assert!(result.is_ok(), "other keys still accepted: {result:?}");
1242 }
1243
1244 #[tokio::test]
1245 async fn test_bucket_ttl_eviction() {
1246 let config = AggregatorConfig::correlate_by("orderId")
1247 .complete_when_size(10) .bucket_ttl(Duration::from_millis(50))
1249 .build()
1250 .unwrap();
1251
1252 let mut svc = new_test_svc(config);
1253
1254 let ex1 = make_exchange("orderId", "key-A", "body1");
1256 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1257
1258 tokio::time::sleep(Duration::from_millis(100)).await;
1260
1261 let ex2 = make_exchange("orderId", "key-B", "body2");
1263 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1264
1265 let ex3 = make_exchange("orderId", "key-A", "body3");
1268 let result = svc.ready().await.unwrap().call(ex3).await;
1269 assert!(result.is_ok(), "Should be able to recreate evicted bucket");
1270 }
1271
1272 #[tokio::test(start_paused = true)]
1273 async fn test_timeout_completes_bucket() {
1274 let config = AggregatorConfig::correlate_by("key")
1275 .complete_on_timeout(Duration::from_millis(100))
1276 .build()
1277 .unwrap();
1278 let mut svc = new_test_svc(config);
1279 let ex = make_exchange("key", "A", "data");
1280 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1281 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_some());
1282
1283 tokio::time::sleep(Duration::from_millis(200)).await;
1284
1285 assert_eq!(
1286 svc.buckets.lock().unwrap().len(),
1287 0,
1288 "bucket should be removed after timeout"
1289 );
1290 }
1291
1292 #[tokio::test(start_paused = true)]
1293 async fn test_timeout_resets_on_new_exchange() {
1294 let config = AggregatorConfig::correlate_by("key")
1295 .complete_on_timeout(Duration::from_millis(150))
1296 .build()
1297 .unwrap();
1298 let mut svc = new_test_svc(config);
1299
1300 let ex1 = make_exchange("key", "A", "first");
1301 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1302
1303 tokio::time::sleep(Duration::from_millis(100)).await;
1304
1305 let ex2 = make_exchange("key", "A", "second");
1306 let _ = svc.ready().await.unwrap().call(ex2).await.unwrap();
1307
1308 tokio::time::sleep(Duration::from_millis(100)).await;
1309
1310 assert_eq!(
1311 svc.buckets.lock().unwrap().len(),
1312 1,
1313 "bucket should still exist — timeout was reset"
1314 );
1315
1316 tokio::time::sleep(Duration::from_millis(100)).await;
1317
1318 assert_eq!(
1319 svc.buckets.lock().unwrap().len(),
1320 0,
1321 "bucket should be gone after timeout fires"
1322 );
1323 }
1324
1325 #[tokio::test]
1326 async fn test_composable_size_and_timeout() {
1327 let config = AggregatorConfig::correlate_by("key")
1328 .complete_on_size_or_timeout(2, Duration::from_millis(200))
1329 .build()
1330 .unwrap();
1331 let mut svc = new_test_svc(config);
1332
1333 let ex1 = make_exchange("key", "A", "first");
1334 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1335 assert!(svc.buckets.lock().unwrap().contains_key("\"A\""));
1336
1337 let ex2 = make_exchange("key", "A", "second");
1338 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1339 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1340 assert_eq!(
1341 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1342 Some(&serde_json::json!("size"))
1343 );
1344 }
1345
1346 #[tokio::test(start_paused = true)]
1347 async fn test_discard_on_timeout() {
1348 let config = AggregatorConfig::correlate_by("key")
1349 .complete_on_timeout(Duration::from_millis(50))
1350 .discard_on_timeout(true)
1351 .build()
1352 .unwrap();
1353 let (tx, mut rx) = mpsc::channel(256);
1354 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1355 let cancel = CancellationToken::new();
1356 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1357
1358 let ex = make_exchange("key", "A", "data");
1359 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1360
1361 tokio::time::sleep(Duration::from_millis(100)).await;
1362
1363 assert!(
1364 rx.try_recv().is_err(),
1365 "no emit expected with discard_on_timeout"
1366 );
1367 assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1368 assert!(
1369 svc.timeout_tasks.lock().unwrap().is_empty(),
1370 "timeout task should be cleaned up"
1371 );
1372 }
1373
1374 #[tokio::test]
1375 async fn test_force_completion_on_stop() {
1376 let config = AggregatorConfig::correlate_by("key")
1377 .complete_when_size(10)
1378 .force_completion_on_stop(true)
1379 .build()
1380 .unwrap();
1381 let (tx, mut rx) = mpsc::channel(256);
1382 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1383 let cancel = CancellationToken::new();
1384 let svc = AggregatorService::new(config, tx, registry, cancel);
1385
1386 let mut call_svc = svc.clone();
1387 let ex = make_exchange("key", "A", "data");
1388 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1389
1390 svc.force_complete_all();
1391
1392 let result = rx.try_recv().expect("should emit on force-complete");
1393 assert!(
1394 result.input.body.as_text().is_some() || matches!(result.input.body, Body::Json(_))
1395 );
1396 assert_eq!(
1397 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1398 Some(&serde_json::json!("stop"))
1399 );
1400 }
1401
1402 #[tokio::test]
1403 async fn test_completion_reason_property_size() {
1404 let config = AggregatorConfig::correlate_by("key")
1405 .complete_when_size(1)
1406 .build()
1407 .unwrap();
1408 let mut svc = new_test_svc(config);
1409 let ex = make_exchange("key", "X", "body");
1410 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1411 assert_eq!(
1412 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1413 Some(&serde_json::json!("size"))
1414 );
1415 }
1416
1417 #[tokio::test]
1418 async fn test_completion_reason_property_predicate() {
1419 let config = AggregatorConfig::correlate_by("key")
1420 .complete_when(|_| true)
1421 .build()
1422 .unwrap();
1423 let mut svc = new_test_svc(config);
1424 let ex = make_exchange("key", "X", "body");
1425 let result = svc.ready().await.unwrap().call(ex).await.unwrap();
1426 assert_eq!(
1427 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1428 Some(&serde_json::json!("predicate"))
1429 );
1430 }
1431
1432 #[tokio::test(start_paused = true)]
1433 async fn test_size_completes_before_timeout() {
1434 let config = AggregatorConfig::correlate_by("key")
1435 .complete_on_size_or_timeout(2, Duration::from_millis(200))
1436 .build()
1437 .unwrap();
1438 let mut svc = new_test_svc(config);
1439
1440 let ex1 = make_exchange("key", "A", "first");
1441 let _ = svc.ready().await.unwrap().call(ex1).await.unwrap();
1442
1443 let ex2 = make_exchange("key", "A", "second");
1444 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1445
1446 assert!(result.property(CAMEL_AGGREGATOR_PENDING).is_none());
1447 assert_eq!(
1448 result.property(CAMEL_AGGREGATED_COMPLETION_REASON),
1449 Some(&serde_json::json!("size"))
1450 );
1451 assert_eq!(svc.buckets.lock().unwrap().len(), 0);
1452
1453 tokio::time::sleep(Duration::from_millis(300)).await;
1454 assert_eq!(
1455 svc.buckets.lock().unwrap().len(),
1456 0,
1457 "no re-fire after timeout"
1458 );
1459 }
1460
1461 #[tokio::test(start_paused = true)]
1462 async fn test_concurrent_timeout_fire_and_new_exchange() {
1463 let config = AggregatorConfig::correlate_by("key")
1464 .complete_on_size_or_timeout(2, Duration::from_millis(100))
1465 .build()
1466 .unwrap();
1467 let (tx, mut rx) = mpsc::channel(256);
1468 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1469 let cancel = CancellationToken::new();
1470 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1471
1472 let ex = make_exchange("key", "A", "data");
1473 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1474
1475 tokio::time::sleep(Duration::from_millis(150)).await;
1477
1478 let ex2 = make_exchange("key", "A", "data2");
1480 let result = svc.ready().await.unwrap().call(ex2).await.unwrap();
1481 assert!(
1482 result.property(CAMEL_AGGREGATOR_PENDING).is_some(),
1483 "should be pending in new bucket"
1484 );
1485
1486 let mut late_count = 0;
1488 while rx.try_recv().is_ok() {
1489 late_count += 1;
1490 }
1491 assert_eq!(
1492 late_count, 1,
1493 "exactly 1 late emit from the timed-out bucket"
1494 );
1495 }
1496
1497 #[tokio::test(start_paused = true)]
1498 async fn test_late_channel_full_drops_with_warning() {
1499 let config = AggregatorConfig::correlate_by("key")
1500 .complete_on_timeout(Duration::from_millis(50))
1501 .build()
1502 .unwrap();
1503 let (tx, mut rx) = mpsc::channel(1);
1504 rx.close();
1505 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1506 let cancel = CancellationToken::new();
1507 let mut svc = AggregatorService::new(config, tx, registry, cancel);
1508
1509 let ex = make_exchange("key", "A", "data");
1510 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1511
1512 tokio::time::sleep(Duration::from_millis(100)).await;
1513 assert_eq!(
1514 svc.buckets.lock().unwrap().len(),
1515 0,
1516 "bucket removed despite channel closed"
1517 );
1518 }
1519
1520 #[tokio::test]
1526 async fn test_da3_force_complete_all_drops_on_saturated_channel() {
1527 let config = AggregatorConfig::correlate_by("k")
1528 .complete_when_size(10)
1529 .force_completion_on_stop(true)
1530 .build()
1531 .unwrap();
1532 let (late_tx, mut late_rx) = mpsc::channel::<Exchange>(1);
1534 late_tx
1538 .try_send(make_exchange("k", "99", "dummy"))
1539 .expect("pre-fill succeeds");
1540
1541 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1542 let cancel = CancellationToken::new();
1543 let mut svc = AggregatorService::new(config, late_tx, registry, cancel);
1544
1545 for v in ["1", "2", "3"] {
1548 let ex = make_exchange("k", v, "body");
1549 let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
1550 }
1551 assert_eq!(svc.buckets.lock().unwrap().len(), 3);
1552
1553 svc.force_complete_all();
1555
1556 let pre_fill = late_rx
1558 .try_recv()
1559 .expect("pre-fill should still be in channel");
1560 assert_eq!(
1561 pre_fill.input.headers.get("k"),
1562 Some(&serde_json::json!("99"))
1563 );
1564
1565 assert!(matches!(
1568 late_rx.try_recv(),
1569 Err(mpsc::error::TryRecvError::Empty)
1570 ));
1571
1572 assert!(svc.buckets.lock().unwrap().is_empty());
1574 }
1575
1576 #[tokio::test]
1577 async fn test_aggregate_stream_bodies_creates_valid_json() {
1578 use bytes::Bytes;
1579 use camel_api::{Body, StreamBody, StreamMetadata};
1580 use futures::stream;
1581 use tokio::sync::Mutex;
1582
1583 let chunks = vec![Ok(Bytes::from("test"))];
1584 let stream_body = StreamBody {
1585 stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1586 metadata: StreamMetadata {
1587 origin: Some("file:///test.txt".to_string()),
1588 ..Default::default()
1589 },
1590 };
1591
1592 let ex1 = Exchange::new(Message {
1593 headers: Default::default(),
1594 body: Body::Stream(stream_body),
1595 });
1596
1597 let exchanges = vec![ex1];
1598 let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1599
1600 let exchange = result.expect("Expected Ok result");
1601 assert!(
1602 matches!(exchange.input.body, Body::Json(_)),
1603 "Expected Json body"
1604 );
1605
1606 if let Body::Json(value) = exchange.input.body {
1607 let json_str = serde_json::to_string(&value).unwrap();
1608 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1609
1610 assert!(parsed.is_array(), "Result should be an array");
1611 let arr = parsed.as_array().unwrap();
1612 assert!(arr[0].is_object(), "First element should be an object");
1613 assert!(
1614 arr[0]["_stream"].is_object(),
1615 "Should contain _stream object"
1616 );
1617 assert_eq!(arr[0]["_stream"]["origin"], "file:///test.txt");
1618 assert_eq!(
1619 arr[0]["_stream"]["placeholder"], true,
1620 "placeholder flag should be true"
1621 );
1622 }
1623 }
1624
1625 #[tokio::test]
1626 async fn test_aggregate_stream_bodies_with_none_origin() {
1627 use bytes::Bytes;
1628 use camel_api::{Body, StreamBody, StreamMetadata};
1629 use futures::stream;
1630 use tokio::sync::Mutex;
1631
1632 let chunks = vec![Ok(Bytes::from("test"))];
1633 let stream_body = StreamBody {
1634 stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
1635 metadata: StreamMetadata {
1636 origin: None,
1637 ..Default::default()
1638 },
1639 };
1640
1641 let ex1 = Exchange::new(Message {
1642 headers: Default::default(),
1643 body: Body::Stream(stream_body),
1644 });
1645
1646 let exchanges = vec![ex1];
1647 let result = aggregate(exchanges, &AggregationStrategy::CollectAll);
1648
1649 let exchange = result.expect("Expected Ok result");
1650 assert!(
1651 matches!(exchange.input.body, Body::Json(_)),
1652 "Expected Json body"
1653 );
1654
1655 if let Body::Json(value) = exchange.input.body {
1656 let json_str = serde_json::to_string(&value).unwrap();
1657 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1658
1659 assert!(parsed.is_array(), "Result should be an array");
1660 let arr = parsed.as_array().unwrap();
1661 assert!(arr[0].is_object(), "First element should be an object");
1662 assert!(
1663 arr[0]["_stream"].is_object(),
1664 "Should contain _stream object"
1665 );
1666 assert_eq!(
1667 arr[0]["_stream"]["origin"],
1668 serde_json::Value::Null,
1669 "origin should be null when None"
1670 );
1671 assert_eq!(
1672 arr[0]["_stream"]["placeholder"], true,
1673 "placeholder flag should be true"
1674 );
1675 }
1676 }
1677
1678 #[tokio::test]
1679 async fn timeout_completion_clears_handle_from_map() {
1680 let config = AggregatorConfig::correlate_by("key")
1685 .complete_on_timeout(Duration::from_millis(50))
1686 .build()
1687 .unwrap();
1688 let (tx, _rx) = mpsc::channel(256);
1689 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1690 let cancel = CancellationToken::new();
1691 let svc = AggregatorService::new(config, tx, registry, cancel);
1692
1693 let mut call_svc = svc.clone();
1695 let ex = make_exchange("key", "A", "data");
1696 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1697 assert!(
1698 !svc.timeout_handles.lock().unwrap().is_empty(),
1699 "handle should exist while timeout pending"
1700 );
1701
1702 tokio::time::sleep(Duration::from_millis(200)).await;
1704
1705 assert!(
1706 svc.timeout_handles.lock().unwrap().is_empty(),
1707 "handle should be cleared from map after natural timeout completion (was leak)"
1708 );
1709 }
1710
1711 #[tokio::test]
1712 async fn aggregator_shutdown_via_trait_dispatch() {
1713 let config = AggregatorConfig::correlate_by("key")
1716 .complete_when_size(10)
1717 .build()
1718 .unwrap();
1719 let (tx, _rx) = mpsc::channel(256);
1720 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1721 let cancel = CancellationToken::new();
1722 let svc = AggregatorService::new(config, tx, registry, cancel);
1723
1724 let step: Arc<dyn StepLifecycle> = Arc::new(svc);
1725 step.shutdown(StepShutdownReason::RouteStop)
1726 .await
1727 .expect("first shutdown should succeed");
1728 step.shutdown(StepShutdownReason::RouteStop)
1729 .await
1730 .expect("second shutdown (idempotent) should succeed");
1731 }
1732
1733 #[tokio::test(start_paused = true)]
1734 async fn test_shutdown_awaits_timeout_handles() {
1735 let config = AggregatorConfig::correlate_by("key")
1736 .complete_on_timeout(Duration::from_millis(100))
1737 .build()
1738 .unwrap();
1739 let (tx, _rx) = mpsc::channel(256);
1740 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1741 let cancel = CancellationToken::new();
1742 let svc = AggregatorService::new(config, tx, registry, cancel);
1743
1744 let mut call_svc = svc.clone();
1746 let ex = make_exchange("key", "A", "data");
1747 let _ = call_svc.ready().await.unwrap().call(ex).await.unwrap();
1748
1749 assert!(
1751 !svc.timeout_handles.lock().unwrap().is_empty(),
1752 "should have a timeout handle"
1753 );
1754
1755 svc.shutdown_inner().await;
1758
1759 assert!(
1760 svc.timeout_handles.lock().unwrap().is_empty(),
1761 "all handles should be cleaned up after shutdown"
1762 );
1763 }
1764
1765 #[tokio::test]
1772 async fn test_unique_key_flood_stays_bounded_by_default() {
1773 let config = AggregatorConfig::correlate_by("orderId")
1775 .complete_when_size(1_000_000) .build()
1777 .unwrap();
1778 let mut svc = new_test_svc(config);
1779
1780 for i in 0..10_000usize {
1783 let ex = make_exchange("orderId", &format!("key-{i}"), "body");
1784 let result = svc.ready().await.unwrap().call(ex).await;
1785 assert!(result.is_ok(), "key {i} should be accepted under the cap");
1786 }
1787 let ex = make_exchange("orderId", "key-10001", "body");
1788 let result = svc.ready().await.unwrap().call(ex).await;
1789 assert!(
1790 result.is_err(),
1791 "10_001st unique key must be rejected by the max_buckets cap"
1792 );
1793 let err = result.unwrap_err().to_string();
1794 assert!(
1795 err.contains("maximum") || err.contains("max"),
1796 "error should mention cap: {err}"
1797 );
1798 }
1799
1800 #[tokio::test]
1806 async fn test_background_sweep_spawns_on_first_poll_not_construction() {
1807 let config = AggregatorConfig::correlate_by("key")
1808 .complete_when_size(10_000)
1809 .bucket_ttl(Duration::from_millis(50))
1810 .build()
1811 .unwrap();
1812 let cancel = CancellationToken::new();
1813 let (tx, _rx) = mpsc::channel(8);
1814 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1815 let mut svc = AggregatorService::new(config, tx, registry, cancel.clone());
1816
1817 assert!(
1819 svc.sweep_handle
1820 .lock()
1821 .unwrap_or_else(|e| e.into_inner())
1822 .is_none(),
1823 "sweep must NOT be spawned at construction (runtime-free new)"
1824 );
1825
1826 let _ = svc.ready().await.unwrap();
1829 let sweep_present = svc
1830 .sweep_handle
1831 .lock()
1832 .unwrap_or_else(|e| e.into_inner())
1833 .is_some();
1834 assert!(
1835 sweep_present,
1836 "sweep handle should be Some after first poll when bucket_ttl is set"
1837 );
1838
1839 cancel.cancel();
1841 tokio::time::sleep(Duration::from_millis(50)).await;
1843 }
1844
1845 struct QueueDepthRecorder(Mutex<Vec<(String, usize)>>);
1848 impl MetricsCollector for QueueDepthRecorder {
1849 fn record_exchange_duration(&self, _: &str, _: std::time::Duration) {}
1850 fn increment_errors(&self, _: &str, _: &str) {}
1851 fn increment_exchanges(&self, _: &str) {}
1852 fn set_queue_depth(&self, queue: &str, depth: usize) {
1853 self.0
1854 .lock()
1855 .unwrap_or_else(|e| e.into_inner())
1856 .push((queue.to_string(), depth));
1857 }
1858 fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
1859 }
1860
1861 async fn await_depth_sample(
1864 recorder: &QueueDepthRecorder,
1865 label: &str,
1866 pred: impl Fn(usize) -> bool,
1867 ) {
1868 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1869 loop {
1870 let matched = recorder
1871 .0
1872 .lock()
1873 .unwrap_or_else(|e| e.into_inner())
1874 .iter()
1875 .any(|(q, d)| q == label && pred(*d));
1876 if matched {
1877 return;
1878 }
1879 assert!(
1880 std::time::Instant::now() < deadline,
1881 "no queue-depth sample for '{label}' matched within 2s"
1882 );
1883 tokio::time::sleep(Duration::from_millis(20)).await;
1884 }
1885 }
1886
1887 #[tokio::test]
1892 async fn test_sweep_reports_queue_depth() {
1893 let config = AggregatorConfig::correlate_by("orderId")
1894 .complete_when_size(3)
1895 .bucket_ttl(Duration::from_millis(100))
1899 .build()
1900 .unwrap();
1901 let cancel = CancellationToken::new();
1902 let (tx, _rx) = mpsc::channel(8);
1903 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1904 let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
1905 let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
1906 Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
1907 "aggregator:t",
1908 );
1909
1910 let _ = svc.ready().await.unwrap();
1912
1913 let ex = make_exchange("orderId", "g1", "partial");
1915 let _ = svc.ready().await.unwrap().call(ex).await;
1916
1917 await_depth_sample(&recorder, "aggregator:t", |d| d > 0).await;
1918
1919 let ex = make_exchange("orderId", "g1", "b");
1921 let _ = svc.ready().await.unwrap().call(ex).await;
1922 let ex = make_exchange("orderId", "g1", "c");
1923 let _ = svc.ready().await.unwrap().call(ex).await;
1924
1925 await_depth_sample(&recorder, "aggregator:t", |d| d == 0).await;
1926
1927 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
1928 }
1929
1930 #[tokio::test]
1935 async fn test_no_ttl_aggregator_reports_queue_depth() {
1936 use camel_api::aggregator::CorrelationStrategy;
1937
1938 let config = AggregatorConfig {
1939 header_name: "orderId".into(),
1940 completion: CompletionMode::Single(CompletionCondition::Size(3)),
1941 correlation: CorrelationStrategy::HeaderName("orderId".into()),
1942 strategy: AggregationStrategy::CollectAll,
1943 max_buckets: Some(100),
1944 max_bucket_size: Some(100),
1945 bucket_ttl: None,
1946 force_completion_on_stop: false,
1947 discard_on_timeout: false,
1948 max_timeout_tasks: 64,
1949 };
1950 let cancel = CancellationToken::new();
1951 let (tx, _rx) = mpsc::channel(8);
1952 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1953 let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
1954 let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
1955 Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
1956 "aggregator:nottl",
1957 );
1958
1959 let _ = svc.ready().await.unwrap();
1960 assert!(
1961 svc.sweep_handle
1962 .lock()
1963 .unwrap_or_else(|e| e.into_inner())
1964 .is_some(),
1965 "metrics-only config (bucket_ttl = None) must still spawn the sweep"
1966 );
1967
1968 let ex = make_exchange("orderId", "g1", "partial");
1970 let _ = svc.ready().await.unwrap().call(ex).await;
1971 await_depth_sample(&recorder, "aggregator:nottl", |d| d > 0).await;
1972
1973 let ex = make_exchange("orderId", "g1", "b");
1975 let _ = svc.ready().await.unwrap().call(ex).await;
1976 let ex = make_exchange("orderId", "g1", "c");
1977 let _ = svc.ready().await.unwrap().call(ex).await;
1978 await_depth_sample(&recorder, "aggregator:nottl", |d| d == 0).await;
1979
1980 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
1981 }
1982
1983 #[tokio::test]
1988 async fn test_transient_clone_drop_keeps_sweep_sampling() {
1989 let config = AggregatorConfig::correlate_by("orderId")
1990 .complete_when_size(3)
1991 .bucket_ttl(Duration::from_millis(100))
1992 .build()
1993 .unwrap();
1994 let cancel = CancellationToken::new();
1995 let (tx, _rx) = mpsc::channel(8);
1996 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
1997 let recorder = Arc::new(QueueDepthRecorder(Mutex::new(Vec::new())));
1998 let mut svc = AggregatorService::new(config, tx, registry, cancel).with_queue_metrics(
1999 Arc::clone(&recorder) as Arc<dyn MetricsCollector>,
2000 "aggregator:clone",
2001 );
2002
2003 let _ = svc.ready().await.unwrap();
2004
2005 drop(svc.clone());
2007 assert!(
2008 svc.sweep_handle
2009 .lock()
2010 .unwrap_or_else(|e| e.into_inner())
2011 .is_some(),
2012 "transient clone drop must not abort the shared sweep"
2013 );
2014
2015 let ex = make_exchange("orderId", "g1", "partial");
2017 let _ = svc.ready().await.unwrap().call(ex).await;
2018 await_depth_sample(&recorder, "aggregator:clone", |d| d > 0).await;
2019
2020 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2021 }
2022
2023 #[tokio::test]
2026 async fn test_aggregator_timeout_task_cap_no_panic_under_flood() {
2027 use camel_api::aggregator::CorrelationStrategy;
2030
2031 let config = AggregatorConfig {
2032 header_name: "k".into(),
2033 completion: CompletionMode::Any(vec![
2034 CompletionCondition::Size(999),
2035 CompletionCondition::Timeout(Duration::from_secs(30)),
2036 ]),
2037 correlation: CorrelationStrategy::HeaderName("k".into()),
2038 strategy: AggregationStrategy::CollectAll,
2039 max_buckets: Some(50),
2040 max_bucket_size: Some(50),
2041 bucket_ttl: Some(Duration::from_secs(30)),
2042 force_completion_on_stop: false,
2043 discard_on_timeout: false,
2044 max_timeout_tasks: 2,
2045 };
2046 let (late_tx, mut late_rx) = mpsc::channel(64);
2047 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2048 let cancel = CancellationToken::new();
2049 let svc = AggregatorService::new(config, late_tx, registry, cancel);
2050
2051 for i in 0..20u64 {
2053 let mut ex = Exchange::new(Message {
2054 headers: HashMap::from([("k".to_string(), serde_json::json!(i))]),
2055 body: Body::Text(i.to_string()),
2056 });
2057 ex.input
2058 .headers
2059 .insert("k".to_string(), serde_json::json!(i));
2060 let outcome = tokio::time::timeout(Duration::from_secs(2), async {
2061 let mut s = svc.clone();
2062 use tower::ServiceExt;
2063 s.ready().await.unwrap().call(ex).await
2064 })
2065 .await;
2066 assert!(outcome.is_ok(), "call {} hung/panicked under task cap", i);
2067 let res = outcome.unwrap().unwrap();
2069 assert_eq!(
2070 res.properties
2071 .get(CAMEL_AGGREGATOR_PENDING)
2072 .and_then(|v| v.as_bool()),
2073 Some(true),
2074 "exchange {} should be pending",
2075 i
2076 );
2077 }
2078 let _ = late_rx.try_recv();
2080 }
2081
2082 #[tokio::test]
2083 async fn evaluate_completion_predicate_or_combines_any() {
2084 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2085 use camel_language_api::Language;
2086 use std::collections::HashMap;
2087
2088 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2090 registry.lock().unwrap().insert(
2091 "simple".to_string(),
2092 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2093 );
2094
2095 let incoming = make_exchange("k", "X", "hello");
2096
2097 let mode = CompletionMode::Any(vec![
2099 CompletionCondition::PredicateExpr {
2100 expr: "${body} == 'NOPE'".to_string(),
2101 language: "simple".to_string(),
2102 },
2103 CompletionCondition::PredicateExpr {
2104 expr: "${body} == 'hello'".to_string(),
2105 language: "simple".to_string(),
2106 },
2107 ]);
2108
2109 let satisfied = evaluate_completion_predicate(&mode, &incoming, ®istry)
2110 .await
2111 .expect("eval must succeed");
2112 assert!(satisfied, "second predicate matches → OR true");
2113 }
2114
2115 #[tokio::test]
2116 async fn evaluate_completion_predicate_no_predicate_skips_registry() {
2117 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2118 use std::collections::HashMap;
2119
2120 let mode = CompletionMode::Single(CompletionCondition::Size(3));
2124 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2125 let incoming = make_exchange("k", "X", "hello");
2126 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
2127 assert!(result.is_ok(), "fast path must not error: {:?}", result);
2128 assert!(!result.unwrap(), "no predicate → not satisfied");
2129 }
2130
2131 #[tokio::test]
2132 async fn evaluate_completion_predicate_unregistered_language_errors() {
2133 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2134
2135 let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
2136 expr: "${body} == 'DONE'".to_string(),
2137 language: "nonexistent-lang".to_string(),
2138 });
2139 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2140 let incoming = make_exchange("k", "X", "hello");
2141 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
2142 assert!(result.is_err(), "unregistered language must error");
2143 }
2144
2145 #[tokio::test]
2146 async fn evaluate_completion_predicate_all_miss_returns_false() {
2147 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2148 use camel_language_api::Language;
2149 use std::collections::HashMap;
2150
2151 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2152 registry.lock().unwrap().insert(
2153 "simple".to_string(),
2154 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2155 );
2156
2157 let mode = CompletionMode::Single(CompletionCondition::PredicateExpr {
2158 expr: "${body} == 'NOPE'".to_string(),
2159 language: "simple".to_string(),
2160 });
2161 let incoming = make_exchange("k", "X", "hello");
2162 let result = evaluate_completion_predicate(&mode, &incoming, ®istry).await;
2163 assert!(result.is_ok(), "eval must succeed: {:?}", result);
2164 assert!(!result.unwrap(), "predicate does not match");
2165 }
2166
2167 #[tokio::test]
2168 async fn completion_predicate_expr_completes_on_match() {
2169 use camel_api::aggregator::{CompletionCondition, CompletionMode};
2170 use camel_language_api::Language;
2171 use std::collections::HashMap;
2172
2173 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2175 registry.lock().unwrap().insert(
2176 "simple".to_string(),
2177 Arc::new(camel_language_simple::SimpleLanguage::new()) as Arc<dyn Language>,
2178 );
2179
2180 let mut config = AggregatorConfig::correlate_by("key")
2185 .complete_when_size(999) .build()
2187 .expect("config builds");
2188 config.completion = CompletionMode::Single(CompletionCondition::PredicateExpr {
2189 expr: "${body} == 'DONE'".to_string(),
2190 language: "simple".to_string(),
2191 });
2192
2193 let mut svc = new_test_svc_with_registry(config, registry);
2194
2195 let r = svc
2197 .ready()
2198 .await
2199 .unwrap()
2200 .call(make_exchange("key", "K", "first"))
2201 .await
2202 .unwrap();
2203 assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_some());
2204
2205 let r = svc
2207 .ready()
2208 .await
2209 .unwrap()
2210 .call(make_exchange("key", "K", "DONE"))
2211 .await
2212 .unwrap();
2213 assert!(r.property(CAMEL_AGGREGATOR_PENDING).is_none());
2214 assert_eq!(
2215 r.property(CAMEL_AGGREGATED_COMPLETION_REASON),
2216 Some(&serde_json::json!("predicate"))
2217 );
2218 }
2219
2220 #[tokio::test]
2232 async fn test_da1_strategy_receives_two_exchanges_first_message_preserved() {
2233 use camel_api::aggregator::{AggregationFn, AggregationStrategy};
2234 use std::sync::Arc;
2235
2236 let recorded: Arc<std::sync::Mutex<Vec<(String, String)>>> =
2237 Arc::new(std::sync::Mutex::new(Vec::new()));
2238 let recorded_for_closure = Arc::clone(&recorded);
2239
2240 let f: AggregationFn = Arc::new(move |old: Exchange, new: Exchange| {
2241 let old_body = old.input.body.as_text().unwrap_or("").to_string();
2242 let new_body = new.input.body.as_text().unwrap_or("").to_string();
2243 recorded_for_closure
2244 .lock()
2245 .expect("recorded mutex poisoned")
2246 .push((old_body, new_body));
2247 new
2248 });
2249
2250 let config = AggregatorConfig::correlate_by("k")
2251 .complete_when_size(2)
2252 .strategy(AggregationStrategy::Custom(f))
2253 .build()
2254 .unwrap();
2255 let mut svc = new_test_svc(config);
2256
2257 let first = svc
2259 .ready()
2260 .await
2261 .unwrap()
2262 .call(make_exchange("k", "1", "A"))
2263 .await
2264 .unwrap();
2265 assert!(
2266 first.property(CAMEL_AGGREGATOR_PENDING).is_some(),
2267 "first message must leave the bucket pending (size < 2)"
2268 );
2269 assert!(
2270 recorded.lock().expect("recorded mutex poisoned").is_empty(),
2271 "strategy must NOT be invoked on the first message of a bucket"
2272 );
2273
2274 let _completed = svc
2277 .ready()
2278 .await
2279 .unwrap()
2280 .call(make_exchange("k", "1", "B"))
2281 .await
2282 .unwrap();
2283
2284 let recorded = recorded.lock().expect("recorded mutex poisoned");
2285 assert_eq!(
2286 recorded.len(),
2287 1,
2288 "strategy must be invoked exactly once across the two-message bucket, got {recorded:?}"
2289 );
2290 assert_eq!(
2291 recorded[0],
2292 ("A".to_string(), "B".to_string()),
2293 "strategy must observe the first message as `old` and the second as `new`, \
2294 with both bodies preserved unchanged"
2295 );
2296 }
2297
2298 #[tokio::test]
2301 async fn sweep_shutdown_cancels_task() {
2302 let config = AggregatorConfig::correlate_by("key")
2303 .complete_when_size(10)
2304 .bucket_ttl(Duration::from_millis(100))
2305 .build()
2306 .unwrap();
2307 let (tx, _rx) = mpsc::channel(256);
2308 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2309 let cancel = CancellationToken::new();
2310 let mut svc = AggregatorService::new(config, tx, registry, cancel);
2311
2312 let _ = svc.ready().await.unwrap();
2313
2314 assert!(
2315 svc.sweep_handle
2316 .lock()
2317 .unwrap_or_else(|e| e.into_inner())
2318 .is_some(),
2319 "sweep handle should be Some after poll_ready"
2320 );
2321
2322 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2323
2324 assert!(
2325 svc.sweep_handle
2326 .lock()
2327 .unwrap_or_else(|e| e.into_inner())
2328 .is_none(),
2329 "sweep handle should be None after shutdown (taken + aborted)"
2330 );
2331 assert!(
2332 svc.sweep_cancel
2333 .lock()
2334 .unwrap_or_else(|e| e.into_inner())
2335 .is_cancelled(),
2336 "sweep_cancel token should be cancelled after shutdown"
2337 );
2338
2339 tokio::time::sleep(Duration::from_millis(50)).await;
2340 }
2341
2342 #[tokio::test]
2343 async fn sweep_start_respawns_after_shutdown() {
2344 let config = AggregatorConfig::correlate_by("key")
2345 .complete_when_size(10)
2346 .bucket_ttl(Duration::from_millis(100))
2347 .build()
2348 .unwrap();
2349 let (tx, _rx) = mpsc::channel(256);
2350 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2351 let cancel = CancellationToken::new();
2352 let mut svc = AggregatorService::new(config, tx, registry, cancel);
2353
2354 let _ = svc.ready().await.unwrap();
2355 svc.shutdown(StepShutdownReason::RouteStop).await.unwrap();
2356
2357 svc.start().await.unwrap();
2358 let _ = svc.ready().await.unwrap();
2359
2360 assert!(
2361 svc.sweep_handle
2362 .lock()
2363 .unwrap_or_else(|e| e.into_inner())
2364 .is_some(),
2365 "sweep handle should be Some after start + poll_ready"
2366 );
2367 assert!(
2368 !svc.sweep_cancel
2369 .lock()
2370 .unwrap_or_else(|e| e.into_inner())
2371 .is_cancelled(),
2372 "sweep_cancel should be a fresh uncancelled token after start"
2373 );
2374 }
2375
2376 #[tokio::test]
2377 async fn sweep_shutdown_hotswap_cancels_task() {
2378 let config = AggregatorConfig::correlate_by("key")
2379 .complete_when_size(10)
2380 .bucket_ttl(Duration::from_millis(100))
2381 .build()
2382 .unwrap();
2383 let (tx, _rx) = mpsc::channel(256);
2384 let registry: SharedLanguageRegistry = Arc::new(std::sync::Mutex::new(HashMap::new()));
2385 let cancel = CancellationToken::new();
2386 let mut svc = AggregatorService::new(config, tx, registry, cancel);
2387
2388 let _ = svc.ready().await.unwrap();
2389
2390 assert!(
2391 svc.sweep_handle
2392 .lock()
2393 .unwrap_or_else(|e| e.into_inner())
2394 .is_some(),
2395 "sweep handle should be Some after poll_ready"
2396 );
2397
2398 svc.shutdown(StepShutdownReason::HotSwap).await.unwrap();
2399
2400 assert!(
2401 svc.sweep_handle
2402 .lock()
2403 .unwrap_or_else(|e| e.into_inner())
2404 .is_none(),
2405 "sweep handle should be None after HotSwap shutdown"
2406 );
2407 assert!(
2408 svc.sweep_cancel
2409 .lock()
2410 .unwrap_or_else(|e| e.into_inner())
2411 .is_cancelled(),
2412 "sweep_cancel token should be cancelled after HotSwap shutdown"
2413 );
2414
2415 tokio::time::sleep(Duration::from_millis(50)).await;
2416 }
2417}