Skip to main content

camel_processor/
intercept_compose.rs

1//! Divert composition for route interception (`advice-route-interception`).
2//!
3//! A divert sends a copy of the exchange to an interception target and then
4//! feeds the original exchange to the real producer. The copy stage is a
5//! public [`WireTapService`]; the real stage is any [`BoxProcessor`].
6
7use std::future::Future;
8use std::pin::Pin;
9use std::task::{Context, Poll};
10
11use tower::{Service, ServiceExt};
12
13use camel_api::{BoxProcessor, CamelError, Exchange};
14
15use crate::wire_tap::WireTapService;
16
17/// Composed divert service: wiretap copy stage, then real producer.
18///
19/// The copy stage runs detached (or inline under CallerRuns saturation) and
20/// its failures are suppressed by the wiretap. The real stage's `Result` is
21/// returned verbatim to the caller.
22#[derive(Clone)]
23struct DivertService {
24    tap: WireTapService,
25    real: BoxProcessor,
26}
27
28impl Service<Exchange> for DivertService {
29    type Response = Exchange;
30    type Error = CamelError;
31    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
32
33    /// Always ready (ADR-0019): real-producer readiness is driven inside
34    /// `call`, so the divert never blocks pipeline admission.
35    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
36        Poll::Ready(Ok(()))
37    }
38
39    fn call(&mut self, exchange: Exchange) -> Self::Future {
40        let mut tap = self.tap.clone();
41        let mut real = self.real.clone();
42        Box::pin(async move {
43            // Copy stage: the wiretap admits or drops the tap and returns the
44            // original exchange. Readiness is unconditional (ADR-0019).
45            let original = tap.ready().await?.call(exchange).await?;
46            // Real stage: drive readiness on this same instance, then call.
47            // A readiness error is returned verbatim and `call` is skipped.
48            real.ready().await?;
49            real.call(original).await
50        })
51    }
52}
53
54/// Compose a divert from a copy stage and a real producer.
55///
56/// `tap` is moved into the composed processor; clone it before the call to
57/// keep a handle for lifecycle wiring — clones share the admission gate.
58pub fn compose_divert(tap: WireTapService, real: BoxProcessor) -> BoxProcessor {
59    BoxProcessor::new(DivertService { tap, real })
60}
61
62#[cfg(test)]
63mod tests {
64    use std::future::Future;
65    use std::pin::Pin;
66    use std::sync::atomic::{AtomicUsize, Ordering};
67    use std::sync::{Arc, Mutex};
68    use std::task::{Context, Poll};
69
70    use tokio::sync::Notify;
71    use tower::{Service, ServiceExt};
72
73    use crate::wire_tap::WireTapService;
74    use camel_api::{BoxProcessor, BoxProcessorExt, CamelError, Exchange, Message, Value};
75
76    use super::*;
77
78    /// Real-producer stub that records readiness and call events in order.
79    /// `poll_ready` pushes `"ready"`; `call` pushes `"call"` and returns the
80    /// exchange stamped with the `X-Sentinel` header.
81    #[derive(Clone)]
82    struct EventRealSvc {
83        events: Arc<Mutex<Vec<&'static str>>>,
84    }
85
86    impl Service<Exchange> for EventRealSvc {
87        type Response = Exchange;
88        type Error = CamelError;
89        type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
90
91        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
92            self.events.lock().unwrap().push("ready"); // allow-unwrap: test-only
93            Poll::Ready(Ok(()))
94        }
95
96        fn call(&mut self, mut ex: Exchange) -> Self::Future {
97            self.events.lock().unwrap().push("call"); // allow-unwrap: test-only
98            ex.input.headers.insert(
99                "X-Sentinel".to_string(),
100                Value::String("real-ok".to_string()),
101            );
102            Box::pin(async move { Ok(ex) })
103        }
104    }
105
106    /// Real-producer stub whose `poll_ready` fails with a sentinel error.
107    /// `call` pushes `"call"` — it must never run.
108    #[derive(Clone)]
109    struct ReadyFailingRealSvc {
110        events: Arc<Mutex<Vec<&'static str>>>,
111    }
112
113    impl Service<Exchange> for ReadyFailingRealSvc {
114        type Response = Exchange;
115        type Error = CamelError;
116        type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
117
118        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
119            Poll::Ready(Err(CamelError::ProcessorError("sentinel-ready".into())))
120        }
121
122        fn call(&mut self, _ex: Exchange) -> Self::Future {
123            self.events.lock().unwrap().push("call"); // allow-unwrap: test-only
124            Box::pin(async move { Ok(Exchange::default()) })
125        }
126    }
127
128    #[tokio::test]
129    async fn real_producer_readiness_is_driven_before_call_success_order() {
130        let events: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
131        let copy_stub = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
132        let real_stub = BoxProcessor::new(EventRealSvc {
133            events: events.clone(),
134        });
135
136        let tap = WireTapService::new(copy_stub);
137        let svc = compose_divert(tap, real_stub);
138
139        let result = svc
140            .oneshot(Exchange::new(Message::new("main")))
141            .await
142            .unwrap();
143
144        assert_eq!(
145            *events.lock().unwrap(), // allow-unwrap: test-only
146            vec!["ready", "call"],
147            "real producer readiness must be driven before call"
148        );
149        assert_eq!(
150            result.input.headers.get("X-Sentinel"),
151            Some(&Value::String("real-ok".to_string())),
152            "returned exchange must be the real producer's sentinel"
153        );
154    }
155
156    #[tokio::test]
157    async fn real_producer_readiness_failure_returns_verbatim_and_skips_call() {
158        let events: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
159        let copy_stub = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
160        let real_stub = BoxProcessor::new(ReadyFailingRealSvc {
161            events: events.clone(),
162        });
163
164        let tap = WireTapService::new(copy_stub);
165        let svc = compose_divert(tap, real_stub);
166
167        let err = svc
168            .oneshot(Exchange::new(Message::new("main")))
169            .await
170            .unwrap_err();
171
172        match err {
173            CamelError::ProcessorError(msg) => assert_eq!(msg, "sentinel-ready"),
174            other => panic!("expected ProcessorError(\"sentinel-ready\"), got {other:?}"),
175        }
176        assert!(
177            events.lock().unwrap().is_empty(), // allow-unwrap: test-only
178            "real producer call must be skipped on readiness failure"
179        );
180    }
181
182    #[tokio::test]
183    async fn wiretap_lifecycle_start_reopens_admission_with_fresh_token() {
184        use camel_api::StepShutdownReason;
185
186        let arrivals = Arc::new(AtomicUsize::new(0));
187        let arrived = Arc::new(Notify::new());
188
189        let counter = arrivals.clone();
190        let notify = arrived.clone();
191        let copy_stub = BoxProcessor::from_fn(move |ex| {
192            let counter = counter.clone();
193            let notify = notify.clone();
194            Box::pin(async move {
195                counter.fetch_add(1, Ordering::SeqCst);
196                notify.notify_one();
197                Ok(ex)
198            })
199        });
200
201        let svc = WireTapService::new(copy_stub);
202        let lifecycle = svc.lifecycle();
203
204        // First shutdown: admission closes, no copy runs.
205        lifecycle
206            .shutdown(StepShutdownReason::RouteStop)
207            .await
208            .unwrap();
209        let _ = svc
210            .clone()
211            .oneshot(Exchange::new(Message::new("after-shutdown")))
212            .await
213            .unwrap();
214        assert_eq!(
215            arrivals.load(Ordering::SeqCst),
216            0,
217            "no copy must run while admission is closed"
218        );
219
220        // Restart: admission reopens with a fresh token and tracker.
221        lifecycle.start().await.unwrap();
222        let _ = svc
223            .clone()
224            .oneshot(Exchange::new(Message::new("after-restart")))
225            .await
226            .unwrap();
227        arrived.notified().await;
228        assert_eq!(
229            arrivals.load(Ordering::SeqCst),
230            1,
231            "copy must arrive after restart reopens admission"
232        );
233
234        // Second shutdown after restart: must be effective, not a no-op.
235        lifecycle
236            .shutdown(StepShutdownReason::RouteStop)
237            .await
238            .unwrap();
239        let _ = svc
240            .clone()
241            .oneshot(Exchange::new(Message::new("after-second-shutdown")))
242            .await
243            .unwrap();
244        assert_eq!(
245            arrivals.load(Ordering::SeqCst),
246            1,
247            "second shutdown after restart must close admission again"
248        );
249    }
250
251    /// `MakeWriter` that appends formatted events to a shared sink, so tests
252    /// can assert that a `warn!` record was emitted.
253    #[derive(Clone)]
254    struct CapturingWriter {
255        sink: Arc<Mutex<Vec<u8>>>,
256    }
257
258    impl std::io::Write for CapturingWriter {
259        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
260            self.sink.lock().unwrap().extend_from_slice(buf); // allow-unwrap: test-only
261            Ok(buf.len())
262        }
263        fn flush(&mut self) -> std::io::Result<()> {
264            Ok(())
265        }
266    }
267
268    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CapturingWriter {
269        type Writer = CapturingWriter;
270        fn make_writer(&'a self) -> Self::Writer {
271            self.clone()
272        }
273    }
274
275    #[tokio::test]
276    async fn copy_call_failure_is_suppressed_and_logged() {
277        let copy_done = Arc::new(Notify::new());
278        let notify = copy_done.clone();
279        let copy_stub = BoxProcessor::from_fn(move |_ex| {
280            let notify = notify.clone();
281            Box::pin(async move {
282                notify.notify_one();
283                Err(CamelError::ProcessorError("copy-boom".into()))
284            })
285        });
286        let real_stub = BoxProcessor::from_fn(|mut ex| {
287            Box::pin(async move {
288                ex.input.headers.insert(
289                    "X-Sentinel".to_string(),
290                    Value::String("real-ok".to_string()),
291                );
292                Ok(ex)
293            })
294        });
295
296        let sink: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
297        let subscriber = tracing_subscriber::fmt()
298            .with_writer(CapturingWriter { sink: sink.clone() })
299            .with_ansi(false)
300            .finish();
301
302        // set_default propagates to tasks spawned on this thread; force a
303        // callsite interest rebuild so warn! re-evaluates against it
304        // (same pattern as the wire_tap tests, bd rc-u9hs).
305        let _guard = tracing::subscriber::set_default(subscriber);
306        tracing::callsite::rebuild_interest_cache();
307
308        let tap = WireTapService::new(copy_stub);
309        let svc = compose_divert(tap, real_stub);
310
311        // Keep `svc` alive: dropping the last divert clone cancels the
312        // shared tap token, which would abort the detached copy before it
313        // signals.
314        let result = svc
315            .clone()
316            .oneshot(Exchange::new(Message::new("main")))
317            .await
318            .unwrap();
319        assert_eq!(
320            result.input.headers.get("X-Sentinel"),
321            Some(&Value::String("real-ok".to_string())),
322            "real producer result must be returned verbatim"
323        );
324
325        // The copy runs detached; await its completion signal before the
326        // warn assertion (the warn fires when the tap task observes the
327        // copy failure).
328        copy_done.notified().await;
329        // Deterministic only on a current-thread runtime (#[tokio::test] default): no await between notify_one and warn! in run_tap; multi_thread would make this flaky.
330        let captured = String::from_utf8(sink.lock().unwrap().clone()).unwrap(); // allow-unwrap: test-only
331        assert!(
332            captured.contains("copy-boom"),
333            "a warn record mentioning the copy failure should have been emitted; got: {captured}"
334        );
335    }
336}