pub trait IdempotencyStore: Send + Sync {
type Result;
// Required methods
fn claim(
&self,
key: &IdempotencyKey,
) -> Result<Claim<Self::Result>, IdempotencyError>;
fn complete(
&self,
token: &ClaimToken,
result: Self::Result,
) -> Result<(), IdempotencyError>;
fn abandon(&self, token: &ClaimToken) -> Result<(), IdempotencyError>;
}Expand description
A store that runs each key’s side effect at most once.
§Implementing this
The three methods must be individually atomic with respect to one
another, and the atomicity has to be the implementation’s, not the caller’s.
Concretely: two threads calling claim with the same key must
never both receive Claim::Claimed. The in-memory adapter holds
CompletedResults behind a Mutex for exactly this, taking the lock once
per operation and releasing it before the caller executes — so a slow tool
does not serialise every other key behind it.
CompletedResults is the
transition table to delegate to; an adapter that is not in-memory has to
reproduce its transitions, and the invariants worth reproducing are that a
Claimed key stays reserved until completed or abandoned, and that a
completion carrying a stale ClaimToken is refused rather than applied.
§Example
use std::num::NonZeroUsize;
use std::sync::Mutex;
use protocol_droid::application::idempotency::IdempotencyStore;
use protocol_droid::domain::idempotency::{
Claim, ClaimToken, CompletedResults, IdempotencyError, IdempotencyKey,
};
struct InMemory<T>(Mutex<CompletedResults<T>>);
impl<T: Clone + Send + Sync> IdempotencyStore for InMemory<T> {
type Result = T;
fn claim(&self, key: &IdempotencyKey) -> Result<Claim<T>, IdempotencyError> {
self.0
.lock()
.map_err(|_| IdempotencyError::Unavailable { reason: "lock poisoned" })?
.claim(key)
}
fn complete(
&self,
token: &ClaimToken,
result: T,
) -> Result<(), IdempotencyError> {
self.0
.lock()
.map_err(|_| IdempotencyError::Unavailable { reason: "lock poisoned" })?
.complete(token, result)?;
Ok(())
}
fn abandon(&self, token: &ClaimToken) -> Result<(), IdempotencyError> {
self.0
.lock()
.map_err(|_| IdempotencyError::Unavailable { reason: "lock poisoned" })?
.abandon(token)?;
Ok(())
}
}
let store = InMemory(Mutex::new(CompletedResults::with_capacity(NonZeroUsize::MIN)));
let key = IdempotencyKey::new("req-1");
let first = store.claim(&key)?;
assert!(first.is_execution_mine());
let token = first.token().ok_or("claimed without a token")?;
store.complete(token, "the only run")?;
// A retry of the same request id replays rather than re-running.
match store.claim(&key)? {
Claim::Completed(result) => assert_eq!(result, "the only run"),
other => return Err(format!("re-claimed: {other:?}").into()),
}Send + Sync because the whole point of the store is to be consulted
concurrently by the requests it is deduplicating. An adapter that held one
behind &self without those bounds would force every tools/call handler
to reach a lock it cannot see, and the deduplication guarantee would then
depend on each caller remembering to take it.
Required Associated Types§
Required Methods§
Sourcefn claim(
&self,
key: &IdempotencyKey,
) -> Result<Claim<Self::Result>, IdempotencyError>
fn claim( &self, key: &IdempotencyKey, ) -> Result<Claim<Self::Result>, IdempotencyError>
Takes ownership of key, or reports that it is already spoken for.
§Errors
IdempotencyError::StoreFull when the store cannot hold another key
without risking the loss of a recorded result.
Sourcefn complete(
&self,
token: &ClaimToken,
result: Self::Result,
) -> Result<(), IdempotencyError>
fn complete( &self, token: &ClaimToken, result: Self::Result, ) -> Result<(), IdempotencyError>
Records the result of a claim the caller owns.
§Errors
StaleClaim when the token no longer holds its key, which happens
when the attempt was abandoned and a later one re-claimed it. The caller
should discard its result in that case: the later attempt’s is the one
the peer is entitled to. Also
IdempotencyError::Unavailable when the store itself could not answer.
Sourcefn abandon(&self, token: &ClaimToken) -> Result<(), IdempotencyError>
fn abandon(&self, token: &ClaimToken) -> Result<(), IdempotencyError>
Releases a claim whose execution did not produce a result.
Without this a failed attempt would reserve the key forever and every
retry would be told InFlight by a caller that no longer exists.
§Errors
StaleClaim when the token no longer holds its key. Also
IdempotencyError::Unavailable when the store itself could not answer.
Dyn Compatibility§
This trait is dyn compatible.
In older versions of Rust, dyn compatibility was called "object safety".