use core::num::NonZeroUsize;
use std::sync::Mutex;
use crate::application::idempotency::{IdempotencyStore, default_capacity};
use crate::domain::idempotency::{
Claim, ClaimToken, CompletedResults, IdempotencyError, IdempotencyKey,
};
#[derive(Debug)]
pub struct InMemoryIdempotencyStore<T> {
results: Mutex<CompletedResults<T>>,
}
impl<T> InMemoryIdempotencyStore<T> {
#[must_use]
pub fn with_capacity(capacity: NonZeroUsize) -> Self {
Self {
results: Mutex::new(CompletedResults::with_capacity(capacity)),
}
}
pub fn capacity(&self) -> Result<usize, IdempotencyError> {
Ok(self.locked()?.capacity())
}
pub fn len(&self) -> Result<usize, IdempotencyError> {
Ok(self.locked()?.len())
}
pub fn is_empty(&self) -> Result<bool, IdempotencyError> {
Ok(self.locked()?.is_empty())
}
fn locked(&self) -> Result<std::sync::MutexGuard<'_, CompletedResults<T>>, IdempotencyError> {
self.results
.lock()
.map_err(|_| IdempotencyError::Unavailable {
reason: "the idempotency store's lock is poisoned",
})
}
}
impl<T: Clone + Send + Sync> IdempotencyStore for InMemoryIdempotencyStore<T> {
type Result = T;
fn claim(&self, key: &IdempotencyKey) -> Result<Claim<T>, IdempotencyError> {
self.locked()?.claim(key)
}
fn complete(&self, token: &ClaimToken, result: T) -> Result<(), IdempotencyError> {
self.locked()?.complete(token, result)?;
Ok(())
}
fn abandon(&self, token: &ClaimToken) -> Result<(), IdempotencyError> {
self.locked()?.abandon(token)?;
Ok(())
}
}
impl<T> Default for InMemoryIdempotencyStore<T> {
fn default() -> Self {
Self::with_capacity(default_capacity())
}
}
#[cfg(test)]
mod tests {
use super::InMemoryIdempotencyStore;
use crate::application::idempotency::IdempotencyStore;
use crate::domain::idempotency::{Claim, IdempotencyError, IdempotencyKey};
use core::num::NonZeroUsize;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
use std::thread;
fn store(capacity: usize) -> Result<InMemoryIdempotencyStore<String>, Failure> {
let capacity = NonZeroUsize::new(capacity).ok_or(Failure::ZeroCapacity)?;
Ok(InMemoryIdempotencyStore::with_capacity(capacity))
}
#[derive(Debug, PartialEq, Eq)]
enum Failure {
ZeroCapacity,
Unexpected(String),
}
impl From<IdempotencyError> for Failure {
fn from(error: IdempotencyError) -> Self {
Self::Unexpected(error.to_string())
}
}
#[test]
fn one_execution_survives_a_synchronised_race() -> Result<(), Failure> {
const THREADS: usize = 32;
let store: Arc<dyn IdempotencyStore<Result = String>> = Arc::new(store(4)?);
let executions = Arc::new(AtomicUsize::new(0));
let barrier = Arc::new(Barrier::new(THREADS));
let replays = Arc::new(AtomicUsize::new(0));
let inflight = Arc::new(AtomicUsize::new(0));
let mut workers = Vec::with_capacity(THREADS);
for _ in 0..THREADS {
let store = Arc::clone(&store);
let executions = Arc::clone(&executions);
let replays = Arc::clone(&replays);
let inflight = Arc::clone(&inflight);
let barrier = Arc::clone(&barrier);
workers.push(thread::spawn(move || -> Result<(), Failure> {
let key = IdempotencyKey::new("req-1");
barrier.wait();
match store.claim(&key)? {
Claim::Claimed { token } => {
executions.fetch_add(1, Ordering::SeqCst);
store.complete(&token, "the one run".to_owned())?;
}
Claim::Completed(_) => {
replays.fetch_add(1, Ordering::SeqCst);
}
Claim::InFlight => {
inflight.fetch_add(1, Ordering::SeqCst);
}
}
Ok(())
}));
}
for worker in workers {
worker
.join()
.map_err(|_| Failure::Unexpected("a worker panicked".to_owned()))??;
}
let ran = executions.load(Ordering::SeqCst);
if ran != 1 {
return Err(Failure::Unexpected(format!("{ran} executions")));
}
let benign = replays.load(Ordering::SeqCst) + inflight.load(Ordering::SeqCst);
if benign != THREADS - 1 {
return Err(Failure::Unexpected(format!(
"{benign} of {} losers accounted for",
THREADS - 1
)));
}
Ok(())
}
#[test]
fn a_stale_completion_is_refused_with_the_domain_error() -> Result<(), Failure> {
let store = store(4)?;
let key = IdempotencyKey::new("req-1");
let slow = store.claim(&key)?;
let slow_token = slow
.token()
.cloned()
.ok_or_else(|| Failure::Unexpected("no token".to_owned()))?;
store.abandon(&slow_token)?;
let retry = store.claim(&key)?;
let retry_token = retry
.token()
.cloned()
.ok_or_else(|| Failure::Unexpected("no token".to_owned()))?;
store.complete(&retry_token, "second".to_owned())?;
match store.complete(&slow_token, "first".to_owned()) {
Err(IdempotencyError::Stale(_)) => Ok(()),
other => Err(Failure::Unexpected(format!("{other:?}"))),
}
}
#[test]
fn the_ceiling_is_observable_and_fail_secure() -> Result<(), Failure> {
let store = store(1)?;
if store.capacity()? != 1 {
return Err(Failure::Unexpected("capacity".to_owned()));
}
let key = IdempotencyKey::new("req-1");
let _running = store.claim(&key)?;
match store.claim(&IdempotencyKey::new("req-2")) {
Err(IdempotencyError::StoreFull { capacity }) => {
if capacity != 1 {
return Err(Failure::Unexpected("misreported ceiling".to_owned()));
}
}
other => return Err(Failure::Unexpected(format!("{other:?}"))),
}
if store.len()? != 1 {
return Err(Failure::Unexpected("lost a key".to_owned()));
}
if store.is_empty()? {
return Err(Failure::Unexpected("reported empty".to_owned()));
}
Ok(())
}
#[test]
fn the_store_is_process_local() -> Result<(), Failure> {
let first = store(4)?;
let second = store(4)?;
let key = IdempotencyKey::new("req-1");
let claim = first.claim(&key)?;
first.complete(
claim
.token()
.ok_or_else(|| Failure::Unexpected("no token".to_owned()))?,
"only here".to_owned(),
)?;
if !second.claim(&key)?.is_execution_mine() {
return Err(Failure::Unexpected(
"results leaked between stores".to_owned(),
));
}
Ok(())
}
}