Skip to main content

protocol_droid/adapters/secondary/
idempotency.rs

1//! The in-memory [`IdempotencyStore`].
2//!
3//! # Where the atomicity comes from, and why it is here
4//!
5//! A `Mutex` around [`CompletedResults`], taken once per operation and released
6//! before the caller executes. That single acquisition is the whole of the
7//! guarantee, and it has to live in an adapter because it is a claim about
8//! concurrency that the state machine cannot make: `CompletedResults::claim`
9//! takes `&mut self`, which is exclusive for the duration of one call and
10//! nothing more, and an HTTP server has many calls in flight at once.
11//!
12//! This is the same division the registry's compare-and-swap publish makes. The
13//! transitions are domain knowledge and sit in `domain/idempotency.rs`; the
14//! exclusion is infrastructure and sits here. Neither is asked to do the other's
15//! job, and a second store implementation would reuse the same table rather than
16//! re-derive it — so the two cannot disagree about when a claim is stale.
17//!
18//! # Why the lock is not held across the execution
19//!
20//! Because holding it would serialise every request in the process behind the
21//! slowest tool. The claim is a microsecond-scale map operation; the execution
22//! is the whole cost of the request. Taking the lock, deciding, releasing it,
23//! then executing is what makes the store correct *and* concurrent: a slow
24//! execution blocks retries of its own key — which must wait, because the first
25//! attempt is still in flight — and nothing else.
26//!
27//! # Why a poisoned lock refuses rather than recovers
28//!
29//! Because the state behind it is no longer something this process can vouch
30//! for: a panic elsewhere unwound through the critical section, and the map may
31//! be missing the insert it was midway through. Answering from it could run a
32//! side effect twice, or hand a caller a result nobody executed for. Refusing
33//! is the fail-secure direction, and a caller that sees
34//! [`IdempotencyError::Unavailable`] retries rather than guessing.
35
36use core::num::NonZeroUsize;
37use std::sync::Mutex;
38
39use crate::application::idempotency::{IdempotencyStore, default_capacity};
40use crate::domain::idempotency::{
41    Claim, ClaimToken, CompletedResults, IdempotencyError, IdempotencyKey,
42};
43
44/// A process-local store of completed results, one `Mutex` wide.
45///
46/// # Example
47///
48/// ```
49/// use std::num::NonZeroUsize;
50///
51/// use protocol_droid::adapters::secondary::idempotency::InMemoryIdempotencyStore;
52/// use protocol_droid::application::idempotency::IdempotencyStore;
53/// use protocol_droid::domain::idempotency::{Claim, IdempotencyKey};
54///
55/// let store = InMemoryIdempotencyStore::with_capacity(NonZeroUsize::MIN);
56/// let key = IdempotencyKey::new("req-1");
57///
58/// let first = store.claim(&key)?;
59/// assert!(first.is_execution_mine());
60/// let token = first.token().ok_or("claimed without a token")?;
61/// store.complete(token, "charged once")?;
62///
63/// // The client retries because the connection dropped. It gets the recorded
64/// // result and no second charge.
65/// let replay = store.claim(&key)?;
66/// // `matches!` on a `&replay` binds `result` as `&&str`, so the recorded
67/// // value is two derefs in.
68/// assert!(matches!(&replay, Claim::Completed(result) if **result == *"charged once"));
69/// # Ok::<(), Box<dyn std::error::Error>>(())
70/// ```
71#[derive(Debug)]
72pub struct InMemoryIdempotencyStore<T> {
73    results: Mutex<CompletedResults<T>>,
74}
75
76impl<T> InMemoryIdempotencyStore<T> {
77    /// A store holding at most `capacity` keys.
78    ///
79    /// Bounded because the keys are peer-chosen; see
80    /// [`CompletedResults::with_capacity`].
81    #[must_use]
82    pub fn with_capacity(capacity: NonZeroUsize) -> Self {
83        Self {
84            results: Mutex::new(CompletedResults::with_capacity(capacity)),
85        }
86    }
87
88    /// The ceiling, in keys.
89    ///
90    /// Takes the lock, so a poisoned store reports the error rather than a
91    /// made-up number.
92    ///
93    /// # Errors
94    ///
95    /// [`IdempotencyError::Unavailable`] when the lock is poisoned.
96    pub fn capacity(&self) -> Result<usize, IdempotencyError> {
97        Ok(self.locked()?.capacity())
98    }
99
100    /// How many keys are held, including claims that are still in flight.
101    ///
102    /// # Errors
103    ///
104    /// [`IdempotencyError::Unavailable`] when the lock is poisoned.
105    pub fn len(&self) -> Result<usize, IdempotencyError> {
106        Ok(self.locked()?.len())
107    }
108
109    /// Whether nothing is held.
110    ///
111    /// # Errors
112    ///
113    /// [`IdempotencyError::Unavailable`] when the lock is poisoned.
114    pub fn is_empty(&self) -> Result<bool, IdempotencyError> {
115        Ok(self.locked()?.is_empty())
116    }
117
118    /// One lock acquisition, shared by every operation.
119    fn locked(&self) -> Result<std::sync::MutexGuard<'_, CompletedResults<T>>, IdempotencyError> {
120        self.results
121            .lock()
122            .map_err(|_| IdempotencyError::Unavailable {
123                reason: "the idempotency store's lock is poisoned",
124            })
125    }
126}
127
128impl<T: Clone + Send + Sync> IdempotencyStore for InMemoryIdempotencyStore<T> {
129    type Result = T;
130
131    fn claim(&self, key: &IdempotencyKey) -> Result<Claim<T>, IdempotencyError> {
132        self.locked()?.claim(key)
133    }
134
135    fn complete(&self, token: &ClaimToken, result: T) -> Result<(), IdempotencyError> {
136        self.locked()?.complete(token, result)?;
137        Ok(())
138    }
139
140    fn abandon(&self, token: &ClaimToken) -> Result<(), IdempotencyError> {
141        self.locked()?.abandon(token)?;
142        Ok(())
143    }
144}
145
146impl<T> Default for InMemoryIdempotencyStore<T> {
147    /// A store of [`default_capacity`] keys.
148    fn default() -> Self {
149        Self::with_capacity(default_capacity())
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::InMemoryIdempotencyStore;
156    use crate::application::idempotency::IdempotencyStore;
157    use crate::domain::idempotency::{Claim, IdempotencyError, IdempotencyKey};
158    use core::num::NonZeroUsize;
159    use std::sync::atomic::{AtomicUsize, Ordering};
160    use std::sync::{Arc, Barrier};
161    use std::thread;
162
163    fn store(capacity: usize) -> Result<InMemoryIdempotencyStore<String>, Failure> {
164        let capacity = NonZeroUsize::new(capacity).ok_or(Failure::ZeroCapacity)?;
165        Ok(InMemoryIdempotencyStore::with_capacity(capacity))
166    }
167
168    #[derive(Debug, PartialEq, Eq)]
169    enum Failure {
170        ZeroCapacity,
171        Unexpected(String),
172    }
173
174    impl From<IdempotencyError> for Failure {
175        fn from(error: IdempotencyError) -> Self {
176            Self::Unexpected(error.to_string())
177        }
178    }
179
180    /// **Counting executions, through the real adapter.** The threads are
181    /// released from a [`Barrier`] so they arrive at the claim together rather
182    /// than in whatever order the scheduler picks — a race that only sometimes
183    /// happens is not a race test. The counter is the claim: exactly one
184    /// execution, every other caller either saw it in flight or replayed the
185    /// recorded result.
186    #[test]
187    fn one_execution_survives_a_synchronised_race() -> Result<(), Failure> {
188        const THREADS: usize = 32;
189
190        let store: Arc<dyn IdempotencyStore<Result = String>> = Arc::new(store(4)?);
191        let executions = Arc::new(AtomicUsize::new(0));
192        let barrier = Arc::new(Barrier::new(THREADS));
193        let replays = Arc::new(AtomicUsize::new(0));
194        let inflight = Arc::new(AtomicUsize::new(0));
195
196        let mut workers = Vec::with_capacity(THREADS);
197        for _ in 0..THREADS {
198            let store = Arc::clone(&store);
199            let executions = Arc::clone(&executions);
200            let replays = Arc::clone(&replays);
201            let inflight = Arc::clone(&inflight);
202            let barrier = Arc::clone(&barrier);
203            workers.push(thread::spawn(move || -> Result<(), Failure> {
204                let key = IdempotencyKey::new("req-1");
205                barrier.wait();
206                match store.claim(&key)? {
207                    Claim::Claimed { token } => {
208                        executions.fetch_add(1, Ordering::SeqCst);
209                        // The work happens here, with no lock held.
210                        store.complete(&token, "the one run".to_owned())?;
211                    }
212                    Claim::Completed(_) => {
213                        replays.fetch_add(1, Ordering::SeqCst);
214                    }
215                    Claim::InFlight => {
216                        inflight.fetch_add(1, Ordering::SeqCst);
217                    }
218                }
219                Ok(())
220            }));
221        }
222        for worker in workers {
223            worker
224                .join()
225                .map_err(|_| Failure::Unexpected("a worker panicked".to_owned()))??;
226        }
227
228        let ran = executions.load(Ordering::SeqCst);
229        if ran != 1 {
230            return Err(Failure::Unexpected(format!("{ran} executions")));
231        }
232        // Every non-owner accounted for: one of the two benign outcomes.
233        let benign = replays.load(Ordering::SeqCst) + inflight.load(Ordering::SeqCst);
234        if benign != THREADS - 1 {
235            return Err(Failure::Unexpected(format!(
236                "{benign} of {} losers accounted for",
237                THREADS - 1
238            )));
239        }
240        Ok(())
241    }
242
243    /// A stale completion through the adapter is refused with the same
244    /// [`StaleClaim`] the domain machine produces — the adapter adds the lock
245    /// and nothing else, so it cannot report a different fault for the same
246    /// transition.
247    #[test]
248    fn a_stale_completion_is_refused_with_the_domain_error() -> Result<(), Failure> {
249        let store = store(4)?;
250        let key = IdempotencyKey::new("req-1");
251
252        let slow = store.claim(&key)?;
253        let slow_token = slow
254            .token()
255            .cloned()
256            .ok_or_else(|| Failure::Unexpected("no token".to_owned()))?;
257        store.abandon(&slow_token)?;
258
259        let retry = store.claim(&key)?;
260        let retry_token = retry
261            .token()
262            .cloned()
263            .ok_or_else(|| Failure::Unexpected("no token".to_owned()))?;
264        store.complete(&retry_token, "second".to_owned())?;
265
266        match store.complete(&slow_token, "first".to_owned()) {
267            Err(IdempotencyError::Stale(_)) => Ok(()),
268            other => Err(Failure::Unexpected(format!("{other:?}"))),
269        }
270    }
271
272    /// The ceiling is visible through the adapter, and a full store of running
273    /// work refuses a new key rather than discarding a live claim.
274    #[test]
275    fn the_ceiling_is_observable_and_fail_secure() -> Result<(), Failure> {
276        let store = store(1)?;
277        if store.capacity()? != 1 {
278            return Err(Failure::Unexpected("capacity".to_owned()));
279        }
280        // Held and never completed: the one thing that is not evicted.
281        let key = IdempotencyKey::new("req-1");
282        let _running = store.claim(&key)?;
283
284        match store.claim(&IdempotencyKey::new("req-2")) {
285            Err(IdempotencyError::StoreFull { capacity }) => {
286                if capacity != 1 {
287                    return Err(Failure::Unexpected("misreported ceiling".to_owned()));
288                }
289            }
290            other => return Err(Failure::Unexpected(format!("{other:?}"))),
291        }
292        if store.len()? != 1 {
293            return Err(Failure::Unexpected("lost a key".to_owned()));
294        }
295        if store.is_empty()? {
296            return Err(Failure::Unexpected("reported empty".to_owned()));
297        }
298        Ok(())
299    }
300
301    /// Two adapters sharing nothing do not share results — the obvious
302    /// consequence of a process-local store, checked so a future change that
303    /// made this global would be caught.
304    #[test]
305    fn the_store_is_process_local() -> Result<(), Failure> {
306        let first = store(4)?;
307        let second = store(4)?;
308        let key = IdempotencyKey::new("req-1");
309
310        let claim = first.claim(&key)?;
311        first.complete(
312            claim
313                .token()
314                .ok_or_else(|| Failure::Unexpected("no token".to_owned()))?,
315            "only here".to_owned(),
316        )?;
317
318        if !second.claim(&key)?.is_execution_mine() {
319            return Err(Failure::Unexpected(
320                "results leaked between stores".to_owned(),
321            ));
322        }
323        Ok(())
324    }
325}