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}