Skip to main content

camel_processor/resequencer/
mod.rs

1//! Resequencer — continuation-boundary EIP.
2//!
3//! The resequencer is a `CompiledStep` + `StepLifecycle`. `call(input)` sends
4//! the input into a bounded actor channel; an actor buffers + computes ready
5//! outputs + sends them to a post-driver that drives the owned post-continuation;
6//! `call()` returns a control ack. The main pipeline ends at the resequencer.
7//!
8//! Architecture: See ADR-0029 (resequencer continuation boundary).
9
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13
14use async_trait::async_trait;
15use tokio::sync::{Mutex as TokioMutex, mpsc};
16use tokio::task::JoinHandle;
17use tower::Service;
18
19pub mod batch;
20pub mod stream;
21
22use camel_api::{
23    BoxProcessor, CamelError, MetricsCollector, StepLifecycle, StepShutdownReason,
24    exchange::Exchange, message::Message, processor::SyncBoxProcessor,
25};
26
27/// Rate-limit window for InOut warning log emission.
28const INOUT_WARN_INTERVAL: Duration = Duration::from_secs(30);
29
30/// Configuration for the `ResequencerService`.
31#[derive(Clone, Default)]
32pub struct ResequencerConfig {
33    /// Allow `InOut` exchanges to pass through the resequencer without
34    /// emitting a warning. Defaults to `false`.
35    pub allow_inout: bool,
36    /// Optional metrics collector for incrementing operational counters.
37    pub metrics: Option<Arc<dyn MetricsCollector>>,
38    /// Optional route ID for metric labels.
39    pub route_id: Option<String>,
40}
41
42impl std::fmt::Debug for ResequencerConfig {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("ResequencerConfig")
45            .field("allow_inout", &self.allow_inout)
46            .field("metrics", &self.metrics.as_ref().map(|_| "<metrics>"))
47            .field("route_id", &self.route_id)
48            .finish()
49    }
50}
51
52// ── Property keys (CamelCase, matching CAMEL_AGGREGATED_COMPLETION_REASON) ──
53
54/// Set on the ack exchange: `true` when the resequencer accepted the input.
55pub const CAMEL_RESEQUENCER_ACCEPTED: &str = "CamelResequencerAccepted";
56
57/// Set on the ack exchange: `true` when the input was dropped during shutdown.
58pub const CAMEL_RESEQUENCER_DROPPED: &str = "CamelResequencerDropped";
59
60/// Set on the ack exchange: `true` when an InOut exchange reaches the resequencer.
61pub const CAMEL_RESEQUENCER_INOUT_WARN: &str = "CamelResequencerInoutWarn";
62
63// ── Policy trait ──
64
65/// Buffer / ordering policy for a resequencer.
66///
67/// Implementations (batch, stream) live in sibling modules.
68#[async_trait]
69pub trait ResequencePolicy: Send + Sync + 'static {
70    /// Accept an input; return the list of now-ready exchanges (in emit order).
71    async fn accept(&self, input: Exchange) -> Vec<Exchange>;
72
73    /// Flush all buffered state (shutdown). Return any remaining, ordered.
74    async fn flush(&self) -> Vec<Exchange>;
75
76    /// Stable name for logging / diagnostics.
77    fn name(&self) -> &'static str;
78
79    /// Number of exchanges currently held awaiting their release condition
80    /// (the awaiting-sequence buffer). Reported by the service as
81    /// `camel_queue_depth{queue="resequencer:<route>"}`. Default 0 for
82    /// stateless policies (passthrough).
83    fn buffered(&self) -> usize {
84        0
85    }
86
87    /// Set the driver channel for timeout-triggered emissions.
88    /// Default is a no-op. `BatchPolicy` overrides this to receive
89    /// the channel that feeds the post-driver.
90    fn set_timeout_tx(&self, _tx: tokio::sync::mpsc::Sender<Exchange>) {}
91}
92
93// ── Service ──
94
95/// Continuation-boundary resequencer.
96///
97/// Owns an actor task (consumes from input channel, calls `policy.accept(input)`)
98/// and a post-driver task (consumes ready exchanges, drives `post_continuation`).
99/// `Service::call(input)` sends into the bounded input channel and returns an ack.
100#[derive(Clone)]
101pub struct ResequencerService {
102    policy: Arc<dyn ResequencePolicy>,
103    config: ResequencerConfig,
104    /// Bounded input channel sender. Wrapped in `Option` so `shutdown` can
105    /// `take()` it to signal EOF to the actor (all sender clones must drop).
106    input_tx: Arc<Mutex<Option<mpsc::Sender<Exchange>>>>,
107    /// Post-driver channel sender. The actor task holds a clone; we hold one
108    /// here for shutdown flush. Wrapped in `Option` so `shutdown` can take it
109    /// to close the post-driver channel after flush.
110    driver_tx: Arc<Mutex<Option<mpsc::Sender<Exchange>>>>,
111    actor_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
112    driver_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
113    shutdown_started: Arc<Mutex<bool>>,
114    /// Post-step lifecycles to drain after post-driver quiesces (oracle Fix 2).
115    /// Empty for Task 1a (no post-steps yet); filled by Tasks 1b/2/3.
116    post_lifecycles: Arc<Mutex<Vec<Arc<dyn StepLifecycle>>>>,
117    /// Metric counter for InOut exchanges that reach the resequencer.
118    inout_counter: Arc<AtomicU64>,
119    /// Rate-limit last-warn timestamp (TokioMutex for async safety).
120    last_inout_warn: Arc<TokioMutex<Option<Instant>>>,
121    /// Optional metrics collector for operational counters.
122    metrics: Option<Arc<dyn MetricsCollector>>,
123    /// Route ID for metric labels.
124    route_id: Option<String>,
125}
126
127impl std::fmt::Debug for ResequencerService {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("ResequencerService")
130            .field("policy", &self.policy.name())
131            .finish_non_exhaustive()
132    }
133}
134
135impl ResequencerService {
136    /// Create a new resequencer with the given policy, continuation, and post-step lifecycles.
137    ///
138    /// * `input_capacity` — bounded channel capacity (default 1024). Backpressure
139    ///   propagates to the caller via `send().await`.
140    /// * `post_lifecycles` — lifecycle handles for steps AFTER the resequencer
141    ///   (drained in `shutdown` after post-driver quiesces; empty for Task 1a).
142    ///
143    /// # Panics
144    ///
145    /// Panics if called outside a Tokio runtime context: `new()` spawns the actor and
146    /// post-driver tasks via `tokio::spawn`.
147    pub fn new(
148        policy: Arc<dyn ResequencePolicy>,
149        post_continuation: BoxProcessor,
150        input_capacity: usize,
151        post_lifecycles: Vec<Arc<dyn StepLifecycle>>,
152    ) -> Self {
153        Self::with_config(
154            policy,
155            post_continuation,
156            input_capacity,
157            post_lifecycles,
158            ResequencerConfig::default(),
159        )
160    }
161
162    /// Full constructor with explicit `ResequencerConfig`.
163    ///
164    /// # Panics
165    ///
166    /// Panics if called outside a Tokio runtime context.
167    pub fn with_config(
168        policy: Arc<dyn ResequencePolicy>,
169        post_continuation: BoxProcessor,
170        input_capacity: usize,
171        post_lifecycles: Vec<Arc<dyn StepLifecycle>>,
172        config: ResequencerConfig,
173    ) -> Self {
174        // Bounded channel for input exchanges → actor
175        let (input_tx, mut input_rx) = mpsc::channel::<Exchange>(input_capacity);
176
177        // Post-driver channel: actor → post-driver (bounded, single consumer)
178        let (driver_tx, mut driver_rx) = mpsc::channel::<Exchange>(input_capacity);
179
180        // Shared (Arc<Mutex<Option<T>>>) wrappers
181        let input_tx_shared: Arc<Mutex<Option<mpsc::Sender<Exchange>>>> =
182            Arc::new(Mutex::new(Some(input_tx)));
183        let driver_tx_shared: Arc<Mutex<Option<mpsc::Sender<Exchange>>>> =
184            Arc::new(Mutex::new(Some(driver_tx.clone()))); // we keep one for flush; actor gets clone
185        let actor_handle: Arc<Mutex<Option<JoinHandle<()>>>> = Arc::new(Mutex::new(None));
186        let driver_handle: Arc<Mutex<Option<JoinHandle<()>>>> = Arc::new(Mutex::new(None));
187        let shutdown_started = Arc::new(Mutex::new(false));
188
189        // Wire the driver channel into the policy for timeout-triggered emissions
190        policy.set_timeout_tx(driver_tx.clone());
191
192        // Thread-safe wrapper for the post-continuation
193        let sync_post = SyncBoxProcessor::new(post_continuation);
194
195        // ── Spawn actor task ──
196        {
197            let policy = Arc::clone(&policy);
198            let actor_h = Arc::clone(&actor_handle);
199            let actor_driver_tx = driver_tx; // move the original sender into the actor
200            // Queue-depth sampling: the actor loop is the resequencer's
201            // maintenance pass (there is no timer-driven sweep in the
202            // service). After each accept, publish the policy's
203            // awaiting-sequence buffer size under `resequencer:<route>`.
204            let queue_metrics = config.metrics.clone();
205            let queue_label = config
206                .route_id
207                .as_ref()
208                .map(|route| format!("resequencer:{route}"));
209            let handle = tokio::spawn(async move {
210                while let Some(input) = input_rx.recv().await {
211                    let ready = policy.accept(input).await;
212                    for ex in ready {
213                        // If the post-driver channel is closed, stop
214                        if actor_driver_tx.send(ex).await.is_err() {
215                            // post-driver dropped → exit
216                            return;
217                        }
218                    }
219                    if let (Some(metrics), Some(label)) =
220                        (queue_metrics.as_ref(), queue_label.as_ref())
221                    {
222                        metrics.set_queue_depth(label, policy.buffered());
223                    }
224                }
225                // input channel closed (EOF from shutdown) → exit naturally
226            });
227            *actor_h.lock().expect("actor_handle lock poisoned") = Some(handle); // allow-unwrap: handle slot is None at construction time
228        }
229
230        // ── Spawn post-driver task ──
231        {
232            let post = sync_post.clone();
233            let driver_h = Arc::clone(&driver_handle);
234            let metrics = config.metrics.clone();
235            let route_id = config.route_id.clone();
236            let handle = tokio::spawn(async move {
237                while let Some(ex) = driver_rx.recv().await {
238                    // CamelStop interaction: skip continuation for stop-signaled exchanges
239                    if camel_api::is_camel_stop(&ex) {
240                        tracing::debug!(
241                            "resequencer post-driver: skipping continuation for CamelStop exchange"
242                        );
243                        continue;
244                    }
245                    // Clone inner processor (cheap: Arc bump + Mutex briefly held)
246                    let mut proc = post.clone_inner();
247                    match proc.call(ex).await {
248                        Ok(_) => {}
249                        Err(e) => {
250                            // log-policy: post-ack failure (ADR-0012 best-effort, ADR-0029 I7)
251                            tracing::warn!(
252                                error = %e,
253                                "resequencer post-driver: continuation call failed after ack (best-effort)"
254                            );
255                            if let Some(ref m) = metrics {
256                                m.increment_errors(
257                                    route_id.as_deref().unwrap_or("unknown"),
258                                    "resequencer:post_ack_failure",
259                                );
260                            }
261                        }
262                    }
263                }
264            });
265            *driver_h.lock().expect("driver_handle lock poisoned") = Some(handle); // allow-unwrap: handle slot is None at construction time
266        }
267
268        let metrics = config.metrics.clone();
269        let route_id = config.route_id.clone();
270        Self {
271            policy,
272            config,
273            input_tx: input_tx_shared,
274            driver_tx: driver_tx_shared,
275            actor_handle,
276            driver_handle,
277            shutdown_started,
278            post_lifecycles: Arc::new(Mutex::new(post_lifecycles)),
279            inout_counter: Arc::new(AtomicU64::new(0)),
280            last_inout_warn: Arc::new(TokioMutex::new(None)),
281            metrics,
282            route_id,
283        }
284    }
285}
286
287// ── Tower Service impl ──
288
289impl Service<Exchange> for ResequencerService {
290    type Response = Exchange;
291    type Error = CamelError;
292    type Future =
293        std::pin::Pin<Box<dyn std::future::Future<Output = Result<Exchange, CamelError>> + Send>>;
294
295    fn poll_ready(
296        &mut self,
297        _cx: &mut std::task::Context<'_>,
298    ) -> std::task::Poll<Result<(), CamelError>> {
299        // ADR-0019: always ready; backpressure via bounded send().await in call()
300        std::task::Poll::Ready(Ok(()))
301    }
302
303    fn call(&mut self, input: Exchange) -> Self::Future {
304        let config = self.config.clone();
305        let inout_counter = Arc::clone(&self.inout_counter);
306        let last_inout_warn = Arc::clone(&self.last_inout_warn);
307        let tx_opt = Arc::clone(&self.input_tx);
308        let metrics = self.metrics.clone();
309        let route_id = self.route_id.clone();
310
311        Box::pin(async move {
312            // Build ack exchange
313            let mut ack = Exchange::new(Message::default());
314
315            // InOut guard (I6): rate-limited with metric counter and property flag
316            if input.pattern == camel_api::exchange::ExchangePattern::InOut && !config.allow_inout {
317                inout_counter.fetch_add(1, Ordering::Relaxed);
318                ack.set_property(CAMEL_RESEQUENCER_INOUT_WARN, true);
319                if let Some(ref m) = metrics {
320                    m.increment_errors(
321                        route_id.as_deref().unwrap_or("unknown"),
322                        "resequencer:inout_warning",
323                    );
324                }
325                let now = Instant::now();
326                let mut last_guard = last_inout_warn.lock().await;
327                let should_warn = last_guard
328                    .map(|t| now.duration_since(t) >= INOUT_WARN_INTERVAL)
329                    .unwrap_or(true);
330                if should_warn {
331                    let count = inout_counter.load(Ordering::Relaxed);
332                    tracing::warn!(
333                        inout_count = count,
334                        "InOut exchange reached resequencer ({count} total); \
335                         consider using InOnly pattern. \
336                         Set allow_inout=true to suppress this warning."
337                    );
338                    *last_guard = Some(now);
339                }
340            }
341
342            // Snapshot the sender (if still active). If shutdown already took it,
343            // the exchange is dropped — best-effort (intake already cancelled).
344            let tx = {
345                let guard = tx_opt.lock().unwrap_or_else(|e| e.into_inner());
346                guard.clone()
347            };
348            if let Some(tx) = tx {
349                // Backpressure: blocks if channel is full
350                match tx.send(input).await {
351                    Ok(()) => {
352                        ack.set_property(CAMEL_RESEQUENCER_ACCEPTED, true);
353                    }
354                    Err(tokio::sync::mpsc::error::SendError(input)) => {
355                        tracing::warn!(
356                            correlation_id = %input.correlation_id,
357                            "resequencer input dropped during shutdown"
358                        );
359                        ack.set_property(CAMEL_RESEQUENCER_ACCEPTED, false);
360                        ack.set_property(CAMEL_RESEQUENCER_DROPPED, true);
361                    }
362                }
363            }
364
365            Ok(ack)
366        })
367    }
368}
369
370// ── StepLifecycle impl ──
371
372#[async_trait]
373impl StepLifecycle for ResequencerService {
374    fn name(&self) -> &'static str {
375        self.policy.name()
376    }
377
378    /// Idempotent shutdown with this ordering:
379    /// 1. Set shutdown flag; close/drop input_tx so actor sees EOF.
380    /// 2. Await actor JoinHandle (bounded deadline).
381    /// 3. policy.flush() → emit remaining in order via post-driver.
382    /// 4. Close post-driver channel sender so its loop sees EOF.
383    /// 5. Await post-driver JoinHandle with 5s deadline.
384    /// 6. Drain post-step lifecycles (oracle Fix 2).
385    async fn shutdown(&self, reason: StepShutdownReason) -> Result<(), CamelError> {
386        // TODO(Task 1b): differentiate HotSwap (complete in-flight through old continuation,
387        // ADR-0004) from RouteStop (flush + drain). Currently both paths run the same
388        // flush-then-close sequence.
389        tracing::debug!(
390            reason = ?reason,
391            policy = self.policy.name(),
392            "ResequencerService shutdown via StepLifecycle"
393        );
394
395        // Idempotent guard (I1)
396        {
397            let mut started = self
398                .shutdown_started
399                .lock()
400                .unwrap_or_else(|e| e.into_inner());
401            if *started {
402                tracing::debug!(
403                    "ResequencerService shutdown already started (idempotent); skipping"
404                );
405                return Ok(());
406            }
407            *started = true;
408        }
409
410        // Step 1: Close/drop input_tx so actor's input_rx sees EOF.
411        // Taking the sender from the Option drops it. The shared Arc<Mutex<...>>
412        // means all clones see None after this.
413        {
414            let mut guard = self.input_tx.lock().unwrap_or_else(|e| e.into_inner());
415            *guard = None; // drop the sender
416        }
417
418        // Step 2: Await actor JoinHandle (bounded deadline).
419        // Extract handle first, then await outside the lock to avoid Send issue.
420        let actor_handle_to_await = {
421            let mut guard = self.actor_handle.lock().unwrap_or_else(|e| e.into_inner());
422            guard.take()
423        };
424        if let Some(handle) = actor_handle_to_await {
425            // 5s deadline for actor to finish processing remaining input
426            let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
427        }
428
429        // Step 3: policy.flush() → emit remaining in order via post-driver.
430        let flushed = self.policy.flush().await;
431        if !flushed.is_empty() {
432            let dt = {
433                let guard = self.driver_tx.lock().unwrap_or_else(|e| e.into_inner());
434                guard.clone()
435            };
436            if let Some(driver_tx) = dt {
437                for ex in flushed {
438                    if driver_tx.send(ex).await.is_err() {
439                        tracing::warn!(
440                            "resequencer shutdown flush: post-driver channel closed early"
441                        );
442                        break;
443                    }
444                }
445            }
446        }
447
448        // Step 4: Close the post-driver channel sender so its loop sees EOF.
449        {
450            let mut guard = self.driver_tx.lock().unwrap_or_else(|e| e.into_inner());
451            *guard = None; // drop the sender → driver_rx.recv() returns None
452        }
453
454        // Step 5: Await the post-driver JoinHandle with a 5s deadline.
455        let driver_handle_to_await = {
456            let mut guard = self.driver_handle.lock().unwrap_or_else(|e| e.into_inner());
457            guard.take()
458        };
459        if let Some(handle) = driver_handle_to_await {
460            let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
461            if result.is_err() {
462                tracing::warn!(
463                    "resequencer post-driver task did not finish within 5s deadline; \
464                     leaking handle (best-effort)"
465                );
466            }
467        }
468
469        // Step 6: Drain post-step lifecycles (oracle Fix 2).
470        // Must happen AFTER post-driver drains (flush emits through continuation first).
471        {
472            let post_lcs: Vec<Arc<dyn StepLifecycle>> = {
473                let mut guard = self
474                    .post_lifecycles
475                    .lock()
476                    .unwrap_or_else(|e| e.into_inner());
477                std::mem::take(&mut *guard)
478            };
479            for lc in &post_lcs {
480                if let Err(e) = lc.shutdown(reason).await {
481                    tracing::warn!(
482                        step = lc.name(),
483                        error = %e,
484                        "resequencer post-step lifecycle shutdown failed (best-effort)"
485                    );
486                }
487            }
488        }
489
490        Ok(())
491    }
492}
493
494// ── Passthrough policy (for testing) ──
495
496/// Emits each input unchanged — used as a baseline policy for testing
497/// the continuation-boundary mechanics.
498#[derive(Debug)]
499pub struct PassthroughPolicy;
500
501#[async_trait]
502impl ResequencePolicy for PassthroughPolicy {
503    async fn accept(&self, input: Exchange) -> Vec<Exchange> {
504        vec![input]
505    }
506
507    async fn flush(&self) -> Vec<Exchange> {
508        vec![]
509    }
510
511    fn name(&self) -> &'static str {
512        "passthrough"
513    }
514}
515
516// ── Tests ──
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use tower::ServiceExt;
522
523    /// A test continuation that sends received exchanges through an mpsc channel.
524    #[derive(Clone)]
525    struct CapturePost {
526        tx: mpsc::UnboundedSender<Exchange>,
527    }
528
529    impl Service<Exchange> for CapturePost {
530        type Response = Exchange;
531        type Error = CamelError;
532        type Future = std::pin::Pin<
533            Box<dyn std::future::Future<Output = Result<Exchange, CamelError>> + Send>,
534        >;
535
536        fn poll_ready(
537            &mut self,
538            _cx: &mut std::task::Context<'_>,
539        ) -> std::task::Poll<Result<(), CamelError>> {
540            std::task::Poll::Ready(Ok(()))
541        }
542
543        fn call(&mut self, exchange: Exchange) -> Self::Future {
544            let tx = self.tx.clone();
545            Box::pin(async move {
546                // Fire-and-forget: drop send errors (receiver gone = test teardown)
547                let _ = tx.send(exchange.clone());
548                Ok(exchange)
549            })
550        }
551    }
552
553    #[tokio::test]
554    async fn resequencer_boundary_passthrough_ack_and_continuation() {
555        use camel_api::body::Body;
556
557        // Build a resequencer with passthrough policy + channel capture continuation
558        let policy: Arc<dyn ResequencePolicy> = Arc::new(PassthroughPolicy);
559        let (capture_tx, mut capture_rx) = mpsc::unbounded_channel::<Exchange>();
560        let capture = CapturePost { tx: capture_tx };
561        let post_continuation: BoxProcessor = BoxProcessor::new(capture);
562
563        let service = ResequencerService::new(policy, post_continuation, 1024, vec![]);
564
565        // Send an exchange
566        let mut input = Exchange::new(Message::new(Body::Text("hello".into())));
567        input.set_property("seq", 1);
568
569        let ack = service.clone().oneshot(input).await.unwrap();
570
571        // Assert (a): ack has Body::Empty + CAMEL_RESEQUENCER_ACCEPTED=true
572        assert!(
573            matches!(ack.input.body, Body::Empty),
574            "ack body should be Empty, got {:?}",
575            ack.input.body
576        );
577        assert_eq!(
578            ack.property(CAMEL_RESEQUENCER_ACCEPTED)
579                .and_then(|v| v.as_bool()),
580            Some(true),
581            "CAMEL_RESEQUENCER_ACCEPTED should be true"
582        );
583
584        // Assert (b): the post-continuation receives the input payload
585        let captured = tokio::time::timeout(Duration::from_millis(500), capture_rx.recv())
586            .await
587            .expect("post-continuation did not receive exchange within 500ms timeout")
588            .expect("capture channel closed without receiving exchange");
589        let body = captured.input.body.as_text();
590        assert_eq!(
591            body,
592            Some("hello"),
593            "post-continuation received body should match"
594        );
595
596        // Assert (c): shutdown is idempotent (calling twice returns Ok)
597        service
598            .shutdown(StepShutdownReason::RouteStop)
599            .await
600            .expect("first shutdown should succeed");
601
602        service
603            .shutdown(StepShutdownReason::RouteStop)
604            .await
605            .expect("second shutdown should succeed (idempotent)");
606    }
607
608    #[tokio::test]
609    async fn resequencer_boundary_camel_stop_skipped() {
610        use camel_api::body::Body;
611
612        let policy: Arc<dyn ResequencePolicy> = Arc::new(PassthroughPolicy);
613        let (capture_tx, mut capture_rx) = mpsc::unbounded_channel::<Exchange>();
614        let capture = CapturePost { tx: capture_tx };
615        let post_continuation: BoxProcessor = BoxProcessor::new(capture);
616
617        let service = ResequencerService::new(policy, post_continuation, 1024, vec![]);
618
619        // Send an exchange flagged with CamelStop
620        let mut input = Exchange::new(Message::new(Body::Text(
621            "should-not-reach-continuation".into(),
622        )));
623        input.set_property(camel_api::exchange::CAMEL_STOP, true);
624
625        let ack = service.clone().oneshot(input).await.unwrap();
626
627        // Assert: actor accepted the exchange (ack is returned)
628        assert_eq!(
629            ack.property(CAMEL_RESEQUENCER_ACCEPTED)
630                .and_then(|v| v.as_bool()),
631            Some(true),
632            "CamelStop exchange should still be accepted by resequencer actor"
633        );
634
635        // Assert: the post-continuation does NOT receive the CamelStop exchange
636        let did_receive = tokio::time::timeout(Duration::from_millis(500), capture_rx.recv()).await;
637        match did_receive {
638            Ok(Some(_)) => panic!("CamelStop exchange should NOT reach post-continuation"),
639            Ok(None) => {}      // channel closed (expected in some teardown scenarios)
640            Err(_elapsed) => {} // timeout is the expected path: nothing arrived
641        }
642
643        // Cleanup
644        service
645            .shutdown(StepShutdownReason::RouteStop)
646            .await
647            .expect("shutdown should succeed");
648    }
649
650    #[tokio::test]
651    async fn inout_guard_increments_counter() {
652        let policy: Arc<dyn ResequencePolicy> = Arc::new(PassthroughPolicy);
653        let (tx, _rx) = mpsc::unbounded_channel::<Exchange>();
654        let post: BoxProcessor = BoxProcessor::new(CapturePost { tx });
655        let config = ResequencerConfig::default();
656        let service = ResequencerService::with_config(policy, post, 16, vec![], config);
657
658        // InOnly should NOT increment counter
659        let ex_inonly = Exchange::new(Message::new("inonly"));
660        let _ = service.clone().oneshot(ex_inonly).await.unwrap();
661
662        // InOut SHOULD increment counter
663        let ex_inout = Exchange::new_in_out(Message::new("inout"));
664        let _ = service.clone().oneshot(ex_inout).await.unwrap();
665        assert!(
666            service.inout_counter.load(Ordering::Relaxed) > 0,
667            "InOut counter should be > 0 after InOut exchange"
668        );
669
670        service
671            .shutdown(StepShutdownReason::RouteStop)
672            .await
673            .expect("shutdown");
674    }
675
676    #[tokio::test]
677    async fn inout_guard_allow_inout_suppresses() {
678        let policy: Arc<dyn ResequencePolicy> = Arc::new(PassthroughPolicy);
679        let (tx, _rx) = mpsc::unbounded_channel::<Exchange>();
680        let post: BoxProcessor = BoxProcessor::new(CapturePost { tx });
681        let config = ResequencerConfig {
682            allow_inout: true,
683            ..Default::default()
684        };
685        let service = ResequencerService::with_config(policy, post, 16, vec![], config);
686
687        let ex_inout = Exchange::new_in_out(Message::new("inout-allowed"));
688        let _ = service.clone().oneshot(ex_inout).await.unwrap();
689        assert_eq!(
690            service.inout_counter.load(Ordering::Relaxed),
691            0,
692            "InOut counter should be 0 when allow_inout=true"
693        );
694
695        service
696            .shutdown(StepShutdownReason::RouteStop)
697            .await
698            .expect("shutdown");
699    }
700}