Skip to main content

camel_processor/
wire_tap.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Mutex;
4use std::task::{Context, Poll};
5
6use tokio::sync::Semaphore;
7use tokio::task::JoinSet;
8use tower::{Service, ServiceExt};
9
10use camel_api::{CamelError, Exchange};
11
12/// Configuration for [`WireTapService`].
13#[derive(Clone, Default)]
14pub struct WireTapConfig {
15    /// Maximum number of concurrent tap tasks. `None` means unlimited (backward-compatible).
16    pub max_concurrent: Option<usize>,
17}
18
19impl WireTapConfig {
20    /// Create a config with a bounded concurrency limit.
21    pub fn bounded(max_concurrent: usize) -> Self {
22        assert!(max_concurrent > 0, "max_concurrent must be > 0");
23        Self {
24            max_concurrent: Some(max_concurrent),
25        }
26    }
27}
28
29pub struct WireTapService {
30    tap_endpoint: camel_api::BoxProcessor,
31    semaphore: Option<std::sync::Arc<Semaphore>>,
32    in_flight: Mutex<JoinSet<()>>,
33}
34
35// Manual Clone impl: `JoinSet` is not `Clone` (it has an owned background
36// driver task), so the derive cannot be used. We hand out a brand-new
37// JoinSet per clone — the original's spawned tasks are aborted on the
38// original's Drop, not the clone's. This restores the pre-D-L1 Clone
39// contract that `camel-core`'s step compiler relies on via
40// `BoxProcessor::new`.
41impl Clone for WireTapService {
42    fn clone(&self) -> Self {
43        Self {
44            tap_endpoint: self.tap_endpoint.clone(),
45            semaphore: self.semaphore.clone(),
46            in_flight: Mutex::new(JoinSet::new()),
47        }
48    }
49}
50
51impl WireTapService {
52    /// Create a new `WireTapService` with default (unbounded) concurrency.
53    pub fn new(tap_endpoint: camel_api::BoxProcessor) -> Self {
54        Self {
55            tap_endpoint,
56            semaphore: None,
57            in_flight: Mutex::new(JoinSet::new()),
58        }
59    }
60
61    /// Create a new `WireTapService` from a [`WireTapConfig`].
62    pub fn with_config(tap_endpoint: camel_api::BoxProcessor, config: WireTapConfig) -> Self {
63        let semaphore = config
64            .max_concurrent
65            .map(|limit| std::sync::Arc::new(Semaphore::new(limit)));
66        Self {
67            tap_endpoint,
68            semaphore,
69            in_flight: Mutex::new(JoinSet::new()),
70        }
71    }
72}
73
74impl Service<Exchange> for WireTapService {
75    type Response = Exchange;
76    type Error = CamelError;
77    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
78
79    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
80        self.tap_endpoint.poll_ready(cx)
81    }
82
83    fn call(&mut self, exchange: Exchange) -> Self::Future {
84        let mut tap_endpoint = self.tap_endpoint.clone();
85        let tap_exchange = exchange.clone();
86        let semaphore = self.semaphore.clone();
87
88        // allow-unwrap: Mutex cannot be poisoned in normal operation
89        self.in_flight
90            .lock()
91            .expect("in_flight mutex poisoned") // allow-unwrap
92            .spawn(async move {
93                // Acquire semaphore permit if concurrency is bounded.
94                let _permit = match &semaphore {
95                    Some(sem) => match sem.acquire().await {
96                        Ok(p) => Some(p),
97                        Err(_) => {
98                            tracing::warn!("WireTap semaphore closed, dropping tap");
99                            return;
100                        }
101                    },
102                    None => None,
103                };
104
105                if let Err(e) = tap_endpoint.ready().await {
106                    tracing::warn!("WireTap endpoint poll_ready failed: {}", e);
107                    return;
108                }
109                if let Err(e) = tap_endpoint.call(tap_exchange).await {
110                    // log-policy: handler-owned
111                    tracing::warn!("WireTap processing error: {}", e);
112                }
113            });
114        Box::pin(async move { Ok(exchange) })
115    }
116}
117
118/// A Tower layer that produces `WireTapService` instances.
119pub struct WireTapLayer {
120    tap_endpoint: camel_api::BoxProcessor,
121    config: WireTapConfig,
122}
123
124impl WireTapLayer {
125    /// Create a new WireTapLayer with the given tap endpoint processor (unbounded).
126    pub fn new(tap_endpoint: camel_api::BoxProcessor) -> Self {
127        Self {
128            tap_endpoint,
129            config: WireTapConfig::default(),
130        }
131    }
132
133    /// Create a new WireTapLayer with bounded concurrency.
134    pub fn bounded(tap_endpoint: camel_api::BoxProcessor, max_concurrent: usize) -> Self {
135        Self {
136            tap_endpoint,
137            config: WireTapConfig::bounded(max_concurrent),
138        }
139    }
140}
141
142impl<S> tower::Layer<S> for WireTapLayer {
143    type Service = WireTapService;
144
145    fn layer(&self, _inner: S) -> Self::Service {
146        WireTapService::with_config(self.tap_endpoint.clone(), self.config.clone())
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use camel_api::{BoxProcessor, BoxProcessorExt, Message};
154    use std::sync::Arc;
155    use std::sync::atomic::{AtomicUsize, Ordering};
156    use tower::ServiceExt;
157
158    #[tokio::test]
159    async fn test_wire_tap_returns_original_immediately() {
160        let tap_processor = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
161
162        let mut wire_tap = WireTapService::new(tap_processor);
163        let exchange = Exchange::new(Message::new("test message"));
164
165        let result = wire_tap
166            .ready()
167            .await
168            .unwrap()
169            .call(exchange)
170            .await
171            .unwrap();
172
173        assert_eq!(result.input.body.as_text(), Some("test message"));
174    }
175
176    #[tokio::test]
177    async fn test_wire_tap_endpoint_receives_clone() {
178        let received_count = Arc::new(AtomicUsize::new(0));
179        let count_clone = received_count.clone();
180
181        let tap_processor = BoxProcessor::from_fn(move |ex| {
182            let count = count_clone.clone();
183            Box::pin(async move {
184                count.fetch_add(1, Ordering::SeqCst);
185                Ok(ex)
186            })
187        });
188
189        let mut wire_tap = WireTapService::new(tap_processor);
190        let exchange = Exchange::new(Message::new("test"));
191
192        let _result = wire_tap
193            .ready()
194            .await
195            .unwrap()
196            .call(exchange)
197            .await
198            .unwrap();
199
200        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
201
202        assert_eq!(received_count.load(Ordering::SeqCst), 1);
203    }
204
205    #[tokio::test]
206    async fn test_wire_tap_isolates_errors() {
207        let tap_processor = BoxProcessor::from_fn(|_ex| {
208            Box::pin(async move { Err(CamelError::ProcessorError("tap error".into())) })
209        });
210
211        let mut wire_tap = WireTapService::new(tap_processor);
212        let exchange = Exchange::new(Message::new("test"));
213
214        let result = wire_tap.ready().await.unwrap().call(exchange).await;
215
216        assert!(result.is_ok());
217        assert_eq!(result.unwrap().input.body.as_text(), Some("test"));
218    }
219
220    #[tokio::test]
221    async fn test_wire_tap_layer() {
222        use tower::Layer;
223
224        let tap_processor = BoxProcessor::from_fn(|ex| Box::pin(async move { Ok(ex) }));
225
226        let layer = super::WireTapLayer::new(tap_processor);
227        let inner = camel_api::IdentityProcessor;
228        let mut svc = layer.layer(inner);
229
230        let exchange = Exchange::new(Message::new("test"));
231        let result = svc.ready().await.unwrap().call(exchange).await.unwrap();
232
233        assert_eq!(result.input.body.as_text(), Some("test"));
234    }
235
236    #[tokio::test]
237    async fn test_wiretap_bounded_concurrency() {
238        // WireTap with max_concurrent=2: sending 3 slow exchanges must not
239        // spawn more than 2 concurrent tap tasks at any point.
240        let concurrent = Arc::new(AtomicUsize::new(0));
241        let max_concurrent = Arc::new(AtomicUsize::new(0));
242
243        let c = Arc::clone(&concurrent);
244        let mc = Arc::clone(&max_concurrent);
245        let tap_processor = BoxProcessor::from_fn(move |ex| {
246            let c = Arc::clone(&c);
247            let mc = Arc::clone(&mc);
248            Box::pin(async move {
249                let current = c.fetch_add(1, Ordering::SeqCst) + 1;
250                mc.fetch_max(current, Ordering::SeqCst);
251                // Hold the task open so multiple taps overlap.
252                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
253                c.fetch_sub(1, Ordering::SeqCst);
254                Ok(ex)
255            })
256        });
257
258        let config = super::WireTapConfig::bounded(2);
259        let mut svc = super::WireTapService::with_config(tap_processor, config);
260
261        // Send 3 exchanges rapidly — at most 2 tap tasks should run concurrently.
262        for _ in 0..3 {
263            let ex = Exchange::new(Message::new("test"));
264            let _ = svc.ready().await.unwrap().call(ex).await.unwrap();
265        }
266
267        // Wait for all tap tasks to complete.
268        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
269
270        let observed_max = max_concurrent.load(Ordering::SeqCst);
271        assert!(
272            observed_max <= 2,
273            "max concurrency was {observed_max}, expected <= 2"
274        );
275    }
276
277    #[tokio::test]
278    async fn test_wire_tap_drop_aborts_spawned_tasks() {
279        use std::sync::atomic::{AtomicBool, Ordering};
280
281        let task_started = Arc::new(AtomicBool::new(false));
282        let task_completed = Arc::new(AtomicBool::new(false));
283        let started_clone = task_started.clone();
284        let completed_clone = task_completed.clone();
285
286        let tap_processor = BoxProcessor::from_fn(move |_ex| {
287            let started = started_clone.clone();
288            let completed = completed_clone.clone();
289            Box::pin(async move {
290                started.store(true, Ordering::SeqCst);
291                // Simulate slow work that will be interrupted
292                tokio::time::sleep(std::time::Duration::from_secs(10)).await;
293                completed.store(true, Ordering::SeqCst);
294                Ok(Exchange::default())
295            })
296        });
297
298        let mut service = WireTapService::new(tap_processor);
299        let _ = service
300            .ready()
301            .await
302            .unwrap()
303            .call(Exchange::default())
304            .await
305            .unwrap();
306
307        // Give the spawned task time to start
308        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
309        assert!(
310            task_started.load(Ordering::SeqCst),
311            "tap task should be running"
312        );
313        assert!(
314            !task_completed.load(Ordering::SeqCst),
315            "task should not have completed yet"
316        );
317
318        // Drop the service — should abort spawned tasks
319        drop(service);
320
321        // Give abort time to propagate
322        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
323
324        // Task should NOT have completed (it was aborted before the 10s sleep finished)
325        assert!(
326            !task_completed.load(Ordering::SeqCst),
327            "task should have been aborted, not completed"
328        );
329    }
330}