Skip to main content

camel_processor/
idempotent_consumer.rs

1//! Idempotent Consumer EIP — outcome-aware Segment implementation.
2//!
3//! Implements the Idempotent Consumer pattern at the `OutcomePipeline` layer
4//! (one layer above Tower), so that `PipelineOutcome::Stopped` from the child
5//! sub-pipeline propagates with the Exchange intact (ADR-0024, ADR-0025).
6//!
7//! # Why Segment-mode (NOT Process-mode)
8//!
9//! `compose_pipeline()` translates `PipelineOutcome::Stopped → Ok(ex)` for
10//! Tower `Service<Exchange>` consumers. If this EIP were a Tower Service,
11//! a duplicate-detected `Stopped` would become `Ok(ex)` and downstream steps
12//! would re-process the duplicate. By implementing `OutcomePipeline` directly,
13//! the segment returns `PipelineOutcome::Completed(ex)` for duplicates and
14//! skips the child sub-pipeline entirely.
15//!
16//! # Contract C1
17//!
18//! `IdempotentRepository::contains` returns `Result<bool, CamelError>` because
19//! backends (Redis, JDBC) can have transient read failures. The segment
20//! propagates `Err` as `PipelineOutcome::Failed` — it NEVER treats a failed
21//! read as "not a duplicate."
22
23use std::future::Future;
24use std::pin::Pin;
25use std::sync::Arc;
26
27use camel_api::{
28    BoxValueFuture, Exchange, IdempotentRepository, OutcomePipeline, OutcomeSegment,
29    PipelineOutcome, Value,
30};
31
32/// Fallible message-id source for the idempotent consumer.
33///
34/// The sync arm returns `None` when no key can be extracted — in that case
35/// the segment forwards the exchange to the child sub-pipeline without
36/// idempotency (matching Apache Camel semantics). The async arm may fail
37/// with a [`camel_api::CamelError`]; the failure fails the step before the
38/// child runs. Non-scalar values (array/object) are rejected.
39// The closure shapes are part of the published contract (mirroring
40// camel-api's source enums); keep the signatures literal.
41#[allow(clippy::type_complexity)]
42#[derive(Clone)]
43pub enum MessageIdSource {
44    /// Programmatic synchronous extractor.
45    Sync(Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync>),
46    /// Language-backed asynchronous expression.
47    Async(Arc<dyn Fn(&Exchange) -> BoxValueFuture + Send + Sync>),
48}
49
50impl MessageIdSource {
51    /// Extract the optional message id, propagating async failures.
52    pub async fn message_id(
53        &self,
54        exchange: &Exchange,
55    ) -> Result<Option<String>, camel_api::CamelError> {
56        match self {
57            Self::Sync(f) => Ok(f(exchange)),
58            Self::Async(f) => {
59                let value = f(exchange).await?;
60                Ok(match value {
61                    Value::Null => None,
62                    Value::String(s) if s.is_empty() => None,
63                    Value::String(s) => Some(s),
64                    Value::Array(_) | Value::Object(_) => {
65                        return Err(camel_api::CamelError::ProcessorError(
66                            "message id expression returned a non-scalar value (array/object); expected a string"
67                                .into(),
68                        ));
69                    }
70                    other => Some(other.to_string()),
71                })
72            }
73        }
74    }
75}
76
77/// Outcome-aware Idempotent Consumer segment.
78///
79/// Wraps a child sub-pipeline (`OutcomeSegment`) and a named
80/// `IdempotentRepository`. On each exchange:
81///
82/// 1. Extract message-id via `message_id(&exchange)`. `None` → forward to child.
83/// 2. Check `repo.contains(&key)`:
84///    - `Ok(true)` → duplicate, return `Completed(exchange)` (skip child).
85///    - `Ok(false)` → proceed.
86///    - `Err(e)` → `Failed(e)` (contract C1).
87/// 3. If `eager`: `repo.add(&key)` before child. `Ok(false)` = race duplicate
88///    → `Completed`. `Err` → `Failed`.
89/// 4. Run child pipeline.
90/// 5. Post-child:
91///    - `Completed` + non-eager → `repo.add(&key)` (best-effort).
92///    - `Stopped` → no add (pipeline terminating).
93///    - `Failed` + `eager` + `remove_on_failure` → `repo.remove(&key)` (best-effort).
94///
95/// # Eager mode + Stopped
96///
97/// In eager mode, the key is added BEFORE the child pipeline runs. If the child
98/// returns `Stopped`, the key remains registered — subsequent deliveries of the
99/// same message ID are treated as duplicates and return `Completed` immediately.
100/// This is intentional: a Stop indicates the route chose to terminate, and
101/// re-processing the same message would be incorrect.
102pub struct IdempotentConsumerSegment {
103    repository: Arc<dyn IdempotentRepository>,
104    message_id: MessageIdSource,
105    child_pipeline: OutcomeSegment,
106    eager: bool,
107    remove_on_failure: bool,
108}
109
110impl IdempotentConsumerSegment {
111    /// Build a new segment from a resolved repository, message-id extractor,
112    /// child sub-pipeline, and behaviour flags.
113    pub fn new(
114        repository: Arc<dyn IdempotentRepository>,
115        message_id: MessageIdSource,
116        child_pipeline: OutcomeSegment,
117        eager: bool,
118        remove_on_failure: bool,
119    ) -> Self {
120        Self {
121            repository,
122            message_id,
123            child_pipeline,
124            eager,
125            remove_on_failure,
126        }
127    }
128}
129
130impl Clone for IdempotentConsumerSegment {
131    fn clone(&self) -> Self {
132        Self {
133            repository: Arc::clone(&self.repository),
134            message_id: self.message_id.clone(),
135            child_pipeline: self.child_pipeline.clone(),
136            eager: self.eager,
137            remove_on_failure: self.remove_on_failure,
138        }
139    }
140}
141
142impl OutcomePipeline for IdempotentConsumerSegment {
143    fn clone_box(&self) -> Box<dyn OutcomePipeline> {
144        Box::new(self.clone())
145    }
146
147    fn run<'a>(
148        &'a mut self,
149        exchange: Exchange,
150    ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
151        Box::pin(async move {
152            // 1. Extract message-id. None → no idempotency possible, forward
153            //    to child; a failed evaluation fails the step (contract C1).
154            let key = match self.message_id.message_id(&exchange).await {
155                Ok(Some(k)) => k,
156                Ok(None) => return self.child_pipeline.run(exchange).await,
157                Err(e) => return PipelineOutcome::Failed(e),
158            };
159
160            // 2. Check repository (contract C1: propagate Err, never treat as non-duplicate).
161            match self.repository.contains(&key).await {
162                Ok(true) => return PipelineOutcome::Completed(exchange),
163                Ok(false) => {}
164                Err(e) => return PipelineOutcome::Failed(e),
165            }
166
167            // 3. Eager mode: reserve the key before running the child.
168            if self.eager {
169                match self.repository.add(&key).await {
170                    Ok(true) => {}                                            // reserved, proceed
171                    Ok(false) => return PipelineOutcome::Completed(exchange), // race duplicate
172                    Err(e) => return PipelineOutcome::Failed(e),
173                }
174            }
175
176            // 4. Run child sub-pipeline.
177            let outcome = self.child_pipeline.run(exchange).await;
178
179            // 5. Post-child bookkeeping (best-effort; log transient errors).
180            match &outcome {
181                PipelineOutcome::Completed(_) if !self.eager => {
182                    if let Err(e) = self.repository.add(&key).await {
183                        tracing::warn!(
184                            error = %e,
185                            repository = %self.repository.name(),
186                            key = %key,
187                            "idempotent repository add failed post-success; next delivery may re-process"
188                        );
189                    }
190                }
191                PipelineOutcome::Failed(_) if self.eager && self.remove_on_failure => {
192                    if let Err(e) = self.repository.remove(&key).await {
193                        tracing::warn!(
194                            error = %e,
195                            repository = %self.repository.name(),
196                            key = %key,
197                            "idempotent repository remove failed on failure rollback"
198                        );
199                    }
200                }
201                _ => {}
202            }
203
204            outcome
205        })
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use async_trait::async_trait;
213    use camel_api::{CamelError, Exchange, Message, Value};
214    use std::collections::HashSet;
215    use tokio::sync::Mutex;
216
217    // ── Test helpers ──
218
219    /// In-memory mock repository backed by a `Mutex<HashSet>`. Allows tests
220    /// to pre-populate keys and inspect state after the segment runs.
221    #[derive(Debug, Default)]
222    struct MockRepo {
223        keys: Mutex<HashSet<String>>,
224        fail_contains: bool,
225    }
226
227    impl MockRepo {
228        fn new() -> Self {
229            Self::default()
230        }
231
232        fn failing_contains() -> Self {
233            Self {
234                keys: Mutex::new(HashSet::new()),
235                fail_contains: true,
236            }
237        }
238
239        async fn pre_add(&self, key: &str) {
240            self.keys.lock().await.insert(key.to_string());
241        }
242
243        async fn contains_key(&self, key: &str) -> bool {
244            self.keys.lock().await.contains(key)
245        }
246    }
247
248    #[async_trait]
249    impl IdempotentRepository for MockRepo {
250        fn name(&self) -> &str {
251            "mock"
252        }
253
254        async fn contains(&self, key: &str) -> Result<bool, CamelError> {
255            if self.fail_contains {
256                return Err(CamelError::ProcessorError(
257                    "synthetic contains failure".into(),
258                ));
259            }
260            Ok(self.keys.lock().await.contains(key))
261        }
262
263        async fn add(&self, key: &str) -> Result<bool, CamelError> {
264            let mut guard = self.keys.lock().await;
265            Ok(guard.insert(key.to_string()))
266        }
267
268        async fn remove(&self, key: &str) -> Result<(), CamelError> {
269            self.keys.lock().await.remove(key);
270            Ok(())
271        }
272
273        async fn clear(&self) -> Result<(), CamelError> {
274            self.keys.lock().await.clear();
275            Ok(())
276        }
277    }
278
279    /// Test segment that returns a configurable outcome and records whether
280    /// it was invoked. Used to verify the segment's child-dispatch behaviour.
281    struct ScriptedChild {
282        outcome: PipelineOutcome,
283        invoked: Arc<std::sync::atomic::AtomicBool>,
284    }
285
286    impl OutcomePipeline for ScriptedChild {
287        fn clone_box(&self) -> Box<dyn OutcomePipeline> {
288            // clone_box is required by the trait but unused by these tests
289            // (each test constructs a fresh segment).
290            unreachable!("clone_box not used in idempotent_consumer tests")
291        }
292
293        fn run<'a>(
294            &'a mut self,
295            exchange: Exchange,
296        ) -> Pin<Box<dyn Future<Output = PipelineOutcome> + Send + 'a>> {
297            self.invoked
298                .store(true, std::sync::atomic::Ordering::SeqCst);
299            let outcome = std::mem::replace(
300                &mut self.outcome,
301                PipelineOutcome::Completed(Exchange::new(Message::new(""))),
302            );
303            Box::pin(async move { outcome_with_exchange(outcome, exchange) })
304        }
305    }
306
307    /// Replace the placeholder exchange inside a scripted outcome with the
308    /// actual exchange passed to `run`. Keeps the variant, swaps the payload.
309    fn outcome_with_exchange(outcome: PipelineOutcome, exchange: Exchange) -> PipelineOutcome {
310        match outcome {
311            PipelineOutcome::Completed(_) => PipelineOutcome::Completed(exchange),
312            PipelineOutcome::Stopped(_) => PipelineOutcome::Stopped(exchange),
313            PipelineOutcome::Failed(e) => PipelineOutcome::Failed(e),
314        }
315    }
316
317    fn exchange_with_id(id: &str) -> Exchange {
318        let mut ex = Exchange::new(Message::new("payload"));
319        ex.input.set_header("messageId", Value::String(id.into()));
320        ex
321    }
322
323    fn header_message_id() -> MessageIdSource {
324        MessageIdSource::Sync(Arc::new(|ex: &Exchange| {
325            ex.input
326                .header("messageId")
327                .and_then(|v| v.as_str().map(|s| s.to_string()))
328        }))
329    }
330
331    fn build_segment(
332        repo: Arc<MockRepo>,
333        child_outcome: PipelineOutcome,
334        eager: bool,
335        remove_on_failure: bool,
336    ) -> (
337        IdempotentConsumerSegment,
338        Arc<std::sync::atomic::AtomicBool>,
339    ) {
340        let invoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
341        let child = ScriptedChild {
342            outcome: child_outcome,
343            invoked: invoked.clone(),
344        };
345        let segment = IdempotentConsumerSegment::new(
346            repo,
347            header_message_id(),
348            OutcomeSegment::new(Box::new(child)),
349            eager,
350            remove_on_failure,
351        );
352        (segment, invoked)
353    }
354
355    // ── Test 1: duplicate key short-circuits to Completed without touching child ──
356    #[tokio::test]
357    async fn duplicate_key_returns_completed_without_running_child() {
358        let repo = Arc::new(MockRepo::new());
359        repo.pre_add("dup-1").await;
360        let (mut segment, child_invoked) = build_segment(
361            repo.clone(),
362            PipelineOutcome::Failed(stub_error()),
363            false,
364            false,
365        );
366
367        let ex = exchange_with_id("dup-1");
368        let outcome = segment.run(ex).await;
369
370        assert!(matches!(outcome, PipelineOutcome::Completed(_)));
371        assert!(
372            !child_invoked.load(std::sync::atomic::Ordering::SeqCst),
373            "child must NOT run when key is a duplicate"
374        );
375        // key still present
376        assert!(repo.contains_key("dup-1").await);
377    }
378
379    // ── Test 2: new key runs the child and returns its outcome ──
380    #[tokio::test]
381    async fn new_key_runs_child_and_returns_child_outcome() {
382        let repo = Arc::new(MockRepo::new());
383        let (mut segment, child_invoked) = build_segment(
384            repo.clone(),
385            PipelineOutcome::Completed(Exchange::new(Message::new(""))),
386            false,
387            false,
388        );
389
390        let ex = exchange_with_id("new-1");
391        let outcome = segment.run(ex).await;
392
393        assert!(matches!(outcome, PipelineOutcome::Completed(_)));
394        assert!(
395            child_invoked.load(std::sync::atomic::Ordering::SeqCst),
396            "child MUST run when key is new"
397        );
398        // Non-eager mode: key added after child Completed.
399        assert!(
400            repo.contains_key("new-1").await,
401            "non-eager mode must add key after successful child run"
402        );
403    }
404
405    // ── Test 3: failed repo read propagates as Failed (contract C1) ──
406    #[tokio::test]
407    async fn failed_repo_read_propagates_error() {
408        let repo = Arc::new(MockRepo::failing_contains());
409        let (mut segment, child_invoked) = build_segment(
410            repo,
411            PipelineOutcome::Completed(stub_exchange()),
412            false,
413            false,
414        );
415
416        let ex = exchange_with_id("any");
417        let outcome = segment.run(ex).await;
418
419        match outcome {
420            PipelineOutcome::Failed(e) => {
421                let msg = e.to_string();
422                assert!(
423                    msg.contains("synthetic contains failure"),
424                    "expected synthetic failure in error, got: {msg}"
425                );
426            }
427            other => panic!("expected Failed, got {other:?}"),
428        }
429        assert!(
430            !child_invoked.load(std::sync::atomic::Ordering::SeqCst),
431            "child must NOT run when repo read fails"
432        );
433    }
434
435    // ── Test 4: Stopped from child propagates as Stopped (segment-mode contract) ──
436    #[tokio::test]
437    async fn stopped_child_propagates_stopped() {
438        let repo = Arc::new(MockRepo::new());
439        let (mut segment, child_invoked) = build_segment(
440            repo.clone(),
441            PipelineOutcome::Stopped(stub_exchange()),
442            false,
443            false,
444        );
445
446        let ex = exchange_with_id("stop-1");
447        let outcome = segment.run(ex).await;
448
449        assert!(
450            matches!(outcome, PipelineOutcome::Stopped(_)),
451            "Stopped from child MUST propagate as Stopped (segment-mode contract)"
452        );
453        assert!(
454            child_invoked.load(std::sync::atomic::Ordering::SeqCst),
455            "child must run before its Stopped can propagate"
456        );
457        // Stopped path must NOT register the key (pipeline terminating).
458        assert!(
459            !repo.contains_key("stop-1").await,
460            "Stopped outcome must not register the key"
461        );
462    }
463
464    // ── Test 5: eager mode + remove_on_failure rolls back key on Failed ──
465    #[tokio::test]
466    async fn eager_mode_removes_key_on_failure_when_configured() {
467        let repo = Arc::new(MockRepo::new());
468        let (mut segment, _child_invoked) = build_segment(
469            repo.clone(),
470            PipelineOutcome::Failed(stub_error()),
471            true, // eager
472            true, // remove_on_failure
473        );
474
475        let ex = exchange_with_id("eager-fail");
476        let outcome = segment.run(ex).await;
477
478        assert!(matches!(outcome, PipelineOutcome::Failed(_)));
479        assert!(
480            !repo.contains_key("eager-fail").await,
481            "eager + remove_on_failure must roll back the key on failure"
482        );
483    }
484
485    // ── Test 6: missing message-id forwards to child without idempotency ──
486    #[tokio::test]
487    async fn missing_message_id_forwards_to_child() {
488        let repo = Arc::new(MockRepo::new());
489        let (mut segment, child_invoked) = build_segment(
490            repo.clone(),
491            PipelineOutcome::Completed(stub_exchange()),
492            false,
493            false,
494        );
495
496        // Exchange WITHOUT a messageId header.
497        let ex = Exchange::new(Message::new("no-id"));
498        let outcome = segment.run(ex).await;
499
500        assert!(matches!(outcome, PipelineOutcome::Completed(_)));
501        assert!(
502            child_invoked.load(std::sync::atomic::Ordering::SeqCst),
503            "child must run when message-id cannot be extracted"
504        );
505    }
506
507    fn stub_exchange() -> Exchange {
508        Exchange::new(Message::new(""))
509    }
510
511    fn stub_error() -> CamelError {
512        CamelError::ProcessorError("child failed".into())
513    }
514
515    // ── Fallible MessageIdSource (task 1.5) ─────────────────────────────
516
517    #[tokio::test]
518    async fn message_id_async_error_propagates() {
519        use camel_api::BoxValueFuture;
520
521        let repo = Arc::new(MockRepo::new());
522        let invoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
523        let child = ScriptedChild {
524            outcome: PipelineOutcome::Completed(Exchange::new(Message::new(""))),
525            invoked: invoked.clone(),
526        };
527
528        let source = MessageIdSource::Async(Arc::new(|_: &Exchange| {
529            Box::pin(async { Err(CamelError::ProcessorError("id boom".into())) }) as BoxValueFuture
530        }));
531        let mut segment = IdempotentConsumerSegment::new(
532            repo.clone(),
533            source,
534            OutcomeSegment::new(Box::new(child)),
535            false,
536            false,
537        );
538
539        let outcome = segment.run(exchange_with_id("any")).await;
540
541        assert!(
542            matches!(outcome, PipelineOutcome::Failed(ref e) if e.to_string().contains("id boom")),
543            "an async message-id failure must fail the step"
544        );
545        assert!(
546            !invoked.load(std::sync::atomic::Ordering::SeqCst),
547            "child must NOT run when the message-id expression fails"
548        );
549        assert!(!repo.contains_key("any").await);
550    }
551}