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}