Skip to main content

IdempotencyStore

Trait IdempotencyStore 

Source
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§

Source

type Result

What a completed side effect produced. The adapter’s own result type — an IrResult for a tool call, a transport payload for a bridge forward.

Required Methods§

Source

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.

Source

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.

Source

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".

Implementors§