Skip to main content

protocol_droid/domain/
idempotency.rs

1//! Running a peer-observable side effect at most once per key.
2//!
3//! # The problem this solves
4//!
5//! On the Streamable HTTP transport a client may re-send a request whose
6//! response never reached it — the connection dropped between the server
7//! running the tool and the client reading the answer. The client cannot tell
8//! that apart from the server never having run it, so it retries, and the tool
9//! runs twice. For a `tools/call` that means paying for the same thing twice,
10//! sending the same message twice, or writing the same row twice.
11//!
12//! The ordering is therefore not negotiable, and it is the *claim* that has to
13//! be first:
14//!
15//! 1. **claim** the key — atomically, so exactly one caller owns it;
16//! 2. **execute**, only if the claim came back as yours;
17//! 3. **record** the result under the same key.
18//!
19//! Claiming and checking are the same operation here on purpose. A check that
20//! does not also claim leaves a window between the two, and in that window a
21//! second retry reads the same absence and runs the tool as well. That is the
22//! same read-modify-write hazard the registry's compare-and-swap publish
23//! carries, and it is closed here by a single lock in the adapter rather than
24//! by an ordering argument in the handler.
25//!
26//! # Why the key is the request id
27//!
28//! Because that is the only thing a retrying client reliably repeats. JSON-RPC
29//! requires a request id to be unique among in-flight requests, and a client
30//! retrying an unanswered request reuses the id it never got a response for.
31//! So the id *is* the idempotency key for a dropped connection, and this type
32//! derives from [`RequestId`] rather than asking the peer for a second header
33//! it would have no way to invent.
34//!
35//! # Why a `u64` generation rides along with the claim
36//!
37//! Because a claim can be abandoned. If execution fails, the key has to return
38//! to absent so a retry can run it — and if the first attempt is merely slow
39//! rather than failed, it may come back afterwards and record a result for a
40//! key the second attempt now owns. Two writers, one slot, and the second
41//! result is the one the retrying client should see. A token carrying the
42//! generation it was issued under turns that into a typed [`StaleClaim`]
43//! instead of a silent overwrite.
44//!
45//! # Why the state machine is a domain type
46//!
47//! It is the transition table and nothing else: a map, three operations and one
48//! bounded-resource rule. It has no lock, no I/O and no clock, so it can be
49//! tested as a state machine. What it cannot do is make the claim *atomic
50//! against another thread*, because a `&mut self` method is only exclusive
51//! within one caller. That is the lock's job, and the lock lives in the
52//! adapter: see [`crate::application::idempotency`].
53
54use core::fmt;
55use core::num::NonZeroUsize;
56use core::str::FromStr;
57use std::collections::{HashMap, VecDeque};
58use std::sync::Arc;
59
60use thiserror::Error;
61
62use crate::domain::RequestId;
63use crate::domain::session::SessionId;
64
65/// The key a side effect is deduplicated by.
66///
67/// Deliberately *not* `Copy`, for the reason [`RequestId`] is not: it wraps an
68/// `Arc<str>`, and duplicating a string key is not something anyone means to
69/// do. Two "copies" of one key are the same key, and a function that needed
70/// only to read it should have taken a reference.
71#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
72pub struct IdempotencyKey(Arc<str>);
73
74impl IdempotencyKey {
75    /// A key from anything string-shaped.
76    #[must_use]
77    pub fn new(value: impl AsRef<str>) -> Self {
78        Self(Arc::from(value.as_ref()))
79    }
80
81    /// The key, as the request id a retrying client repeats.
82    ///
83    /// Renders the id through its own `Display`, so a numeric id and a string
84    /// id that print the same are the same key. That is the JSON-RPC reading:
85    /// an id is whatever appears in the `id` member, and a client that retries
86    /// repeats it exactly.
87    #[must_use]
88    pub fn from_request_id(id: &RequestId) -> Self {
89        Self::new(id.to_string())
90    }
91
92    /// The key for one request within one session.
93    ///
94    /// A request id alone is not a key, and the reason is that ids are only
95    /// unique per connection: every MCP client numbers its first request `1`,
96    /// so `("session-a", 7)` and `("session-b", 7)` are two different calls. A
97    /// store keyed on the bare id would answer the second session's `7` with
98    /// the first session's result — a cross-session data leak wearing the
99    /// costume of a duplicate.
100    ///
101    /// The separator is a byte that cannot appear in either half. `SessionId`
102    /// renders as an integer and `RequestId` is a number or a string, and a
103    /// string id is free to contain anything the peer typed, so the join has to
104    /// be unambiguous in the general case: `session = 1, id = "2:3"` and
105    /// `session = 12, id = "3"` both render, and only a separator that the id
106    /// cannot contain keeps them apart. A `/` is the choice because MCP session
107    /// ids and the ids a client sends are both opaque, and a `/` in a client-chosen
108    /// id is legal — so this is a delimiter, not an escape, and the two halves
109    /// are distinguished by the _first_ separator rather than by a scan.
110    #[must_use]
111    pub fn for_request(session: &SessionId, id: &RequestId) -> Self {
112        Self(Arc::from(format!("{session}/{id}").as_str()))
113    }
114
115    /// The key for a method invocation, which is the form every
116    /// observable-side-effect handler wants.
117    ///
118    /// [`Self::for_request`] keys on the session and the request id alone, and
119    /// that is exactly right for *retrying the same call*: a client whose POST
120    /// never got an answer resends the identical request and must not run the
121    /// tool twice.
122    ///
123    /// It is wrong as the only form, because a request id is not a promise that
124    /// the same id names the same operation. JSON-RPC 2.0 requires the peer to
125    /// be able to reuse ids across a *sequence* of requests, and real clients
126    /// do — the conformance suite restarts its counter at `1` per scenario
127    /// against a session it never closed. Keyed on the id alone, the second
128    /// `tools/call` of a session is answered with the first one's recording: ask
129    /// `test_audio_content` as id 1 and `test_embedded_resource` as id 2 in
130    /// separate scenarios sharing a connection, and the second is handed back
131    /// an audio block. That is worse than a wrong answer, because the caller
132    /// cannot tell it from a correct one — it is a *valid* `CallToolResult`
133    /// carrying the wrong tool's output.
134    ///
135    /// Adding the method and the tool name makes the key identify *what was
136    /// asked* as well as *who asked and when*, so a genuinely repeated
137    /// `tools/call` for the same tool still replays, and a different tool under
138    /// a reused id runs. It cannot distinguish a *retry* of a different-argument
139    /// call under the same id, which is a caller violating JSON-RPC in the other
140    /// direction; treating that as a fresh call is the answer that loses less.
141    #[must_use]
142    pub fn for_method_call(
143        session: &SessionId,
144        id: &RequestId,
145        method: &str,
146        target: &str,
147    ) -> Self {
148        Self(Arc::from(
149            format!("{session}/{id}/{method}/{target}").as_str(),
150        ))
151    }
152
153    /// The key as a string.
154    #[must_use]
155    pub fn as_str(&self) -> &str {
156        &self.0
157    }
158}
159
160impl fmt::Display for IdempotencyKey {
161    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162        formatter.write_str(&self.0)
163    }
164}
165
166impl From<&RequestId> for IdempotencyKey {
167    fn from(id: &RequestId) -> Self {
168        Self::from_request_id(id)
169    }
170}
171
172impl FromStr for IdempotencyKey {
173    type Err = core::convert::Infallible;
174
175    fn from_str(text: &str) -> Result<Self, Self::Err> {
176        Ok(Self::new(text))
177    }
178}
179
180/// Proof that one caller, and only that caller, owns a key right now.
181///
182/// Carries the generation it was issued under so a late completion from an
183/// abandoned attempt is refused rather than overwriting the attempt that
184/// replaced it.
185#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
186pub struct ClaimToken {
187    key: IdempotencyKey,
188    generation: u64,
189}
190
191impl ClaimToken {
192    /// The key this claim is for.
193    #[must_use]
194    pub const fn key(&self) -> &IdempotencyKey {
195        &self.key
196    }
197
198    /// The attempt this token was issued to.
199    ///
200    /// A counter, not a random value, and exposed for the error a stale claim
201    /// produces. It carries no entropy and is not a secret.
202    #[must_use]
203    pub const fn generation(&self) -> u64 {
204        self.generation
205    }
206}
207
208/// The three things a claim can find, and the three responses a caller has.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub enum Claim<T> {
211    /// The key was absent. This caller owns it and must either complete it with
212    /// a result or abandon it.
213    ///
214    /// In both cases the key stays reserved until one of them happens, which is
215    /// what stops a second caller from taking it in between.
216    Claimed {
217        /// The token to present when recording or releasing.
218        token: ClaimToken,
219    },
220
221    /// The key already completed. Hand the recorded result back and do **not**
222    /// execute: the whole point is that the side effect does not run a second
223    /// time.
224    Completed(T),
225
226    /// Another caller holds the key right now. Do not execute and do not steal
227    /// it — the holder is a real request, and taking the key from it would run
228    /// the same side effect twice from two callers.
229    InFlight,
230}
231
232impl<T> Claim<T> {
233    /// Whether this caller is the one that should execute.
234    #[must_use]
235    pub const fn is_execution_mine(&self) -> bool {
236        matches!(self, Self::Claimed { .. })
237    }
238
239    /// The result, if the key had already completed.
240    #[must_use]
241    pub const fn recorded(&self) -> Option<&T> {
242        match self {
243            Self::Completed(result) => Some(result),
244            Self::Claimed { .. } | Self::InFlight => None,
245        }
246    }
247
248    /// The token, if the claim came back to this caller.
249    #[must_use]
250    pub const fn token(&self) -> Option<&ClaimToken> {
251        match self {
252            Self::Claimed { token } => Some(token),
253            Self::Completed(_) | Self::InFlight => None,
254        }
255    }
256}
257
258/// What is stored against one key.
259#[derive(Debug, Clone, PartialEq, Eq)]
260enum Slot<T> {
261    /// A caller owns the key and has not finished with it.
262    Held {
263        /// The attempt this hold belongs to.
264        generation: u64,
265    },
266    /// The side effect ran and produced this.
267    Done(T),
268}
269
270/// The transition table, with no lock and no I/O.
271///
272/// # What `&mut self` guarantees and what it does not
273///
274/// It guarantees the transitions are consistent within one caller. It does not
275/// make [`claim`](Self::claim) atomic against another thread, because a
276/// `&mut self` method only excludes callers that share the borrow. An adapter
277/// holds this behind a `Mutex` — one acquisition per operation, and the lock
278/// is released across the execution rather than held across it, so a slow tool
279/// does not block every other key. That is the same division as the registry's
280/// compare-and-swap publish: the state machine is domain, the atomicity is the
281/// adapter's, and neither is asked to do the other's job.
282#[derive(Debug)]
283pub struct CompletedResults<T> {
284    slots: HashMap<IdempotencyKey, Slot<T>>,
285    /// Completed keys, oldest first. A key joins when it completes and leaves
286    /// only by eviction, so every key in here is `Done`.
287    completed_order: VecDeque<IdempotencyKey>,
288    next_generation: u64,
289    capacity: NonZeroUsize,
290}
291
292impl<T> CompletedResults<T> {
293    /// A store holding at most `capacity` keys.
294    ///
295    /// Bounded because a client chooses the keys: an unbounded map keyed by a
296    /// peer-supplied id is a memory exhaustion primitive, and the wire offers
297    /// nothing that would stop a client from minting a fresh id per request.
298    ///
299    /// A capacity of zero is refused at the type level — such a store could
300    /// never complete anything, so every call would fail for a reason that has
301    /// nothing to do with the work itself.
302    #[must_use]
303    pub fn with_capacity(capacity: NonZeroUsize) -> Self {
304        Self {
305            slots: HashMap::with_capacity(capacity.get()),
306            completed_order: VecDeque::new(),
307            next_generation: 0,
308            capacity,
309        }
310    }
311
312    /// How many keys are held.
313    #[must_use]
314    pub fn len(&self) -> usize {
315        self.slots.len()
316    }
317
318    /// Whether nothing is held.
319    #[must_use]
320    pub fn is_empty(&self) -> bool {
321        self.slots.is_empty()
322    }
323
324    /// The ceiling, in keys.
325    #[must_use]
326    pub const fn capacity(&self) -> usize {
327        self.capacity.get()
328    }
329
330    /// Takes ownership of `key`, or reports why it could not.
331    ///
332    /// # Errors
333    ///
334    /// [`IdempotencyError::StoreFull`] when the store is at capacity, `key` is
335    /// not already present, and every slot is held by a request still running.
336    /// That is genuine overload, and refusing the new request is visible where
337    /// discarding a live claim would let two callers run one request.
338    ///
339    /// # Retention
340    ///
341    /// When the store is full and `key` is new, the **oldest completed**
342    /// result is dropped to make room. A retry replays its recorded result for
343    /// as long as it is among the last `capacity` completed requests; a retry
344    /// of something older than that runs again. The alternative, refusing every
345    /// new key once the store has filled with finished work, turns a long-lived
346    /// session into one that fails every call after the first `capacity`, which
347    /// is the common case and not an edge. A dropped connection is retried in
348    /// seconds, so the window is far longer than any retry needs.
349    ///
350    /// `T: Clone` because handing a recorded result to the retrying caller
351    /// leaves the recorded copy in place — a claim that consumed it would break
352    /// the second retry, which is the case the store exists for.
353    pub fn claim(&mut self, key: &IdempotencyKey) -> Result<Claim<T>, IdempotencyError>
354    where
355        T: Clone,
356    {
357        if let Some(slot) = self.slots.get(key) {
358            return Ok(match slot {
359                Slot::Done(result) => Claim::Completed(result.clone()),
360                Slot::Held { .. } => Claim::InFlight,
361            });
362        }
363        if self.slots.len() >= self.capacity.get() {
364            let Some(oldest) = self.completed_order.pop_front() else {
365                return Err(IdempotencyError::StoreFull {
366                    capacity: self.capacity.get(),
367                });
368            };
369            self.slots.remove(&oldest);
370        }
371        self.next_generation += 1;
372        let generation = self.next_generation;
373        self.slots.insert(key.clone(), Slot::Held { generation });
374        Ok(Claim::Claimed {
375            token: ClaimToken {
376                key: key.clone(),
377                generation,
378            },
379        })
380    }
381
382    /// Records the result of a claim.
383    ///
384    /// # Errors
385    ///
386    /// [`StaleClaim`] when the token is not the current holder of the key —
387    /// because the claim was abandoned and re-taken, or because it was never
388    /// valid. A stale completion is refused rather than applied: the attempt
389    /// that replaced it is the one whose result the retrying client is entitled
390    /// to, and writing over it would give two callers two different answers for
391    /// one request id.
392    pub fn complete(&mut self, token: &ClaimToken, result: T) -> Result<(), StaleClaim> {
393        match self.slots.get(&token.key) {
394            Some(Slot::Held { generation }) if *generation == token.generation => {}
395            _ => return Err(StaleClaim::new(token)),
396        }
397        self.slots.insert(token.key.clone(), Slot::Done(result));
398        self.completed_order.push_back(token.key.clone());
399        Ok(())
400    }
401
402    /// Releases a claim so a later retry can execute.
403    ///
404    /// # Errors
405    ///
406    /// [`StaleClaim`] when the token is not the current holder. Releasing a
407    /// claim another attempt now owns would hand the same key to a third
408    /// caller while the second is still running it.
409    pub fn abandon(&mut self, token: &ClaimToken) -> Result<(), StaleClaim> {
410        match self.slots.get(&token.key) {
411            Some(Slot::Held { generation }) if *generation == token.generation => {}
412            _ => return Err(StaleClaim::new(token)),
413        }
414        self.slots.remove(&token.key);
415        Ok(())
416    }
417
418    /// Whether a key has completed, without claiming it.
419    #[must_use]
420    pub fn completed(&self, key: &IdempotencyKey) -> Option<&T> {
421        match self.slots.get(key) {
422            Some(Slot::Done(result)) => Some(result),
423            Some(Slot::Held { .. }) | None => None,
424        }
425    }
426
427    /// Whether a key is held by a live claim.
428    #[must_use]
429    pub fn is_in_flight(&self, key: &IdempotencyKey) -> bool {
430        matches!(self.slots.get(key), Some(Slot::Held { .. }))
431    }
432}
433
434/// A claim that is no longer the current holder of its key.
435#[derive(Debug, Clone, PartialEq, Eq, Error)]
436#[error(
437    "the claim on key {key} from attempt {stale_generation} is stale: the key has \
438     since been released and re-claimed, and the later attempt owns the result"
439)]
440pub struct StaleClaim {
441    key: IdempotencyKey,
442    stale_generation: u64,
443}
444
445impl StaleClaim {
446    /// The refusal, naming the key and the attempt that was too late.
447    #[must_use]
448    pub fn new(token: &ClaimToken) -> Self {
449        Self {
450            key: token.key.clone(),
451            stale_generation: token.generation,
452        }
453    }
454
455    /// The key.
456    #[must_use]
457    pub const fn key(&self) -> &IdempotencyKey {
458        &self.key
459    }
460
461    /// The attempt that was too late.
462    #[must_use]
463    pub const fn stale_generation(&self) -> u64 {
464        self.stale_generation
465    }
466}
467
468/// Why a claim could not be taken.
469#[derive(Debug, Clone, PartialEq, Eq, Error)]
470pub enum IdempotencyError {
471    /// Every slot is held by a request that has not finished, and the key is new.
472    ///
473    /// Finished results are evicted oldest-first to make room, so this means the
474    /// store is full of work in progress. See [`CompletedResults::claim`].
475    #[error(
476        "the idempotency store holds {capacity} keys and cannot claim another without risking a lost result"
477    )]
478    StoreFull {
479        /// The ceiling, in keys.
480        capacity: usize,
481    },
482
483    /// A claim was presented that no longer holds its key.
484    #[error(transparent)]
485    Stale(#[from] StaleClaim),
486
487    /// The store could not answer, for a reason of its own.
488    ///
489    /// In the in-memory adapter that is a poisoned lock: some other caller's
490    /// panic unwound through it, and the state inside is no longer something
491    /// this process can vouch for. The store refuses rather than guessing,
492    /// because a wrong answer here means either a side effect that ran twice or
493    /// a result handed to a caller that did not earn it.
494    ///
495    /// It is a variant of its own rather than a [`StaleClaim`] because "your
496    /// claim is too late" and "I cannot tell you anything" are different facts,
497    /// and a caller can act on the first and only retry on the second.
498    #[error("the idempotency store could not answer: {reason}")]
499    Unavailable {
500        /// What went wrong, in the adapter's words.
501        reason: &'static str,
502    },
503}
504
505#[cfg(test)]
506mod tests {
507    use super::{
508        Claim, ClaimToken, CompletedResults, IdempotencyError, IdempotencyKey, StaleClaim,
509    };
510    use crate::domain::RequestId;
511    use core::num::NonZeroUsize;
512    use std::sync::atomic::{AtomicUsize, Ordering};
513    use std::sync::{Arc, Mutex};
514    use std::thread;
515
516    fn store(capacity: usize) -> Result<CompletedResults<String>, Failure> {
517        let capacity = NonZeroUsize::new(capacity).ok_or(Failure::ZeroCapacity)?;
518        Ok(CompletedResults::with_capacity(capacity))
519    }
520
521    /// A test that panics on `.expect()` cannot tell a failed assertion from a
522    /// failed fixture, so every fallible call below is `?`'d into a `Failure`
523    /// arm instead.
524    #[derive(Debug, PartialEq, Eq)]
525    enum Failure {
526        ZeroCapacity,
527        WrongClaim(&'static str),
528        WrongResult(&'static str),
529        WrongError(&'static str),
530        NotStale,
531        Stale,
532        Unexpected(String),
533    }
534
535    impl From<StaleClaim> for Failure {
536        fn from(_: StaleClaim) -> Self {
537            Self::Stale
538        }
539    }
540
541    /// A store error carries its own explanation, so it is carried through
542    /// rather than flattened. The fixtures that expect a particular error match
543    /// on [`Failure::WrongError`] instead, so a conversion here cannot mask the
544    /// distinction a test is making.
545    impl From<IdempotencyError> for Failure {
546        fn from(error: IdempotencyError) -> Self {
547            Self::Unexpected(error.to_string())
548        }
549    }
550
551    fn token_of<T>(claim: &Claim<T>) -> Result<ClaimToken, Failure> {
552        claim
553            .token()
554            .cloned()
555            .ok_or(Failure::WrongClaim("not claimed"))
556    }
557
558    /// A token names the key it was issued for and the attempt it belongs to.
559    ///
560    /// Both are what make a token *checkable* rather than merely unique, so
561    /// this pins that the accessors read back the key that was claimed rather
562    /// than a copy that could drift from it.
563    #[test]
564    fn a_token_reads_back_the_key_and_the_attempt() -> Result<(), Failure> {
565        let mut results = store(4)?;
566        let key = IdempotencyKey::new("req-1");
567        let token = token_of(&results.claim(&key)?)?;
568
569        if token.key() != &key {
570            return Err(Failure::WrongResult("a different key"));
571        }
572        let first = token.generation();
573        if first == 0 {
574            return Err(Failure::WrongResult("generation zero"));
575        }
576
577        // A re-claim after the attempt was abandoned is a *different* attempt,
578        // and the generation is what tells them apart. A store that handed back
579        // the old token would let a stale attempt overwrite a newer result.
580        results.abandon(&token)?;
581        let retaken = token_of(&results.claim(&key)?)?;
582        if retaken.generation() <= first {
583            return Err(Failure::WrongResult(
584                "a re-claim did not advance the generation",
585            ));
586        }
587        Ok(())
588    }
589
590    /// The first claim on a fresh key comes back owned, and the key then reads
591    /// as in flight rather than as absent — the window a naive check-then-execute
592    /// leaves open.
593    #[test]
594    fn a_fresh_key_is_claimed_and_then_held() -> Result<(), Failure> {
595        let mut results = store(4)?;
596        let key = IdempotencyKey::new("req-1");
597
598        let claim = results.claim(&key)?;
599        if !claim.is_execution_mine() {
600            return Err(Failure::WrongClaim("a fresh key was not claimed"));
601        }
602        if !results.is_in_flight(&key) {
603            return Err(Failure::WrongResult("a claimed key did not read as held"));
604        }
605        if results.completed(&key).is_some() {
606            return Err(Failure::WrongResult("a held key read as completed"));
607        }
608        Ok(())
609    }
610
611    /// **The claim is the check.** A second claim on a held key reports
612    /// `InFlight` and does not hand out a token, so the second caller has
613    /// nothing it could execute under.
614    #[test]
615    fn a_held_key_refuses_a_second_claim() -> Result<(), Failure> {
616        let mut results = store(4)?;
617        let key = IdempotencyKey::new("req-1");
618        let _first = results.claim(&key)?;
619
620        match results.claim(&key)? {
621            Claim::InFlight => Ok(()),
622            Claim::Claimed { .. } => Err(Failure::WrongClaim("a held key was re-claimed")),
623            Claim::Completed(_) => Err(Failure::WrongClaim("a held key reported a result")),
624        }
625    }
626
627    /// A completed key hands the recorded result back rather than a token, so
628    /// the caller has nothing it could execute under — which is the requirement
629    /// that the retry replays the original response.
630    #[test]
631    fn a_completed_key_hands_back_the_result_without_a_token() -> Result<(), Failure> {
632        let mut results = store(4)?;
633        let key = IdempotencyKey::new("req-1");
634        let token = token_of(&results.claim(&key)?)?;
635        results.complete(&token, "original".to_owned())?;
636
637        match results.claim(&key)? {
638            Claim::Completed(result) => {
639                if result != "original" {
640                    return Err(Failure::WrongResult(
641                        "the recorded result was not handed back",
642                    ));
643                }
644                if results.is_in_flight(&key) {
645                    return Err(Failure::WrongResult("a completed key read as held"));
646                }
647                Ok(())
648            }
649            Claim::Claimed { .. } => Err(Failure::WrongClaim("a completed key was re-claimed")),
650            Claim::InFlight => Err(Failure::WrongClaim("a completed key reported in flight")),
651        }
652    }
653
654    /// Abandoning returns the key to absent, so a retry after a *failed* attempt
655    /// can run. Without this, one transient failure would poison the key
656    /// forever and the client could never get an answer.
657    #[test]
658    fn abandoning_releases_the_key_for_a_retry() -> Result<(), Failure> {
659        let mut results = store(4)?;
660        let key = IdempotencyKey::new("req-1");
661        let token = token_of(&results.claim(&key)?)?;
662        results.abandon(&token)?;
663
664        if results.is_in_flight(&key) {
665            return Err(Failure::WrongResult("an abandoned key still reads as held"));
666        }
667        if results.completed(&key).is_some() {
668            return Err(Failure::WrongResult("an abandoned key read as completed"));
669        }
670        if !results.claim(&key)?.is_execution_mine() {
671            return Err(Failure::WrongClaim("an abandoned key was not re-claimable"));
672        }
673        Ok(())
674    }
675
676    /// **The stale-claim case.** A slow first attempt comes back after a failure
677    /// released its key and a second attempt re-claimed it. The late completion
678    /// is refused, so the retrying client gets the *second* attempt's result
679    /// rather than whichever landed last.
680    #[test]
681    fn a_late_completion_from_an_abandoned_claim_is_refused() -> Result<(), Failure> {
682        let mut results = store(4)?;
683        let key = IdempotencyKey::new("req-1");
684
685        let slow = token_of(&results.claim(&key)?)?;
686        results.abandon(&slow)?;
687        let retry = token_of(&results.claim(&key)?)?;
688        results.complete(&retry, "second".to_owned())?;
689
690        if results.complete(&slow, "first".to_owned()).is_ok() {
691            return Err(Failure::NotStale);
692        }
693        match results.completed(&key) {
694            Some(result) if result == "second" => Ok(()),
695            Some(_) => Err(Failure::WrongResult(
696                "the stale result overwrote the current one",
697            )),
698            None => Err(Failure::WrongResult("the completed key read as absent")),
699        }
700    }
701
702    /// A late *abandon* is refused too. Releasing a key a second attempt now
703    /// owns would let a third caller start while the second is still running.
704    #[test]
705    fn a_late_abandon_from_an_abandoned_claim_is_refused() -> Result<(), Failure> {
706        let mut results = store(4)?;
707        let key = IdempotencyKey::new("req-1");
708        let slow = token_of(&results.claim(&key)?)?;
709        results.abandon(&slow)?;
710        let retry = token_of(&results.claim(&key)?)?;
711
712        if results.abandon(&slow).is_ok() {
713            return Err(Failure::NotStale);
714        }
715        if !results.is_in_flight(&key) {
716            return Err(Failure::WrongResult(
717                "a late abandon released the live claim",
718            ));
719        }
720        if results.claim(&key)?.is_execution_mine() {
721            return Err(Failure::WrongClaim("the live claim was stealable"));
722        }
723        results.complete(&retry, "second".to_owned())?;
724        Ok(())
725    }
726
727    /// Completing a key that was never claimed is refused. A token no store ever
728    /// issued cannot install a result nobody executed for.
729    #[test]
730    fn completing_an_unclaimed_key_is_refused() -> Result<(), Failure> {
731        let mut results = store(4)?;
732        let key = IdempotencyKey::new("never-seen");
733        let forged = ClaimToken {
734            key: key.clone(),
735            generation: 999,
736        };
737        if results.complete(&forged, "invented".to_owned()).is_ok() {
738            return Err(Failure::NotStale);
739        }
740        if results.completed(&key).is_some() {
741            return Err(Failure::WrongResult("a forged token installed a result"));
742        }
743        Ok(())
744    }
745
746    /// **Counting executions.** Sixty-four threads claim the same key and run
747    /// the side effect only if the claim was theirs. The counter is what the
748    /// assertion is about: not "the same result came back" but "the work
749    /// happened once". This is the test the idempotency requirement is really
750    /// about, and it is the one a check-then-execute handler fails — the mutex
751    /// is a stand-in here for the adapter's lock, and the domain machine is
752    /// what decides who executes.
753    #[test]
754    fn exactly_one_of_many_concurrent_callers_executes() -> Result<(), Failure> {
755        const THREADS: usize = 64;
756
757        let results = Arc::new(Mutex::new(store(8)?));
758        let executions = Arc::new(AtomicUsize::new(0));
759        let mut workers = Vec::with_capacity(THREADS);
760        for _ in 0..THREADS {
761            let results = Arc::clone(&results);
762            let executions = Arc::clone(&executions);
763            workers.push(thread::spawn(move || {
764                let key = IdempotencyKey::new("req-1");
765                let mut held = results
766                    .lock()
767                    .map_err(|_| Failure::Unexpected("poisoned".to_owned()))?;
768                match held.claim(&key) {
769                    Err(error) => Err(Failure::Unexpected(error.to_string())),
770                    // Only the owner executes; everyone else must not.
771                    Ok(Claim::Claimed { token }) => {
772                        executions.fetch_add(1, Ordering::SeqCst);
773                        held.complete(&token, "ran".to_owned())
774                            .map_err(Failure::from)
775                    }
776                    Ok(Claim::InFlight | Claim::Completed(_)) => Ok(()),
777                }
778            }));
779        }
780        for worker in workers {
781            worker
782                .join()
783                .map_err(|_| Failure::Unexpected("panicked".to_owned()))??;
784        }
785
786        let count = executions.load(Ordering::SeqCst);
787        if count != 1 {
788            return Err(Failure::Unexpected(format!("{count} executions")));
789        }
790        let held = results
791            .lock()
792            .map_err(|_| Failure::Unexpected("poisoned".to_owned()))?;
793        if held.len() != 1 {
794            return Err(Failure::Unexpected(format!("{} keys", held.len())));
795        }
796        drop(held);
797        Ok(())
798    }
799
800    /// Distinct keys are independent: claiming one does not block another, and
801    /// completing one does not complete the other.
802    #[test]
803    fn distinct_keys_do_not_interfere() -> Result<(), Failure> {
804        let mut results = store(4)?;
805        let first = IdempotencyKey::new("req-1");
806        let second = IdempotencyKey::new("req-2");
807
808        let token = token_of(&results.claim(&first)?)?;
809        if !results.claim(&second)?.is_execution_mine() {
810            return Err(Failure::WrongClaim("a second key was blocked by the first"));
811        }
812        results.complete(&token, "one".to_owned())?;
813        if results.completed(&second).is_some() {
814            return Err(Failure::WrongResult("completing one key completed another"));
815        }
816        Ok(())
817    }
818
819    /// A full store of finished work makes room for a new key by dropping the
820    /// oldest result, so a long-lived session keeps working. Without this the
821    /// store fills once and every later call fails.
822    #[test]
823    fn a_full_store_drops_its_oldest_result_for_a_new_key() -> Result<(), Failure> {
824        let mut results = store(2)?;
825        for index in 0..2 {
826            let key = IdempotencyKey::new(format!("req-{index}"));
827            let token = token_of(&results.claim(&key)?)?;
828            results.complete(&token, format!("result-{index}"))?;
829        }
830
831        let overflow = IdempotencyKey::new("req-overflow");
832        if !results.claim(&overflow)?.is_execution_mine() {
833            return Err(Failure::WrongClaim("a full store refused a new key"));
834        }
835        if results.len() != 2 {
836            return Err(Failure::WrongResult("the ceiling was exceeded"));
837        }
838
839        // The newer result is still replayed; only the oldest went.
840        let kept = IdempotencyKey::new("req-1");
841        match results.claim(&kept)? {
842            Claim::Completed(result) if result == "result-1" => {}
843            _ => return Err(Failure::WrongResult("a full store lost the newer result")),
844        }
845        let dropped = IdempotencyKey::new("req-0");
846        if results.completed(&dropped).is_some() {
847            return Err(Failure::WrongResult("the oldest result was kept"));
848        }
849        Ok(())
850    }
851
852    /// A claim still running is never the one dropped: discarding it would let
853    /// two callers run one request. A store full of running work refuses.
854    #[test]
855    fn a_store_full_of_running_work_refuses_a_new_key() -> Result<(), Failure> {
856        let mut results = store(2)?;
857        for index in 0..2 {
858            let key = IdempotencyKey::new(format!("req-{index}"));
859            let _ = results.claim(&key)?;
860        }
861
862        match results.claim(&IdempotencyKey::new("req-overflow")) {
863            Err(IdempotencyError::StoreFull { capacity }) => {
864                if capacity != 2 {
865                    return Err(Failure::WrongError("the ceiling was misreported"));
866                }
867                Ok(())
868            }
869            Err(other) => Err(Failure::Unexpected(other.to_string())),
870            Ok(_) => Err(Failure::WrongClaim("a full store handed out a claim")),
871        }
872    }
873
874    /// The key is the request id a retrying client repeats, rendered through
875    /// the id's own `Display` so a numeric id and its printed form agree.
876    #[test]
877    fn the_key_is_the_request_id_a_retry_repeats() -> Result<(), Failure> {
878        let numeric = IdempotencyKey::from(&RequestId::number(7));
879        let text = IdempotencyKey::new("7");
880        if numeric != text {
881            return Err(Failure::Unexpected(format!("{numeric} != {text}")));
882        }
883        if numeric.as_str() != "7" {
884            return Err(Failure::Unexpected(numeric.to_string()));
885        }
886
887        // And the same key deduplicates regardless of which spelling the client
888        // used, which is the property that makes it usable.
889        let mut results = store(4)?;
890        let token = token_of(&results.claim(&numeric)?)?;
891        results.complete(&token, "once".to_owned())?;
892        match results.claim(&text)? {
893            Claim::Completed(result) if result == "once" => Ok(()),
894            _ => Err(Failure::WrongClaim("a re-spelled key was treated as new")),
895        }
896    }
897
898    /// An empty store claims cleanly, which is the state a fresh server starts
899    /// in and the one a call would otherwise fail against.
900    #[test]
901    fn an_empty_store_claims_cleanly() -> Result<(), Failure> {
902        let results = store(4)?;
903        if !results.is_empty() {
904            return Err(Failure::WrongResult("a fresh store was not empty"));
905        }
906        let key = IdempotencyKey::new("req-1");
907        if results.completed(&key).is_some() || results.is_in_flight(&key) {
908            return Err(Failure::WrongResult(
909                "a fresh store reported an existing key",
910            ));
911        }
912        Ok(())
913    }
914}