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}