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