Skip to main content

protocol_droid/application/
idempotency.rs

1//! The port a peer-observable side effect is deduplicated through.
2//!
3//! # Why the trait lives here
4//!
5//! Because the port belongs to its consumer. The consumer is the `tools/call`
6//! handler — the one use case whose side effect a peer can observe and retry —
7//! and this module is that use case's seam. Nothing in `domain/` or
8//! `adapters/` declares it, and there is deliberately no shared `ports` module
9//! for it to be collected into: a trait collected away from its consumer is a
10//! trait whose shape drifts toward whatever is convenient to implement.
11//!
12//! # Why the port is `&self` and not `&mut self`
13//!
14//! Because the claim has to be atomic between concurrent callers, and the only
15//! thing a `&mut self` method can promise is exclusivity for the duration of
16//! one call. An HTTP server handles requests concurrently, so a store that can
17//! only be reached through `&mut self` is either behind a lock somewhere
18//! unavoidable or not safe to share at all. `&self` lets the adapter hold the
19//! lock internally — exactly where the atomicity belongs — and lets a server
20//! hold it in shared state.
21//!
22//! # Why the result type is an associated type
23//!
24//! So the trait is object-safe. The Streamable HTTP adapter keeps its store in
25//! router state, and router state is `Arc<dyn …>`; a trait with a `T` parameter
26//! would force every store to be monomorphised into the router, and a caller
27//! wanting to swap implementations for a test would have to name the result
28//! type at every use site. `Arc<dyn IdempotencyStore<Result = …>>` names it
29//! once.
30//!
31//! # Why there is no `async` on these methods
32//!
33//! Because the operations are a map lookup, an insert and a remove. Declaring
34//! them `async` would put a `Pin<Box<dyn Future>>>` in the signature of a
35//! hash-table operation and add an allocation per call to buy nothing. A store
36//! that genuinely needs to await — one writing to a database, say — implements
37//! this port and does its awaiting inside, which is what an adapter is for.
38//!
39//! # The ordering, stated once more because it is the contract
40//!
41//! [`IdempotencyStore::claim`] → execute → [`IdempotencyStore::complete`]. The
42//! claim *is* the check: it reserves the key for its caller, so a second
43//! retry that arrives before the first finishes gets
44//! [`Claim::InFlight`] rather than a second go-ahead. A handler that checked
45//! and then claimed separately would have two operations and two writers would
46//! interleave — the same read-modify-write the registry's publish loop
47//! carries.
48//!
49//! [`IdempotencyStore::abandon`] exists for the failed-execution case, and it
50//! is not optional bookkeeping: without it a single transient tool failure would
51//! leave the key reserved forever, and every retry of that request id would be
52//! answered `InFlight` with nobody to complete it.
53
54use core::num::NonZeroUsize;
55
56use crate::domain::idempotency::{Claim, ClaimToken, IdempotencyError, IdempotencyKey};
57
58/// How many keys a store holds when nobody has measured its workload.
59///
60/// A store for a `tools/call` result. It keeps the last this-many completed
61/// results so a client that retries after a dropped connection gets the recorded
62/// answer; once full it drops the oldest finished result to make room, and
63/// never a request still running. A retry older than the window runs again,
64/// which is the price of a server that keeps serving. The number is modest
65/// because the store is keyed by a peer-chosen request id. A deployment that
66/// knows its own rate replaces it — the capacity is a constructor argument on
67/// the adapter for exactly that reason.
68///
69/// A deployment that wants *no* ceiling sets a large capacity knowingly: the
70/// store is keyed by a peer-chosen request id, so an unbounded one is a memory
71/// exhaustion primitive.
72#[must_use]
73pub const fn default_capacity() -> NonZeroUsize {
74    match NonZeroUsize::new(4_096) {
75        Some(value) => value,
76        // Unreachable: 4096 is not zero. A `match` rather than `unwrap` because
77        // the crate denies that, and duplicating the number or a lazy static
78        // would be worse for a case the compiler already rules out.
79        None => NonZeroUsize::MIN,
80    }
81}
82
83/// A store that runs each key's side effect at most once.
84///
85/// # Implementing this
86///
87/// The three methods must be **individually atomic** with respect to one
88/// another, and the atomicity has to be the implementation's, not the caller's.
89/// Concretely: two threads calling [`claim`](Self::claim) with the same key must
90/// never both receive [`Claim::Claimed`]. The in-memory adapter holds
91/// `CompletedResults` behind a `Mutex` for exactly this, taking the lock once
92/// per operation and releasing it before the caller executes — so a slow tool
93/// does not serialise every other key behind it.
94///
95/// [`CompletedResults`](crate::domain::idempotency::CompletedResults) is the
96/// transition table to delegate to; an adapter that is not in-memory has to
97/// reproduce its transitions, and the invariants worth reproducing are that a
98/// `Claimed` key stays reserved until completed or abandoned, and that a
99/// completion carrying a stale [`ClaimToken`] is refused rather than applied.
100///
101/// # Example
102///
103/// ```
104/// use std::num::NonZeroUsize;
105/// use std::sync::Mutex;
106///
107/// use protocol_droid::application::idempotency::IdempotencyStore;
108/// use protocol_droid::domain::idempotency::{
109///     Claim, ClaimToken, CompletedResults, IdempotencyError, IdempotencyKey,
110/// };
111///
112/// struct InMemory<T>(Mutex<CompletedResults<T>>);
113///
114/// impl<T: Clone + Send + Sync> IdempotencyStore for InMemory<T> {
115///     type Result = T;
116///
117///     fn claim(&self, key: &IdempotencyKey) -> Result<Claim<T>, IdempotencyError> {
118///         self.0
119///             .lock()
120///             .map_err(|_| IdempotencyError::Unavailable { reason: "lock poisoned" })?
121///             .claim(key)
122///     }
123///
124///     fn complete(
125///         &self,
126///         token: &ClaimToken,
127///         result: T,
128///     ) -> Result<(), IdempotencyError> {
129///         self.0
130///             .lock()
131///             .map_err(|_| IdempotencyError::Unavailable { reason: "lock poisoned" })?
132///             .complete(token, result)?;
133///         Ok(())
134///     }
135///
136///     fn abandon(&self, token: &ClaimToken) -> Result<(), IdempotencyError> {
137///         self.0
138///             .lock()
139///             .map_err(|_| IdempotencyError::Unavailable { reason: "lock poisoned" })?
140///             .abandon(token)?;
141///         Ok(())
142///     }
143/// }
144///
145/// let store = InMemory(Mutex::new(CompletedResults::with_capacity(NonZeroUsize::MIN)));
146/// let key = IdempotencyKey::new("req-1");
147///
148/// let first = store.claim(&key)?;
149/// assert!(first.is_execution_mine());
150///
151/// let token = first.token().ok_or("claimed without a token")?;
152/// store.complete(token, "the only run")?;
153///
154/// // A retry of the same request id replays rather than re-running.
155/// match store.claim(&key)? {
156///     Claim::Completed(result) => assert_eq!(result, "the only run"),
157///     other => return Err(format!("re-claimed: {other:?}").into()),
158/// }
159/// # Ok::<(), Box<dyn std::error::Error>>(())
160/// ```
161/// `Send + Sync` because the whole point of the store is to be consulted
162/// concurrently by the requests it is deduplicating. An adapter that held one
163/// behind `&self` without those bounds would force every `tools/call` handler
164/// to reach a lock it cannot see, and the deduplication guarantee would then
165/// depend on each caller remembering to take it.
166pub trait IdempotencyStore: Send + Sync {
167    /// What a completed side effect produced. The adapter's own result type —
168    /// an `IrResult` for a tool call, a transport payload for a bridge forward.
169    type Result;
170
171    /// Takes ownership of `key`, or reports that it is already spoken for.
172    ///
173    /// # Errors
174    ///
175    /// [`IdempotencyError::StoreFull`] when the store cannot hold another key
176    /// without risking the loss of a recorded result.
177    fn claim(&self, key: &IdempotencyKey) -> Result<Claim<Self::Result>, IdempotencyError>;
178
179    /// Records the result of a claim the caller owns.
180    ///
181    /// # Errors
182    ///
183    /// [`StaleClaim`](crate::domain::idempotency::StaleClaim) when the token no longer holds its key, which happens
184    /// when the attempt was abandoned and a later one re-claimed it. The caller
185    /// should discard its result in that case: the later attempt's is the one
186    /// the peer is entitled to. Also
187    /// [`IdempotencyError::Unavailable`] when the store itself could not answer.
188    fn complete(&self, token: &ClaimToken, result: Self::Result) -> Result<(), IdempotencyError>;
189
190    /// Releases a claim whose execution did not produce a result.
191    ///
192    /// Without this a failed attempt would reserve the key forever and every
193    /// retry would be told `InFlight` by a caller that no longer exists.
194    ///
195    /// # Errors
196    ///
197    /// [`StaleClaim`](crate::domain::idempotency::StaleClaim) when the token no longer holds its key. Also
198    /// [`IdempotencyError::Unavailable`] when the store itself could not answer.
199    fn abandon(&self, token: &ClaimToken) -> Result<(), IdempotencyError>;
200}
201
202#[cfg(test)]
203mod tests {
204    use super::{IdempotencyStore, default_capacity};
205    use crate::adapters::secondary::idempotency::InMemoryIdempotencyStore;
206    use crate::domain::idempotency::{
207        Claim, CompletedResults, IdempotencyError, IdempotencyKey, StaleClaim,
208    };
209    use core::num::NonZeroUsize;
210    use std::sync::atomic::{AtomicUsize, Ordering};
211    use std::sync::{Arc, Mutex};
212    use std::thread;
213
214    fn store(capacity: usize) -> Result<InMemoryIdempotencyStore<String>, Failure> {
215        let capacity = NonZeroUsize::new(capacity).ok_or(Failure::ZeroCapacity)?;
216        Ok(InMemoryIdempotencyStore::with_capacity(capacity))
217    }
218
219    #[derive(Debug, PartialEq, Eq)]
220    enum Failure {
221        ZeroCapacity,
222        Poisoned,
223        Unexpected(String),
224    }
225
226    impl From<IdempotencyError> for Failure {
227        fn from(error: IdempotencyError) -> Self {
228            Self::Unexpected(error.to_string())
229        }
230    }
231
232    impl From<StaleClaim> for Failure {
233        fn from(error: StaleClaim) -> Self {
234            Self::Unexpected(error.to_string())
235        }
236    }
237
238    /// The trait is usable as `Arc<dyn …>`, which is the reason the result type
239    /// is an associated type. A store behind a trait object has to be able to
240    /// answer for its own result type, and this fails to compile if that ever
241    /// changes.
242    #[test]
243    fn is_usable_behind_a_trait_object() -> Result<(), Failure> {
244        let concrete: Arc<dyn IdempotencyStore<Result = String>> = Arc::new(store(4)?);
245        let key = IdempotencyKey::new("req-1");
246        let claim = concrete.claim(&key)?;
247        if !claim.is_execution_mine() {
248            return Err(Failure::Unexpected("not claimed".to_owned()));
249        }
250        concrete.complete(
251            claim
252                .token()
253                .ok_or_else(|| Failure::Unexpected("no token".to_owned()))?,
254            "done".to_owned(),
255        )?;
256        Ok(())
257    }
258
259    /// The default ceiling is a real, non-zero bound, so `Default` cannot
260    /// produce a store that refuses everything.
261    #[test]
262    fn the_default_capacity_is_usable() -> Result<(), Failure> {
263        if default_capacity().get() == 0 {
264            return Err(Failure::Unexpected("zero".to_owned()));
265        }
266        let store = InMemoryIdempotencyStore::<String>::default();
267        if store.capacity()? != default_capacity().get() {
268            return Err(Failure::Unexpected("mismatch".to_owned()));
269        }
270        Ok(())
271    }
272
273    /// **Counting executions, through the port and the adapter.** This is the
274    /// end-to-end version of the domain's own concurrency test: the same lock
275    /// the production adapter uses is what makes exactly one of sixty-four
276    /// racing callers execute. The counter, not the returned result, is the
277    /// claim under test.
278    #[test]
279    fn one_execution_survives_sixty_four_racing_retries() -> Result<(), Failure> {
280        const THREADS: usize = 64;
281
282        /// What each racing worker saw when it claimed the key.
283        ///
284        /// The distinction the test turns on is how many workers *ran* the
285        /// side effect, so the outcome is recorded rather than a count that
286        /// only one of the branches could contribute to. A signed sentinel
287        /// would say the same thing, and would also be a value the count
288        /// could never legitimately be.
289        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
290        enum Saw {
291            /// This worker ran the side effect, and it is worker `n`.
292            Ran(usize),
293            /// This worker got a recorded result back without re-running.
294            Replayed,
295            /// This worker found the key still claimed by another attempt.
296            InFlight,
297        }
298
299        let store: Arc<dyn IdempotencyStore<Result = String>> = Arc::new(store(8)?);
300        let executions = Arc::new(AtomicUsize::new(0));
301        // The work runs outside the store's lock, as it must: the store's
302        // critical section is the claim, not the execution.
303        let observed = Arc::new(Mutex::new(Vec::new()));
304
305        let mut workers = Vec::with_capacity(THREADS);
306        for worker_id in 0..THREADS {
307            let store = Arc::clone(&store);
308            let executions = Arc::clone(&executions);
309            let observed = Arc::clone(&observed);
310            workers.push(thread::spawn(move || -> Result<(), Failure> {
311                let key = IdempotencyKey::new("req-1");
312                let saw = match store.claim(&key)? {
313                    Claim::Claimed { token } => {
314                        executions.fetch_add(1, Ordering::SeqCst);
315                        store.complete(&token, format!("worker-{worker_id}"))?;
316                        Saw::Ran(worker_id)
317                    }
318                    Claim::Completed(result) => {
319                        // A replay must get the *recorded* result, not a new one.
320                        if result.is_empty() {
321                            return Err(Failure::Unexpected("empty replay".to_owned()));
322                        }
323                        Saw::Replayed
324                    }
325                    Claim::InFlight => Saw::InFlight,
326                };
327                observed
328                    .lock()
329                    .map_err(|_| Failure::Poisoned)?
330                    .push((worker_id, saw));
331                Ok(())
332            }));
333        }
334        for worker in workers {
335            worker
336                .join()
337                .map_err(|_| Failure::Unexpected("panicked".to_owned()))??;
338        }
339
340        let count = executions.load(Ordering::SeqCst);
341        if count != 1 {
342            return Err(Failure::Unexpected(format!("{count} executions")));
343        }
344        let seen = observed.lock().map_err(|_| Failure::Poisoned)?.clone();
345        if seen.len() != THREADS {
346            return Err(Failure::Unexpected(format!("{} observers", seen.len())));
347        }
348        let ran = seen
349            .iter()
350            .filter(|(_, saw)| matches!(saw, Saw::Ran(_)))
351            .count();
352        if ran != 1 {
353            return Err(Failure::Unexpected(format!("{ran} workers ran it")));
354        }
355        Ok(())
356    }
357
358    /// A failed execution releases the key, so a retry afterwards runs — and
359    /// runs exactly once itself.
360    #[test]
361    fn a_failed_execution_releases_the_key_for_the_retry() -> Result<(), Failure> {
362        let store = store(4)?;
363        let key = IdempotencyKey::new("req-1");
364
365        let first = store.claim(&key)?;
366        let token = first
367            .token()
368            .cloned()
369            .ok_or_else(|| Failure::Unexpected("no token".to_owned()))?;
370        store.abandon(&token)?;
371
372        let second = store.claim(&key)?;
373        if !second.is_execution_mine() {
374            return Err(Failure::Unexpected("not re-claimed".to_owned()));
375        }
376        store.complete(
377            second
378                .token()
379                .ok_or_else(|| Failure::Unexpected("no token".to_owned()))?,
380            "second".to_owned(),
381        )?;
382
383        match store.claim(&key)? {
384            Claim::Completed(result) if result == "second" => Ok(()),
385            other => Err(Failure::Unexpected(format!("{other:?}"))),
386        }
387    }
388
389    /// The adapter is `Send` and `Sync`, so a server can hold it in shared
390    /// state. If a lock ever leaked into the domain machine, this would fail.
391    #[test]
392    fn is_shareable() -> Result<(), Failure> {
393        fn assert_send_sync<T: Send + Sync>() {}
394        assert_send_sync::<InMemoryIdempotencyStore<String>>();
395        let _ = store(1)?;
396        Ok(())
397    }
398
399    /// The adapter delegates to the domain machine rather than keeping its own
400    /// map, so the transitions cannot drift. Checked by a capacity the two must
401    /// agree on.
402    #[test]
403    fn the_adapter_owns_no_second_state() -> Result<(), Failure> {
404        let store = store(3)?;
405        if store.capacity()? != 3 {
406            return Err(Failure::Unexpected("capacity mismatch".to_owned()));
407        }
408        // A machine built with the same capacity agrees, which is what "the
409        // adapter is only a lock" means operationally.
410        let mut machine = CompletedResults::<String>::with_capacity(
411            NonZeroUsize::new(3).ok_or(Failure::ZeroCapacity)?,
412        );
413        let key = IdempotencyKey::new("req-1");
414        if !machine.claim(&key)?.is_execution_mine() {
415            return Err(Failure::Unexpected("machine refused".to_owned()));
416        }
417        Ok(())
418    }
419}