Skip to main content

camel_processor/resequencer/
batch.rs

1//! Batch resequencing policy — buffer per correlation key, window completion,
2//! sort by expression, burst-emit in order.
3
4use std::collections::HashMap;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Mutex, Weak};
7use std::time::Duration;
8
9use async_trait::async_trait;
10use camel_api::exchange::Exchange;
11use camel_api::resequencer::BatchCompletion;
12use camel_api::value::cmp_values;
13use camel_language_api::Expression;
14use tokio::sync::mpsc;
15use tokio::task::JoinHandle;
16use tokio_util::sync::CancellationToken;
17
18use super::ResequencePolicy;
19
20/// Per-correlation-key bucket holding pending exchanges.
21#[derive(Default)]
22struct Bucket {
23    exchanges: Vec<Exchange>,
24}
25
26/// Batch resequencing policy.
27///
28/// Buffers exchanges per correlation key. Completion is triggered by
29/// window (size and/or timeout). On completion, sorts buffered exchanges
30/// by `sort_expr` and returns them as a burst. Timeout tasks hold a
31/// `Weak<Self>` reference obtained via `Arc::new_cyclic`.
32pub struct BatchPolicy {
33    correlation_expr: Arc<dyn Expression>,
34    sort_expr: Arc<dyn Expression>,
35    completion: BatchCompletion,
36
37    /// Weak self-reference so timeout tasks can upgrade to `Arc<Self>`.
38    weak_self: Weak<Self>,
39
40    /// Per-correlation-key buckets (exchanges pending completion).
41    buckets: Mutex<HashMap<String, Bucket>>,
42
43    /// Timeout cancellation tokens, keyed by correlation key.
44    timeout_tokens: Mutex<HashMap<String, CancellationToken>>,
45
46    /// Timeout task handles, keyed by correlation key.
47    timeout_handles: Mutex<HashMap<String, JoinHandle<()>>>,
48
49    /// Channel to the post-driver for timeout-triggered emissions.
50    /// Set by `ResequencerService` after channel creation.
51    driver_tx: Mutex<Option<mpsc::Sender<Exchange>>>,
52
53    /// Shutdown guard — timeout tasks check this before sending
54    /// to avoid racing with post-driver channel close (M7).
55    shutdown_started: AtomicBool,
56}
57
58impl BatchPolicy {
59    /// Create a new `Arc<BatchPolicy>` using `Arc::new_cyclic` so the
60    /// policy holds a `Weak<Self>` for timeout task spawning.
61    pub fn new_cyclic(
62        correlation_expr: Arc<dyn Expression>,
63        sort_expr: Arc<dyn Expression>,
64        completion: BatchCompletion,
65    ) -> Arc<Self> {
66        Arc::new_cyclic(|weak| Self {
67            correlation_expr,
68            sort_expr,
69            completion,
70            weak_self: weak.clone(),
71            buckets: Mutex::new(HashMap::new()),
72            timeout_tokens: Mutex::new(HashMap::new()),
73            timeout_handles: Mutex::new(HashMap::new()),
74            driver_tx: Mutex::new(None),
75            shutdown_started: AtomicBool::new(false),
76        })
77    }
78
79    /// Set the driver channel (via `set_timeout_tx` trait method).
80    /// Called by `ResequencerService` after channel creation.
81    fn set_driver_tx(&self, tx: mpsc::Sender<Exchange>) {
82        let mut guard = self.driver_tx.lock().unwrap_or_else(|e| e.into_inner());
83        *guard = Some(tx);
84    }
85
86    /// Evaluate the correlation expression against an exchange.
87    async fn eval_key(&self, exchange: &Exchange) -> Result<String, String> {
88        self.correlation_expr
89            .evaluate(exchange)
90            .await
91            // M4: avoid double-quoting for string values — use as_str() for
92            // strings, fall back to to_string() for other types.
93            .map(|v| match v {
94                serde_json::Value::String(s) => s,
95                other => other.to_string(),
96            })
97            .map_err(|e| format!("correlation expression evaluation failed: {e}"))
98    }
99
100    /// Drain a bucket, sort by sort_expr, return sorted Vec.
101    async fn drain_and_sort(&self, mut bucket: Bucket) -> Vec<Exchange> {
102        let mut indexed: Vec<(serde_json::Value, Exchange)> = Vec::new();
103        for ex in bucket.exchanges.drain(..) {
104            let val = self
105                .sort_expr
106                .evaluate(&ex)
107                .await
108                .unwrap_or(serde_json::Value::Null);
109            indexed.push((val, ex));
110        }
111        indexed.sort_by(|a, b| cmp_values(&a.0, &b.0));
112        indexed.into_iter().map(|(_, ex)| ex).collect()
113    }
114
115    /// Check if a bucket count satisfies the size-based completion condition.
116    fn is_complete_by_size(&self, count: usize) -> bool {
117        match self.completion {
118            BatchCompletion::Size(s) => count >= s,
119            BatchCompletion::SizeOrTimeout(s, _) => count >= s,
120            // Timeout and any future variant are not size-complete.
121            _ => false,
122        }
123    }
124
125    /// Whether this completion variant needs timeout tasks spawned.
126    fn needs_timeout(&self) -> bool {
127        matches!(
128            self.completion,
129            BatchCompletion::Timeout(_) | BatchCompletion::SizeOrTimeout(..)
130        )
131    }
132
133    /// Take a bucket by key. Returns `Some(Bucket)` if it existed.
134    fn take_bucket(&self, key: &str) -> Option<Bucket> {
135        let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
136        buckets.remove(key)
137    }
138
139    /// Cancel and remove timeout task for a key.
140    fn cancel_timeout(&self, key: &str) {
141        {
142            let mut tokens = self
143                .timeout_tokens
144                .lock()
145                .unwrap_or_else(|e| e.into_inner());
146            if let Some(token) = tokens.remove(key) {
147                token.cancel();
148            }
149        }
150        {
151            let mut handles = self
152                .timeout_handles
153                .lock()
154                .unwrap_or_else(|e| e.into_inner());
155            handles.remove(key);
156        }
157    }
158
159    /// Spawn a timeout task for the given key.
160    /// Must be called from a method that has access to `&self` (which has the `weak_self`).
161    fn spawn_timeout_task(&self, key: String, timeout_ms: u64) {
162        let cancel = CancellationToken::new();
163        let cancel_clone = cancel.clone();
164
165        // Store the cancellation token
166        {
167            let mut tokens = self
168                .timeout_tokens
169                .lock()
170                .unwrap_or_else(|e| e.into_inner());
171            tokens.insert(key.clone(), cancel);
172        }
173
174        let weak = self.weak_self.clone();
175        let key_clone = key.clone();
176        let driver_tx_opt = {
177            let guard = self.driver_tx.lock().unwrap_or_else(|e| e.into_inner());
178            guard.clone()
179        };
180
181        let handle = tokio::spawn(async move {
182            let timeout = Duration::from_millis(timeout_ms);
183
184            tokio::select! {
185                _ = tokio::time::sleep(timeout) => {
186                    if cancel_clone.is_cancelled() {
187                        return;
188                    }
189                }
190                _ = cancel_clone.cancelled() => {
191                    return;
192                }
193            }
194
195            // Upgrade the weak reference — policy may have been dropped (shutdown)
196            let Some(policy) = weak.upgrade() else {
197                return;
198            };
199
200            // M7: don't send if shutdown has started (driver channel may already be closed)
201            if policy.shutdown_started.load(Ordering::SeqCst) {
202                return;
203            }
204
205            // Drain the bucket
206            let bucket = policy.take_bucket(&key_clone);
207            let Some(bucket) = bucket else {
208                return; // bucket already drained by size-based completion
209            };
210
211            let sorted = policy.drain_and_sort(bucket).await;
212
213            // Send via driver channel
214            if let Some(tx) = driver_tx_opt {
215                for ex in sorted {
216                    if tx.send(ex).await.is_err() {
217                        tracing::debug!(
218                            key = %key_clone,
219                            "BatchPolicy timeout: driver channel closed during emission"
220                        );
221                        break;
222                    }
223                }
224            }
225
226            // Clean up handle entry
227            {
228                let mut handles = policy
229                    .timeout_handles
230                    .lock()
231                    .unwrap_or_else(|e| e.into_inner());
232                handles.remove(&key_clone);
233            }
234        });
235
236        {
237            let mut handles = self
238                .timeout_handles
239                .lock()
240                .unwrap_or_else(|e| e.into_inner());
241            handles.insert(key, handle);
242        }
243    }
244}
245
246#[async_trait]
247impl ResequencePolicy for BatchPolicy {
248    async fn accept(&self, input: Exchange) -> Vec<Exchange> {
249        let correlation_id = input.correlation_id().to_owned();
250        let key = match self.eval_key(&input).await {
251            Ok(k) => k,
252            Err(e) => {
253                // log-policy: handler-owned
254                tracing::warn!(
255                    error = %e,
256                    correlation_id = %correlation_id,
257                    "BatchPolicy: correlation expression failed, dropping exchange"
258                );
259                return vec![];
260            }
261        };
262
263        let bucket_count = {
264            let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
265            let bucket = buckets.entry(key.clone()).or_default();
266            bucket.exchanges.push(input);
267            bucket.exchanges.len()
268        };
269
270        // Spawn timeout task if needed (first exchange for this key)
271        if bucket_count == 1 && self.needs_timeout() {
272            let timeout_ms = match self.completion {
273                BatchCompletion::Timeout(t) | BatchCompletion::SizeOrTimeout(_, t) => t,
274                _ => unreachable!(),
275            };
276            self.spawn_timeout_task(key.clone(), timeout_ms);
277        }
278
279        // Check if the bucket is complete (size-based)
280        if self.is_complete_by_size(bucket_count) {
281            self.cancel_timeout(&key);
282            if let Some(bucket) = self.take_bucket(&key) {
283                return self.drain_and_sort(bucket).await;
284            }
285        }
286
287        vec![]
288    }
289
290    async fn flush(&self) -> Vec<Exchange> {
291        // M7: signal timeout tasks that shutdown is in progress
292        self.shutdown_started.store(true, Ordering::SeqCst);
293
294        let all_keys: Vec<String> = {
295            let buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
296            buckets.keys().cloned().collect()
297        };
298
299        let mut all_sorted = Vec::new();
300        for key in &all_keys {
301            self.cancel_timeout(key);
302            if let Some(bucket) = self.take_bucket(key) {
303                let sorted = self.drain_and_sort(bucket).await;
304                all_sorted.extend(sorted);
305            }
306        }
307
308        // Cancel all remaining timeout tasks
309        {
310            let tokens: HashMap<String, CancellationToken> = {
311                let mut guard = self
312                    .timeout_tokens
313                    .lock()
314                    .unwrap_or_else(|e| e.into_inner());
315                std::mem::take(&mut *guard)
316            };
317            for (_, token) in tokens {
318                token.cancel();
319            }
320        }
321        // Drop handles — tasks wind down when cancelled
322        {
323            let _handles = {
324                let mut guard = self
325                    .timeout_handles
326                    .lock()
327                    .unwrap_or_else(|e| e.into_inner());
328                std::mem::take(&mut *guard)
329            };
330        }
331
332        all_sorted
333    }
334
335    fn name(&self) -> &'static str {
336        "batch-resequencer"
337    }
338
339    fn set_timeout_tx(&self, tx: tokio::sync::mpsc::Sender<Exchange>) {
340        self.set_driver_tx(tx);
341    }
342}
343
344// ── Tests ──
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use camel_api::exchange::ExchangePattern;
350    use camel_api::message::Message;
351
352    /// Mock expression that reads a property by name.
353    struct PropExpr(String);
354
355    #[async_trait::async_trait]
356    impl Expression for PropExpr {
357        async fn evaluate(
358            &self,
359            exchange: &Exchange,
360        ) -> Result<serde_json::Value, camel_language_api::LanguageError> {
361            Ok(exchange
362                .property(&self.0)
363                .cloned()
364                .unwrap_or(serde_json::Value::Null))
365        }
366    }
367
368    /// Mock expression that always returns the same string.
369    struct ConstExpr(String);
370
371    #[async_trait::async_trait]
372    impl Expression for ConstExpr {
373        async fn evaluate(
374            &self,
375            _exchange: &Exchange,
376        ) -> Result<serde_json::Value, camel_language_api::LanguageError> {
377            Ok(serde_json::Value::String(self.0.clone()))
378        }
379    }
380
381    /// Mock expression that always fails.
382    struct FailingExpr;
383
384    #[async_trait::async_trait]
385    impl Expression for FailingExpr {
386        async fn evaluate(
387            &self,
388            _exchange: &Exchange,
389        ) -> Result<serde_json::Value, camel_language_api::LanguageError> {
390            Err(camel_language_api::LanguageError::EvalError(
391                "mock eval failure".into(),
392            ))
393        }
394    }
395
396    fn mk_exchange(seq: i64) -> Exchange {
397        let mut ex = Exchange::new(Message::new(camel_api::body::Body::Text(format!(
398            "msg-{seq}"
399        ))));
400        ex.set_property("seq", serde_json::json!(seq));
401        ex.pattern = ExchangePattern::InOnly;
402        ex
403    }
404
405    fn mk_exchange_with_key(seq: i64, key_prop: &str, key_val: &str) -> Exchange {
406        let mut ex = Exchange::new(Message::new(camel_api::body::Body::Text(format!(
407            "msg-{seq}"
408        ))));
409        ex.set_property("seq", serde_json::json!(seq));
410        ex.set_property(key_prop, serde_json::Value::String(key_val.to_string()));
411        ex.pattern = ExchangePattern::InOnly;
412        ex
413    }
414
415    /// C1.1: 3 exchanges with seq [3,1,2], same correlation key, window size 3 →
416    /// on 3rd input accept() returns [1,2,3] sorted by seq.
417    #[tokio::test]
418    async fn batch_size_completion_emits_sorted_burst() {
419        let policy = BatchPolicy::new_cyclic(
420            Arc::new(ConstExpr("same".into())),
421            Arc::new(PropExpr("seq".into())),
422            BatchCompletion::Size(3),
423        );
424
425        assert!(policy.accept(mk_exchange(3)).await.is_empty());
426        assert!(policy.accept(mk_exchange(1)).await.is_empty());
427
428        let emitted = policy.accept(mk_exchange(2)).await;
429        assert_eq!(emitted.len(), 3, "should emit all 3 on completion");
430        let seqs: Vec<i64> = emitted
431            .iter()
432            .map(|ex| ex.property("seq").and_then(|v| v.as_i64()).unwrap_or(-1))
433            .collect();
434        assert_eq!(seqs, vec![1, 2, 3], "should be sorted ascending");
435    }
436
437    /// C1.2: 2 exchanges, timeout window (no size reached) →
438    /// after timeout fires, emit sorted buffered.
439    #[tokio::test]
440    async fn batch_timeout_completion_emits_after_timeout() {
441        let policy = BatchPolicy::new_cyclic(
442            Arc::new(ConstExpr("same".into())),
443            Arc::new(PropExpr("seq".into())),
444            BatchCompletion::Timeout(50),
445        );
446
447        let (tx, mut rx) = mpsc::channel::<Exchange>(16);
448        policy.set_driver_tx(tx);
449
450        assert!(policy.accept(mk_exchange(3)).await.is_empty());
451        assert!(policy.accept(mk_exchange(1)).await.is_empty());
452
453        let emitted: Vec<Exchange> = tokio::time::timeout(Duration::from_millis(500), async {
454            let mut out = Vec::new();
455            out.push(rx.recv().await.unwrap());
456            out.push(rx.recv().await.unwrap());
457            out
458        })
459        .await
460        .expect("timeout should fire within 500ms");
461
462        assert_eq!(emitted.len(), 2);
463        let seqs: Vec<i64> = emitted
464            .iter()
465            .map(|ex| ex.property("seq").and_then(|v| v.as_i64()).unwrap_or(-1))
466            .collect();
467        assert_eq!(seqs, vec![1, 3], "should be sorted ascending");
468    }
469
470    /// C1.3: SizeOrTimeout(3, 5000ms); send 3 → size wins before timeout.
471    #[tokio::test]
472    async fn batch_size_or_timeout_size_wins() {
473        let policy = BatchPolicy::new_cyclic(
474            Arc::new(ConstExpr("same".into())),
475            Arc::new(PropExpr("seq".into())),
476            BatchCompletion::SizeOrTimeout(3, 5_000),
477        );
478
479        assert!(policy.accept(mk_exchange(2)).await.is_empty());
480        assert!(policy.accept(mk_exchange(1)).await.is_empty());
481
482        let emitted = policy.accept(mk_exchange(3)).await;
483        assert_eq!(emitted.len(), 3);
484        let seqs: Vec<i64> = emitted
485            .iter()
486            .map(|ex| ex.property("seq").and_then(|v| v.as_i64()).unwrap_or(-1))
487            .collect();
488        assert_eq!(seqs, vec![1, 2, 3]);
489    }
490
491    /// C1.4: Exchanges with different correlation keys buffer independently.
492    #[tokio::test]
493    async fn batch_multi_key_independence() {
494        let policy = BatchPolicy::new_cyclic(
495            Arc::new(PropExpr("region".into())),
496            Arc::new(PropExpr("seq".into())),
497            BatchCompletion::Size(2),
498        );
499
500        let _ = policy
501            .accept(mk_exchange_with_key(2, "region", "east"))
502            .await;
503        let east_emit = policy
504            .accept(mk_exchange_with_key(1, "region", "east"))
505            .await;
506        assert_eq!(east_emit.len(), 2, "east bucket should complete at size 2");
507
508        let west_result = policy
509            .accept(mk_exchange_with_key(3, "region", "west"))
510            .await;
511        assert!(
512            west_result.is_empty(),
513            "west bucket should NOT complete yet"
514        );
515    }
516
517    /// C1.5: flush() emits remaining buffered exchanges (within-key sorted).
518    /// With a single correlation key, all remain and are sorted together.
519    #[tokio::test]
520    async fn batch_flush_emits_remaining_sorted() {
521        let policy = BatchPolicy::new_cyclic(
522            Arc::new(ConstExpr("same".into())),
523            Arc::new(PropExpr("seq".into())),
524            BatchCompletion::Size(10),
525        );
526
527        assert!(policy.accept(mk_exchange(5)).await.is_empty());
528        assert!(policy.accept(mk_exchange(3)).await.is_empty());
529        assert!(policy.accept(mk_exchange(1)).await.is_empty());
530
531        let flushed = policy.flush().await;
532        assert_eq!(flushed.len(), 3);
533        let seqs: Vec<i64> = flushed
534            .iter()
535            .map(|ex| ex.property("seq").and_then(|v| v.as_i64()).unwrap_or(-1))
536            .collect();
537        assert_eq!(seqs, vec![1, 3, 5]);
538    }
539
540    /// C1.6: Exchange where correlation expression fails → accept()
541    /// returns empty vec (no crash).
542    #[tokio::test]
543    async fn batch_correlation_eval_failure_returns_empty() {
544        let policy = BatchPolicy::new_cyclic(
545            Arc::new(FailingExpr),
546            Arc::new(PropExpr("seq".into())),
547            BatchCompletion::Size(2),
548        );
549
550        let result = policy.accept(mk_exchange(1)).await;
551        assert!(
552            result.is_empty(),
553            "failed correlation should return empty vec, not crash"
554        );
555    }
556
557    /// Verify pure Size completion does not need timeout tasks.
558    #[tokio::test]
559    async fn batch_pure_size_no_timeout_needed() {
560        let policy = BatchPolicy::new_cyclic(
561            Arc::new(ConstExpr("same".into())),
562            Arc::new(PropExpr("seq".into())),
563            BatchCompletion::Size(2),
564        );
565
566        assert!(!policy.needs_timeout());
567    }
568}