Skip to main content

outbox_core/idempotency/
storage.rs

1//! Producer-side token reservation backend.
2//!
3//! [`IdempotencyStorageProvider`] is the narrow async contract an external
4//! store (Redis, another SQL table, an in-memory set for tests) must satisfy
5//! so [`OutboxService`](crate::service::OutboxService) can reject duplicate
6//! events at insert time. [`NoIdempotency`] is the trivial "always succeeds"
7//! implementation used when no such check is required.
8
9use crate::error::OutboxError;
10use crate::object::IdempotencyToken;
11use async_trait::async_trait;
12
13/// Async contract for reserving an idempotency token before an event is
14/// written.
15///
16/// The implementation is expected to perform an atomic "claim if absent"
17/// operation (e.g. `SET NX` in Redis) so concurrent producers cannot both
18/// reserve the same token.
19#[cfg_attr(test, mockall::automock)]
20#[async_trait]
21pub trait IdempotencyStorageProvider {
22    /// Attempts to reserve `token` as unused.
23    ///
24    /// Returns `true` if the token has been reserved by this call and the
25    /// event may proceed, or `false` if the token was already taken — in
26    /// which case [`OutboxService::add_event`](crate::service::OutboxService::add_event)
27    /// surfaces a [`DuplicateEvent`](OutboxError::DuplicateEvent).
28    ///
29    /// # Errors
30    ///
31    /// Returns an [`OutboxError`] — typically
32    /// [`InfrastructureError`](OutboxError::InfrastructureError) — when the
33    /// reservation backend itself fails (connection lost, timeout, etc.).
34    /// The error is propagated to the caller without inserting the event.
35    async fn try_reserve(&self, token: &IdempotencyToken) -> Result<bool, OutboxError>;
36}
37
38/// No-op [`IdempotencyStorageProvider`] that accepts every token.
39///
40/// Used when no external reservation backend is configured — i.e. when
41/// [`OutboxService::new`](crate::service::OutboxService::new) is called
42/// instead of
43/// [`with_idempotency`](crate::service::OutboxService::with_idempotency).
44/// Tokens produced by the strategy are still stored on the event row, so
45/// downstream consumers can deduplicate on their side if they need to.
46pub struct NoIdempotency;
47#[async_trait]
48impl IdempotencyStorageProvider for NoIdempotency {
49    /// Always returns `Ok(true)` — the token is considered "reserved"
50    /// unconditionally.
51    async fn try_reserve(&self, _token: &IdempotencyToken) -> Result<bool, OutboxError> {
52        Ok(true)
53    }
54}
55
56#[cfg(test)]
57#[allow(clippy::unwrap_used)]
58mod tests {
59    use super::*;
60    use rstest::rstest;
61
62    #[rstest]
63    #[case::non_empty("any-token")]
64    #[case::empty("")]
65    #[case::unicode("token-🦀")]
66    #[tokio::test]
67    async fn no_idempotency_returns_ok_true_for_any_token(#[case] raw: &str) {
68        let provider = NoIdempotency;
69        let token = IdempotencyToken::new(raw.to_string());
70        assert!(provider.try_reserve(&token).await.unwrap());
71    }
72
73    #[rstest]
74    #[tokio::test]
75    async fn no_idempotency_returns_ok_true_across_repeated_calls_with_same_token() {
76        let provider = NoIdempotency;
77        let token = IdempotencyToken::new("same".into());
78        assert!(provider.try_reserve(&token).await.unwrap());
79        assert!(provider.try_reserve(&token).await.unwrap());
80    }
81}