1use 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#[allow(clippy::type_complexity)]
42#[derive(Clone)]
43pub enum MessageIdSource {
44 Sync(Arc<dyn Fn(&Exchange) -> Option<String> + Send + Sync>),
46 Async(Arc<dyn Fn(&Exchange) -> BoxValueFuture + Send + Sync>),
48}
49
50impl MessageIdSource {
51 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
77pub 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 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 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 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 if self.eager {
169 match self.repository.add(&key).await {
170 Ok(true) => {} Ok(false) => return PipelineOutcome::Completed(exchange), Err(e) => return PipelineOutcome::Failed(e),
173 }
174 }
175
176 let outcome = self.child_pipeline.run(exchange).await;
178
179 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 #[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 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 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 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 #[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 assert!(repo.contains_key("dup-1").await);
377 }
378
379 #[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 assert!(
400 repo.contains_key("new-1").await,
401 "non-eager mode must add key after successful child run"
402 );
403 }
404
405 #[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 #[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 assert!(
459 !repo.contains_key("stop-1").await,
460 "Stopped outcome must not register the key"
461 );
462 }
463
464 #[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, true, );
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 #[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 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 #[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}