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, AtomicU64, 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_util::sync::CancellationToken;
16
17use super::ResequencePolicy;
18
19/// Default upper bound on simultaneously open correlation buckets
20/// (audit 2026-08-31, F6-1). Mirrors the aggregator's `max_buckets` default
21/// (`camel-api` aggregator.rs:209). New keys beyond the cap are dropped.
22pub const DEFAULT_MAX_BUCKETS: usize = 10_000;
23
24/// Default upper bound on buffered exchanges inside ONE bucket (F6-1). A hot
25/// correlation key can otherwise buffer unboundedly until completion fires.
26/// Exchanges beyond the cap are dropped (fail-visible: warn + ack drop, same
27/// class as the resequencer's post-ack drop semantics in ADR-0029).
28pub const DEFAULT_MAX_BUCKET_SIZE: usize = 1_000;
29
30/// Default upper bound on live timeout tasks (F6-1). Mirrors the aggregator's
31/// `max_timeout_tasks` default (`camel-api` aggregator.rs:214). When the cap is
32/// reached, new buckets still buffer but get no per-key timer — their
33/// completion then relies on size or shutdown flush.
34pub const DEFAULT_MAX_TIMEOUT_TASKS: usize = 1024;
35
36/// Per-correlation-key bucket holding pending exchanges.
37#[derive(Default)]
38struct Bucket {
39    exchanges: Vec<Exchange>,
40}
41
42/// One live timeout task: the cancellation token plus the spawn generation
43/// that owns it. The generation makes cleanup compare-and-remove safe under
44/// key reuse (a stale task's removal is a no-op once superseded).
45struct TimeoutEntry {
46    generation: u64,
47    cancel: CancellationToken,
48}
49
50/// Batch resequencing policy.
51///
52/// Buffers exchanges per correlation key. Completion is triggered by
53/// window (size and/or timeout). On completion, sorts buffered exchanges
54/// by `sort_expr` and returns them as a burst. Timeout tasks hold a
55/// `Weak<Self>` reference obtained via `Arc::new_cyclic`.
56pub struct BatchPolicy {
57    correlation_expr: Arc<dyn Expression>,
58    sort_expr: Arc<dyn Expression>,
59    completion: BatchCompletion,
60
61    /// Weak self-reference so timeout tasks can upgrade to `Arc<Self>`.
62    weak_self: Weak<Self>,
63
64    /// Per-correlation-key buckets (exchanges pending completion).
65    buckets: Mutex<HashMap<String, Bucket>>,
66
67    /// Live timeout tasks, keyed by correlation key. Token and handle share
68    /// one entry so they share one lifecycle: removing the entry retires
69    /// both (re-review of F6-1: the original two-map layout leaked the
70    /// token entry on natural timeout completion).
71    timeout_tasks: Mutex<HashMap<String, TimeoutEntry>>,
72
73    /// Monotonic spawn generation. Guards timeout ownership: a stale task
74    /// (superseded by key reuse after size-based completion) can neither
75    /// drain the newer bucket nor remove the newer task's entry.
76    timeout_generation: AtomicU64,
77
78    /// Test-only synchronization point INSIDE
79    /// `take_bucket_if_current_timeout_task`, between the generation check
80    /// and the bucket take — i.e. while the `timeout_tasks` lock is still
81    /// held. Lets the regression test force the interleaving that a
82    /// separate-check-then-take implementation would permit (re-review 2
83    /// of F6-1: proving the test bites).
84    #[cfg(test)]
85    interleave_hook: Mutex<Option<Arc<dyn Fn() + Send + Sync>>>,
86
87    /// Channel to the post-driver for timeout-triggered emissions.
88    /// Set by `ResequencerService` after channel creation.
89    driver_tx: Mutex<Option<mpsc::Sender<Exchange>>>,
90
91    /// Shutdown guard — timeout tasks check this before sending
92    /// to avoid racing with post-driver channel close (M7).
93    shutdown_started: AtomicBool,
94
95    /// Upper bound on simultaneously open buckets (F6-1).
96    max_buckets: usize,
97
98    /// Upper bound on buffered exchanges inside one bucket (F6-1).
99    max_bucket_size: usize,
100
101    /// Upper bound on live timeout tasks (F6-1).
102    max_timeout_tasks: usize,
103}
104
105impl BatchPolicy {
106    /// Create a new `Arc<BatchPolicy>` using `Arc::new_cyclic` so the
107    /// policy holds a `Weak<Self>` for timeout task spawning.
108    pub fn new_cyclic(
109        correlation_expr: Arc<dyn Expression>,
110        sort_expr: Arc<dyn Expression>,
111        completion: BatchCompletion,
112    ) -> Arc<Self> {
113        Self::with_limits(
114            correlation_expr,
115            sort_expr,
116            completion,
117            DEFAULT_MAX_BUCKETS,
118            DEFAULT_MAX_BUCKET_SIZE,
119            DEFAULT_MAX_TIMEOUT_TASKS,
120        )
121    }
122
123    /// Create a `BatchPolicy` with explicit resource bounds (F6-1).
124    /// `new_cyclic` delegates here with the `DEFAULT_*` constants.
125    pub fn with_limits(
126        correlation_expr: Arc<dyn Expression>,
127        sort_expr: Arc<dyn Expression>,
128        completion: BatchCompletion,
129        max_buckets: usize,
130        max_bucket_size: usize,
131        max_timeout_tasks: usize,
132    ) -> Arc<Self> {
133        Arc::new_cyclic(|weak| Self {
134            correlation_expr,
135            sort_expr,
136            completion,
137            weak_self: weak.clone(),
138            buckets: Mutex::new(HashMap::new()),
139            timeout_tasks: Mutex::new(HashMap::new()),
140            timeout_generation: AtomicU64::new(0),
141            #[cfg(test)]
142            interleave_hook: Mutex::new(None),
143            driver_tx: Mutex::new(None),
144            shutdown_started: AtomicBool::new(false),
145            max_buckets,
146            max_bucket_size,
147            max_timeout_tasks,
148        })
149    }
150
151    /// Set the driver channel (via `set_timeout_tx` trait method).
152    /// Called by `ResequencerService` after channel creation.
153    fn set_driver_tx(&self, tx: mpsc::Sender<Exchange>) {
154        let mut guard = self.driver_tx.lock().unwrap_or_else(|e| e.into_inner());
155        *guard = Some(tx);
156    }
157
158    /// Evaluate the correlation expression against an exchange.
159    async fn eval_key(&self, exchange: &Exchange) -> Result<String, String> {
160        self.correlation_expr
161            .evaluate(exchange)
162            .await
163            // M4: avoid double-quoting for string values — use as_str() for
164            // strings, fall back to to_string() for other types.
165            .map(|v| match v {
166                serde_json::Value::String(s) => s,
167                other => other.to_string(),
168            })
169            .map_err(|e| format!("correlation expression evaluation failed: {e}"))
170    }
171
172    /// Drain a bucket, sort by sort_expr, return sorted Vec.
173    async fn drain_and_sort(&self, mut bucket: Bucket) -> Vec<Exchange> {
174        let mut indexed: Vec<(serde_json::Value, Exchange)> = Vec::new();
175        for ex in bucket.exchanges.drain(..) {
176            let val = self
177                .sort_expr
178                .evaluate(&ex)
179                .await
180                .unwrap_or(serde_json::Value::Null);
181            indexed.push((val, ex));
182        }
183        indexed.sort_by(|a, b| cmp_values(&a.0, &b.0));
184        indexed.into_iter().map(|(_, ex)| ex).collect()
185    }
186
187    /// Check if a bucket count satisfies the size-based completion condition.
188    fn is_complete_by_size(&self, count: usize) -> bool {
189        match self.completion {
190            BatchCompletion::Size(s) => count >= s,
191            BatchCompletion::SizeOrTimeout(s, _) => count >= s,
192            // Timeout and any future variant are not size-complete.
193            _ => false,
194        }
195    }
196
197    /// Whether this completion variant needs timeout tasks spawned.
198    fn needs_timeout(&self) -> bool {
199        matches!(
200            self.completion,
201            BatchCompletion::Timeout(_) | BatchCompletion::SizeOrTimeout(..)
202        )
203    }
204
205    /// Take a bucket by key. Returns `Some(Bucket)` if it existed.
206    fn take_bucket(&self, key: &str) -> Option<Bucket> {
207        let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
208        buckets.remove(key)
209    }
210
211    /// Cancel and remove the timeout task for `key` (size-based completion,
212    /// flush). Removing the whole entry first makes a concurrently waking
213    /// stale task a no-op via the generation guard.
214    fn cancel_timeout(&self, key: &str) {
215        if let Some(entry) = self
216            .timeout_tasks
217            .lock()
218            .unwrap_or_else(|e| e.into_inner())
219            .remove(key)
220        {
221            entry.cancel.cancel();
222        }
223    }
224
225    /// Atomically verify this task still owns the timeout entry for `key`
226    /// AND take the bucket — ONE critical section holding `timeout_tasks`
227    /// across the bucket removal (re-review 2 of F6-1). Closing the gap
228    /// between a separate generation check and a separate `take_bucket`
229    /// prevents the reuse race where a stale task passes the guard, gets
230    /// interleaved by size-completion + key reuse, and then drains the NEW
231    /// generation's bucket. Lock order: `timeout_tasks` → `buckets`.
232    fn take_bucket_if_current_timeout_task(&self, key: &str, generation: u64) -> Option<Bucket> {
233        let tasks = self.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
234        if !tasks
235            .get(key)
236            .is_some_and(|entry| entry.generation == generation)
237        {
238            return None;
239        }
240        // Test-only interleaving point: runs while `timeout_tasks` is held.
241        // A correct (combined) implementation serializes any concurrent
242        // supersede behind this lock (the hook's bounded wait elapses); a
243        // separate check-then-take implementation lets the supersede
244        // complete inside the gap (the hook's wait succeeds).
245        #[cfg(test)]
246        if let Some(hook) = self
247            .interleave_hook
248            .lock()
249            .unwrap_or_else(|e| e.into_inner())
250            .clone()
251        {
252            hook();
253        }
254        let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
255        buckets.remove(key)
256    }
257
258    /// Remove the timeout entry for `key` iff it still belongs to `generation`.
259    fn remove_timeout_task_if_current(&self, key: &str, generation: u64) {
260        let mut tasks = self.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
261        if tasks
262            .get(key)
263            .is_some_and(|entry| entry.generation == generation)
264        {
265            tasks.remove(key);
266        }
267    }
268
269    /// Spawn a timeout task for the given key.
270    /// Must be called from a method that has access to `&self` (which has the `weak_self`).
271    ///
272    /// Generation-safety (re-review of F6-1): each spawn takes a fresh
273    /// monotonic generation. The task drains and cleans up only through
274    /// generation-checked combined operations, so a stale task (superseded
275    /// by key reuse after size-based completion) can neither steal the
276    /// newer bucket nor delete the newer task's entry. The entry (token +
277    /// generation) is inserted BEFORE spawning so the map never holds a
278    /// half-observed task; the task itself is detached (cancellation winds
279    /// it down, flush drops all entries).
280    fn spawn_timeout_task(&self, key: String, timeout_ms: u64) {
281        let generation = self.timeout_generation.fetch_add(1, Ordering::SeqCst) + 1;
282        let cancel = CancellationToken::new();
283        let cancel_clone = cancel.clone();
284
285        // Store the entry before the task exists.
286        {
287            let mut tasks = self.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
288            tasks.insert(key.clone(), TimeoutEntry { generation, cancel });
289        }
290
291        let weak = self.weak_self.clone();
292        let key_clone = key.clone();
293        let driver_tx_opt = {
294            let guard = self.driver_tx.lock().unwrap_or_else(|e| e.into_inner());
295            guard.clone()
296        };
297
298        tokio::spawn(async move {
299            let timeout = Duration::from_millis(timeout_ms);
300
301            tokio::select! {
302                _ = tokio::time::sleep(timeout) => {
303                    if cancel_clone.is_cancelled() {
304                        return;
305                    }
306                }
307                _ = cancel_clone.cancelled() => {
308                    return;
309                }
310            }
311
312            // Upgrade the weak reference — policy may have been dropped (shutdown)
313            let Some(policy) = weak.upgrade() else {
314                return;
315            };
316
317            // M7: don't send if shutdown has started (driver channel may already be closed)
318            if policy.shutdown_started.load(Ordering::SeqCst) {
319                return;
320            }
321
322            // Atomically verify ownership AND take the bucket in one
323            // critical section. A stale task (superseded by key reuse after
324            // size-based completion) gets None here and can neither drain
325            // the newer bucket nor remove the newer entry.
326            let bucket = policy.take_bucket_if_current_timeout_task(&key_clone, generation);
327            let Some(bucket) = bucket else {
328                // Bucket already drained by size-based completion (or a
329                // newer task owns the key) — clean up our own entry only if
330                // it is still ours (compare-and-remove; no-op when
331                // superseded). The original two-map layout leaked the token
332                // here: re-review of F6-1.
333                policy.remove_timeout_task_if_current(&key_clone, generation);
334                return;
335            };
336
337            let sorted = policy.drain_and_sort(bucket).await;
338
339            // Send via driver channel
340            if let Some(tx) = driver_tx_opt {
341                for ex in sorted {
342                    if tx.send(ex).await.is_err() {
343                        tracing::debug!(
344                            key = %key_clone,
345                            "BatchPolicy timeout: driver channel closed during emission"
346                        );
347                        break;
348                    }
349                }
350            }
351
352            // Clean up our entry — only if a newer task has not superseded us.
353            policy.remove_timeout_task_if_current(&key_clone, generation);
354        });
355    }
356}
357
358#[async_trait]
359impl ResequencePolicy for BatchPolicy {
360    async fn accept(&self, input: Exchange) -> Vec<Exchange> {
361        let correlation_id = input.correlation_id().to_owned();
362        let key = match self.eval_key(&input).await {
363            Ok(k) => k,
364            Err(e) => {
365                // log-policy: handler-owned
366                tracing::warn!(
367                    error = %e,
368                    correlation_id = %correlation_id,
369                    "BatchPolicy: correlation expression failed, dropping exchange"
370                );
371                return vec![];
372            }
373        };
374
375        let bucket_count = {
376            let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
377            // F6-1 bucket-count cap: refuse to open a NEW bucket past the cap.
378            // Existing buckets keep accepting (bounded per-bucket below).
379            if !buckets.contains_key(&key) && buckets.len() >= self.max_buckets {
380                // log-policy: handler-owned
381                tracing::warn!(
382                    correlation_id = %correlation_id,
383                    max_buckets = self.max_buckets,
384                    "BatchPolicy: bucket cap reached, dropping exchange"
385                );
386                return vec![];
387            }
388            let bucket = buckets.entry(key.clone()).or_default();
389            // F6-1 per-bucket cap: a hot key cannot buffer unboundedly.
390            if bucket.exchanges.len() >= self.max_bucket_size {
391                // log-policy: handler-owned
392                tracing::warn!(
393                    correlation_id = %correlation_id,
394                    max_bucket_size = self.max_bucket_size,
395                    "BatchPolicy: per-bucket cap reached, dropping exchange"
396                );
397                return vec![];
398            }
399            bucket.exchanges.push(input);
400            bucket.exchanges.len()
401        };
402
403        // Spawn timeout task if needed (first exchange for this key), subject
404        // to the F6-1 timeout-task cap: past the cap the bucket still buffers
405        // and completes on size or shutdown flush (mirrors the aggregator's
406        // graceful degradation to TTL-only eviction).
407        if bucket_count == 1 && self.needs_timeout() {
408            let live_tasks = {
409                let tasks = self.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
410                tasks.len()
411            };
412            if live_tasks < self.max_timeout_tasks {
413                let timeout_ms = match self.completion {
414                    BatchCompletion::Timeout(t) | BatchCompletion::SizeOrTimeout(_, t) => t,
415                    _ => unreachable!(),
416                };
417                self.spawn_timeout_task(key.clone(), timeout_ms);
418            } else {
419                // log-policy: handler-owned
420                tracing::warn!(
421                    correlation_id = %correlation_id,
422                    max_timeout_tasks = self.max_timeout_tasks,
423                    "BatchPolicy: timeout-task cap reached; bucket relies on size/flush completion"
424                );
425            }
426        }
427
428        // Check if the bucket is complete (size-based)
429        if self.is_complete_by_size(bucket_count) {
430            self.cancel_timeout(&key);
431            if let Some(bucket) = self.take_bucket(&key) {
432                return self.drain_and_sort(bucket).await;
433            }
434        }
435
436        vec![]
437    }
438
439    async fn flush(&self) -> Vec<Exchange> {
440        // M7: signal timeout tasks that shutdown is in progress
441        self.shutdown_started.store(true, Ordering::SeqCst);
442
443        let all_keys: Vec<String> = {
444            let buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
445            buckets.keys().cloned().collect()
446        };
447
448        let mut all_sorted = Vec::new();
449        for key in &all_keys {
450            self.cancel_timeout(key);
451            if let Some(bucket) = self.take_bucket(key) {
452                let sorted = self.drain_and_sort(bucket).await;
453                all_sorted.extend(sorted);
454            }
455        }
456
457        // Cancel all remaining timeout tasks (dropping the entries detaches
458        // the tasks; they wind down once cancelled)
459        {
460            let tasks: HashMap<String, TimeoutEntry> = {
461                let mut guard = self.timeout_tasks.lock().unwrap_or_else(|e| e.into_inner());
462                std::mem::take(&mut *guard)
463            };
464            for (_, entry) in tasks {
465                entry.cancel.cancel();
466            }
467        }
468
469        all_sorted
470    }
471
472    fn name(&self) -> &'static str {
473        "batch-resequencer"
474    }
475
476    fn buffered(&self) -> usize {
477        let buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
478        buckets.values().map(|b| b.exchanges.len()).sum()
479    }
480
481    fn set_timeout_tx(&self, tx: tokio::sync::mpsc::Sender<Exchange>) {
482        self.set_driver_tx(tx);
483    }
484}
485
486// ── Tests ──
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use camel_api::exchange::ExchangePattern;
492    use camel_api::message::Message;
493
494    /// Mock expression that reads a property by name.
495    struct PropExpr(String);
496
497    #[async_trait::async_trait]
498    impl Expression for PropExpr {
499        async fn evaluate(
500            &self,
501            exchange: &Exchange,
502        ) -> Result<serde_json::Value, camel_language_api::LanguageError> {
503            Ok(exchange
504                .property(&self.0)
505                .cloned()
506                .unwrap_or(serde_json::Value::Null))
507        }
508    }
509
510    /// Mock expression that always returns the same string.
511    struct ConstExpr(String);
512
513    #[async_trait::async_trait]
514    impl Expression for ConstExpr {
515        async fn evaluate(
516            &self,
517            _exchange: &Exchange,
518        ) -> Result<serde_json::Value, camel_language_api::LanguageError> {
519            Ok(serde_json::Value::String(self.0.clone()))
520        }
521    }
522
523    /// Mock expression that always fails.
524    struct FailingExpr;
525
526    #[async_trait::async_trait]
527    impl Expression for FailingExpr {
528        async fn evaluate(
529            &self,
530            _exchange: &Exchange,
531        ) -> Result<serde_json::Value, camel_language_api::LanguageError> {
532            Err(camel_language_api::LanguageError::EvalError(
533                "mock eval failure".into(),
534            ))
535        }
536    }
537
538    fn mk_exchange(seq: i64) -> Exchange {
539        let mut ex = Exchange::new(Message::new(camel_api::body::Body::Text(format!(
540            "msg-{seq}"
541        ))));
542        ex.set_property("seq", serde_json::json!(seq));
543        ex.pattern = ExchangePattern::InOnly;
544        ex
545    }
546
547    fn mk_exchange_with_key(seq: i64, key_prop: &str, key_val: &str) -> Exchange {
548        let mut ex = Exchange::new(Message::new(camel_api::body::Body::Text(format!(
549            "msg-{seq}"
550        ))));
551        ex.set_property("seq", serde_json::json!(seq));
552        ex.set_property(key_prop, serde_json::Value::String(key_val.to_string()));
553        ex.pattern = ExchangePattern::InOnly;
554        ex
555    }
556
557    /// C1.1: 3 exchanges with seq [3,1,2], same correlation key, window size 3 →
558    /// on 3rd input accept() returns [1,2,3] sorted by seq.
559    #[tokio::test]
560    async fn batch_size_completion_emits_sorted_burst() {
561        let policy = BatchPolicy::new_cyclic(
562            Arc::new(ConstExpr("same".into())),
563            Arc::new(PropExpr("seq".into())),
564            BatchCompletion::Size(3),
565        );
566
567        assert!(policy.accept(mk_exchange(3)).await.is_empty());
568        assert!(policy.accept(mk_exchange(1)).await.is_empty());
569
570        let emitted = policy.accept(mk_exchange(2)).await;
571        assert_eq!(emitted.len(), 3, "should emit all 3 on completion");
572        let seqs: Vec<i64> = emitted
573            .iter()
574            .map(|ex| ex.property("seq").and_then(|v| v.as_i64()).unwrap_or(-1))
575            .collect();
576        assert_eq!(seqs, vec![1, 2, 3], "should be sorted ascending");
577    }
578
579    /// C1.2: 2 exchanges, timeout window (no size reached) →
580    /// after timeout fires, emit sorted buffered.
581    #[tokio::test]
582    async fn batch_timeout_completion_emits_after_timeout() {
583        let policy = BatchPolicy::new_cyclic(
584            Arc::new(ConstExpr("same".into())),
585            Arc::new(PropExpr("seq".into())),
586            BatchCompletion::Timeout(50),
587        );
588
589        let (tx, mut rx) = mpsc::channel::<Exchange>(16);
590        policy.set_driver_tx(tx);
591
592        assert!(policy.accept(mk_exchange(3)).await.is_empty());
593        assert!(policy.accept(mk_exchange(1)).await.is_empty());
594
595        let emitted: Vec<Exchange> = tokio::time::timeout(Duration::from_millis(500), async {
596            let mut out = Vec::new();
597            out.push(rx.recv().await.unwrap());
598            out.push(rx.recv().await.unwrap());
599            out
600        })
601        .await
602        .expect("timeout should fire within 500ms");
603
604        assert_eq!(emitted.len(), 2);
605        let seqs: Vec<i64> = emitted
606            .iter()
607            .map(|ex| ex.property("seq").and_then(|v| v.as_i64()).unwrap_or(-1))
608            .collect();
609        assert_eq!(seqs, vec![1, 3], "should be sorted ascending");
610    }
611
612    /// C1.3: SizeOrTimeout(3, 5000ms); send 3 → size wins before timeout.
613    #[tokio::test]
614    async fn batch_size_or_timeout_size_wins() {
615        let policy = BatchPolicy::new_cyclic(
616            Arc::new(ConstExpr("same".into())),
617            Arc::new(PropExpr("seq".into())),
618            BatchCompletion::SizeOrTimeout(3, 5_000),
619        );
620
621        assert!(policy.accept(mk_exchange(2)).await.is_empty());
622        assert!(policy.accept(mk_exchange(1)).await.is_empty());
623
624        let emitted = policy.accept(mk_exchange(3)).await;
625        assert_eq!(emitted.len(), 3);
626        let seqs: Vec<i64> = emitted
627            .iter()
628            .map(|ex| ex.property("seq").and_then(|v| v.as_i64()).unwrap_or(-1))
629            .collect();
630        assert_eq!(seqs, vec![1, 2, 3]);
631    }
632
633    /// C1.4: Exchanges with different correlation keys buffer independently.
634    #[tokio::test]
635    async fn batch_multi_key_independence() {
636        let policy = BatchPolicy::new_cyclic(
637            Arc::new(PropExpr("region".into())),
638            Arc::new(PropExpr("seq".into())),
639            BatchCompletion::Size(2),
640        );
641
642        let _ = policy
643            .accept(mk_exchange_with_key(2, "region", "east"))
644            .await;
645        let east_emit = policy
646            .accept(mk_exchange_with_key(1, "region", "east"))
647            .await;
648        assert_eq!(east_emit.len(), 2, "east bucket should complete at size 2");
649
650        let west_result = policy
651            .accept(mk_exchange_with_key(3, "region", "west"))
652            .await;
653        assert!(
654            west_result.is_empty(),
655            "west bucket should NOT complete yet"
656        );
657    }
658
659    /// C1.5: flush() emits remaining buffered exchanges (within-key sorted).
660    /// With a single correlation key, all remain and are sorted together.
661    #[tokio::test]
662    async fn batch_flush_emits_remaining_sorted() {
663        let policy = BatchPolicy::new_cyclic(
664            Arc::new(ConstExpr("same".into())),
665            Arc::new(PropExpr("seq".into())),
666            BatchCompletion::Size(10),
667        );
668
669        assert!(policy.accept(mk_exchange(5)).await.is_empty());
670        assert!(policy.accept(mk_exchange(3)).await.is_empty());
671        assert!(policy.accept(mk_exchange(1)).await.is_empty());
672
673        let flushed = policy.flush().await;
674        assert_eq!(flushed.len(), 3);
675        let seqs: Vec<i64> = flushed
676            .iter()
677            .map(|ex| ex.property("seq").and_then(|v| v.as_i64()).unwrap_or(-1))
678            .collect();
679        assert_eq!(seqs, vec![1, 3, 5]);
680    }
681
682    /// C1.6: Exchange where correlation expression fails → accept()
683    /// returns empty vec (no crash).
684    #[tokio::test]
685    async fn batch_correlation_eval_failure_returns_empty() {
686        let policy = BatchPolicy::new_cyclic(
687            Arc::new(FailingExpr),
688            Arc::new(PropExpr("seq".into())),
689            BatchCompletion::Size(2),
690        );
691
692        let result = policy.accept(mk_exchange(1)).await;
693        assert!(
694            result.is_empty(),
695            "failed correlation should return empty vec, not crash"
696        );
697    }
698
699    /// Verify pure Size completion does not need timeout tasks.
700    #[tokio::test]
701    async fn batch_pure_size_no_timeout_needed() {
702        let policy = BatchPolicy::new_cyclic(
703            Arc::new(ConstExpr("same".into())),
704            Arc::new(PropExpr("seq".into())),
705            BatchCompletion::Size(2),
706        );
707
708        assert!(!policy.needs_timeout());
709    }
710
711    // -----------------------------------------------------------------------
712    // F6-1 resource-bound tests (audit 2026-08-31)
713    // -----------------------------------------------------------------------
714
715    /// Bucket-count cap: N unique keys fill the map; key N+1 is dropped.
716    #[tokio::test]
717    async fn batch_bucket_count_cap_drops_new_keys() {
718        let policy = BatchPolicy::with_limits(
719            Arc::new(PropExpr("key".into())),
720            Arc::new(PropExpr("seq".into())),
721            BatchCompletion::Size(100), // never completes by size in this test
722            4,                          // max_buckets
723            1000,                       // max_bucket_size
724            16,                         // max_timeout_tasks
725        );
726
727        for i in 0..4 {
728            let mut ex = mk_exchange(i);
729            ex.set_property("key", serde_json::json!(format!("k{i}")));
730            assert!(policy.accept(ex).await.is_empty(), "buffered, not emitted");
731        }
732        assert_eq!(policy.buckets.lock().unwrap().len(), 4);
733
734        // Fifth unique key must be dropped (cap reached).
735        let mut ex = mk_exchange(99);
736        ex.set_property("key", serde_json::json!("k-overflow"));
737        assert!(policy.accept(ex).await.is_empty());
738        assert_eq!(
739            policy.buckets.lock().unwrap().len(),
740            4,
741            "no new bucket past the cap"
742        );
743
744        // Existing keys still accept (bounded per-bucket).
745        let mut ex = mk_exchange(100);
746        ex.set_property("key", serde_json::json!("k0"));
747        assert!(policy.accept(ex).await.is_empty());
748        assert_eq!(
749            policy.buckets.lock().unwrap()["k0"].exchanges.len(),
750            2,
751            "existing bucket keeps accepting"
752        );
753    }
754
755    /// Per-bucket cap: one hot key cannot buffer more than max_bucket_size.
756    #[tokio::test]
757    async fn batch_per_bucket_cap_drops_overflow() {
758        let policy = BatchPolicy::with_limits(
759            Arc::new(ConstExpr("hot".into())),
760            Arc::new(PropExpr("seq".into())),
761            BatchCompletion::Size(1_000_000), // effectively never
762            10,                               // max_buckets
763            3,                                // max_bucket_size
764            16,                               // max_timeout_tasks
765        );
766
767        for i in 0..3 {
768            assert!(policy.accept(mk_exchange(i)).await.is_empty());
769        }
770        assert_eq!(policy.buffered(), 3);
771
772        // 4th and 5th on the same key are dropped.
773        assert!(policy.accept(mk_exchange(3)).await.is_empty());
774        assert!(policy.accept(mk_exchange(4)).await.is_empty());
775        assert_eq!(
776            policy.buffered(),
777            3,
778            "bucket must not grow past max_bucket_size"
779        );
780    }
781
782    /// Timeout-task cap: past the cap, no new task is spawned (bucket relies on
783    /// size/flush). Uses Timeout completion so every new key wants a task.
784    #[tokio::test]
785    async fn batch_timeout_task_cap_stops_spawning() {
786        let policy = BatchPolicy::with_limits(
787            Arc::new(PropExpr("key".into())),
788            Arc::new(PropExpr("seq".into())),
789            BatchCompletion::Timeout(60_000), // long; we flush before it fires
790            16,                               // max_buckets
791            100,                              // max_bucket_size
792            2,                                // max_timeout_tasks
793        );
794
795        for i in 0..4 {
796            let mut ex = mk_exchange(i);
797            ex.set_property("key", serde_json::json!(format!("k{i}")));
798            assert!(policy.accept(ex).await.is_empty());
799        }
800
801        assert_eq!(
802            policy.timeout_tasks.lock().unwrap().len(),
803            2,
804            "no more than max_timeout_tasks tasks spawned"
805        );
806        assert_eq!(
807            policy.buckets.lock().unwrap().len(),
808            4,
809            "buckets still buffered even without their own timer"
810        );
811
812        // Flush completes everything (no hang, no loss of buffered exchanges).
813        let flushed = policy.flush().await;
814        assert_eq!(flushed.len(), 4);
815    }
816
817    /// Re-review of F6-1 (token-entry leak): sequential unique keys that all
818    /// complete by natural timeout must not leave entries behind — the
819    /// timeout map returns to empty after each task fires.
820    #[tokio::test]
821    async fn batch_sequential_unique_keys_do_not_leak_timeout_entries() {
822        let policy = BatchPolicy::with_limits(
823            Arc::new(PropExpr("key".into())),
824            Arc::new(PropExpr("seq".into())),
825            BatchCompletion::Timeout(30),
826            100, // max_buckets
827            100, // max_bucket_size
828            16,  // max_timeout_tasks
829        );
830
831        for i in 0..8 {
832            let mut ex = mk_exchange(i);
833            ex.set_property("key", serde_json::json!(format!("k{i}")));
834            assert!(policy.accept(ex).await.is_empty());
835            // Let the timeout fire and the task finish its cleanup.
836            tokio::time::sleep(Duration::from_millis(80)).await;
837            assert_eq!(
838                policy.timeout_tasks.lock().unwrap().len(),
839                0,
840                "timeout entry must be removed on natural completion (k{i})"
841            );
842        }
843        assert_eq!(policy.buckets.lock().unwrap().len(), 0);
844    }
845
846    /// Re-review 2 of F6-1 (guard/take TOCTOU under key reuse): the
847    /// generation check and the bucket take must be ONE critical section.
848    /// Drives the supersede cycle, then asserts that a stale generation's
849    /// COMBINED take returns None AND leaves the newer bucket intact, while
850    /// the current generation's take drains it.
851    #[tokio::test]
852    async fn batch_timeout_key_reuse_stale_take_leaves_newer_bucket() {
853        let policy = BatchPolicy::with_limits(
854            Arc::new(ConstExpr("hot".into())),
855            Arc::new(PropExpr("seq".into())),
856            BatchCompletion::Timeout(60_000), // long — nothing fires naturally here
857            16,
858            100,
859            16,
860        );
861
862        // Bucket + timeout task for key "hot" (generation 1).
863        assert!(policy.accept(mk_exchange(1)).await.is_empty());
864        let first_gen = {
865            let tasks = policy.timeout_tasks.lock().unwrap();
866            tasks.get("hot").map(|e| e.generation).unwrap()
867        };
868
869        // Supersede: size-based completion path cancels + removes generation
870        // 1 AND drains the bucket, then a new exchange respawns generation
871        // 2 with a fresh bucket for the same key.
872        policy.cancel_timeout("hot");
873        assert!(policy.take_bucket("hot").is_some());
874        assert!(policy.timeout_tasks.lock().unwrap().is_empty());
875        assert!(policy.accept(mk_exchange(2)).await.is_empty());
876        let second_gen = {
877            let tasks = policy.timeout_tasks.lock().unwrap();
878            tasks.get("hot").map(|e| e.generation).unwrap()
879        };
880        assert_ne!(first_gen, second_gen);
881
882        // A stale cleanup (as if the generation-1 task woke up late) must
883        // not remove the generation-2 entry.
884        policy.remove_timeout_task_if_current("hot", first_gen);
885        assert_eq!(
886            policy.timeout_tasks.lock().unwrap().len(),
887            1,
888            "stale generation cleanup must not remove the newer entry"
889        );
890
891        // The stale task's combined guard+take must return None AND leave
892        // the newer generation's bucket untouched.
893        let stolen = policy.take_bucket_if_current_timeout_task("hot", first_gen);
894        assert!(
895            stolen.is_none(),
896            "stale generation must not take the bucket"
897        );
898        assert_eq!(
899            policy.buffered(),
900            1,
901            "newer bucket must remain after the stale combined take"
902        );
903
904        // The current generation's combined take succeeds and drains.
905        let bucket = policy.take_bucket_if_current_timeout_task("hot", second_gen);
906        assert_eq!(
907            bucket
908                .expect("current generation drains its bucket")
909                .exchanges
910                .len(),
911            1
912        );
913        policy.remove_timeout_task_if_current("hot", second_gen);
914        assert!(policy.timeout_tasks.lock().unwrap().is_empty());
915
916        // Flush has nothing left — exactly-once semantics preserved.
917        let flushed = policy.flush().await;
918        assert!(flushed.is_empty());
919    }
920
921    /// Re-review 2 of F6-1 (regression bite): proves the combined
922    /// guard+take is atomic by forcing the interleaving DETERMINISTICALLY.
923    /// A background thread performs the full supersede (cancel + newer
924    /// bucket + newer generation entry) exactly between the generation
925    /// check and the bucket take, via the test-only hook inside the
926    /// critical section. Under the combined implementation the supersede
927    /// blocks on the held `timeout_tasks` lock and the take retrieves the
928    /// ORIGINAL bucket; a separate check-then-take implementation would
929    /// let the supersede complete in the gap and the take would STEAL the
930    /// newer generation's bucket — failing the assertions below.
931    #[tokio::test]
932    async fn batch_timeout_combined_take_atomic_under_interleaved_supersede() {
933        let policy = BatchPolicy::with_limits(
934            Arc::new(ConstExpr("hot".into())),
935            Arc::new(PropExpr("seq".into())),
936            BatchCompletion::Timeout(60_000), // long — nothing fires naturally here
937            16,
938            100,
939            16,
940        );
941
942        // Bucket + timeout task for key "hot" (generation 1, seq 1).
943        assert!(policy.accept(mk_exchange(1)).await.is_empty());
944        let first_gen = {
945            let tasks = policy.timeout_tasks.lock().unwrap();
946            tasks.get("hot").map(|e| e.generation).unwrap()
947        };
948
949        let (start_tx, start_rx) = std::sync::mpsc::channel::<()>();
950        let (supersede_done_tx, supersede_done_rx) = std::sync::mpsc::channel::<()>();
951        let supersede_done_rx = std::sync::Arc::new(std::sync::Mutex::new(supersede_done_rx));
952        *policy.interleave_hook.lock().unwrap() = Some(Arc::new(move || {
953            // Signal the background supersede, then wait (bounded) for it to
954            // complete. Under the CORRECT combined implementation the
955            // supersede blocks on the held `timeout_tasks` lock, this wait
956            // times out, and the take proceeds with the original bucket.
957            // Under a separate check-then-take implementation the supersede
958            // completes in the unlocked gap, this wait succeeds, and the
959            // subsequent take steals the newer bucket — failing the test.
960            start_tx.send(()).expect("hook start signal");
961            let _ = supersede_done_rx
962                .lock()
963                .unwrap()
964                .recv_timeout(std::time::Duration::from_secs(2));
965        }));
966
967        let bg_policy = Arc::clone(&policy);
968        let bg = std::thread::spawn(move || {
969            start_rx.recv().expect("bg start");
970            // Full supersede, as a size-completion + key reuse would do.
971            bg_policy.cancel_timeout("hot");
972            {
973                let mut buckets = bg_policy.buckets.lock().unwrap();
974                buckets.insert(
975                    "hot".to_string(),
976                    Bucket {
977                        exchanges: vec![mk_exchange(2)],
978                    },
979                );
980            }
981            let newer_gen = bg_policy.timeout_generation.fetch_add(1, Ordering::SeqCst) + 1;
982            bg_policy.timeout_tasks.lock().unwrap().insert(
983                "hot".to_string(),
984                TimeoutEntry {
985                    generation: newer_gen,
986                    cancel: CancellationToken::new(),
987                },
988            );
989            supersede_done_tx.send(()).expect("supersede done signal");
990        });
991
992        // The operation under test: guard + take in one critical section.
993        // join() blocks until the background supersede finishes (it is
994        // serialized behind the lock the take holds).
995        let taken = policy.take_bucket_if_current_timeout_task("hot", first_gen);
996        bg.join().expect("background thread clean");
997
998        let taken = taken.expect("generation 1 still owned the take at check time");
999        // The taken bucket is the ORIGINAL (seq 1) — never the newer one.
1000        let seq_of = |ex: &Exchange| ex.property("seq").cloned().unwrap_or_default();
1001        assert_eq!(
1002            seq_of(&taken.exchanges[0]),
1003            serde_json::json!(1),
1004            "combined take must retrieve the original bucket, not steal the newer one"
1005        );
1006        // The newer generation's bucket survived the interleaving.
1007        assert_eq!(
1008            policy.buffered(),
1009            1,
1010            "newer bucket must remain after the interleaved supersede"
1011        );
1012        let newer_bucket = policy.take_bucket("hot").expect("newer bucket present");
1013        assert_eq!(seq_of(&newer_bucket.exchanges[0]), serde_json::json!(2));
1014
1015        policy.flush().await;
1016    }
1017}