use core::num::NonZeroUsize;
use crate::domain::idempotency::{Claim, ClaimToken, IdempotencyError, IdempotencyKey};
#[must_use]
pub const fn default_capacity() -> NonZeroUsize {
match NonZeroUsize::new(4_096) {
Some(value) => value,
None => NonZeroUsize::MIN,
}
}
pub trait IdempotencyStore: Send + Sync {
type Result;
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>;
}
#[cfg(test)]
mod tests {
use super::{IdempotencyStore, default_capacity};
use crate::adapters::secondary::idempotency::InMemoryIdempotencyStore;
use crate::domain::idempotency::{
Claim, CompletedResults, IdempotencyError, IdempotencyKey, StaleClaim,
};
use core::num::NonZeroUsize;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
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,
Poisoned,
Unexpected(String),
}
impl From<IdempotencyError> for Failure {
fn from(error: IdempotencyError) -> Self {
Self::Unexpected(error.to_string())
}
}
impl From<StaleClaim> for Failure {
fn from(error: StaleClaim) -> Self {
Self::Unexpected(error.to_string())
}
}
#[test]
fn is_usable_behind_a_trait_object() -> Result<(), Failure> {
let concrete: Arc<dyn IdempotencyStore<Result = String>> = Arc::new(store(4)?);
let key = IdempotencyKey::new("req-1");
let claim = concrete.claim(&key)?;
if !claim.is_execution_mine() {
return Err(Failure::Unexpected("not claimed".to_owned()));
}
concrete.complete(
claim
.token()
.ok_or_else(|| Failure::Unexpected("no token".to_owned()))?,
"done".to_owned(),
)?;
Ok(())
}
#[test]
fn the_default_capacity_is_usable() -> Result<(), Failure> {
if default_capacity().get() == 0 {
return Err(Failure::Unexpected("zero".to_owned()));
}
let store = InMemoryIdempotencyStore::<String>::default();
if store.capacity()? != default_capacity().get() {
return Err(Failure::Unexpected("mismatch".to_owned()));
}
Ok(())
}
#[test]
fn one_execution_survives_sixty_four_racing_retries() -> Result<(), Failure> {
const THREADS: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Saw {
Ran(usize),
Replayed,
InFlight,
}
let store: Arc<dyn IdempotencyStore<Result = String>> = Arc::new(store(8)?);
let executions = Arc::new(AtomicUsize::new(0));
let observed = Arc::new(Mutex::new(Vec::new()));
let mut workers = Vec::with_capacity(THREADS);
for worker_id in 0..THREADS {
let store = Arc::clone(&store);
let executions = Arc::clone(&executions);
let observed = Arc::clone(&observed);
workers.push(thread::spawn(move || -> Result<(), Failure> {
let key = IdempotencyKey::new("req-1");
let saw = match store.claim(&key)? {
Claim::Claimed { token } => {
executions.fetch_add(1, Ordering::SeqCst);
store.complete(&token, format!("worker-{worker_id}"))?;
Saw::Ran(worker_id)
}
Claim::Completed(result) => {
if result.is_empty() {
return Err(Failure::Unexpected("empty replay".to_owned()));
}
Saw::Replayed
}
Claim::InFlight => Saw::InFlight,
};
observed
.lock()
.map_err(|_| Failure::Poisoned)?
.push((worker_id, saw));
Ok(())
}));
}
for worker in workers {
worker
.join()
.map_err(|_| Failure::Unexpected("panicked".to_owned()))??;
}
let count = executions.load(Ordering::SeqCst);
if count != 1 {
return Err(Failure::Unexpected(format!("{count} executions")));
}
let seen = observed.lock().map_err(|_| Failure::Poisoned)?.clone();
if seen.len() != THREADS {
return Err(Failure::Unexpected(format!("{} observers", seen.len())));
}
let ran = seen
.iter()
.filter(|(_, saw)| matches!(saw, Saw::Ran(_)))
.count();
if ran != 1 {
return Err(Failure::Unexpected(format!("{ran} workers ran it")));
}
Ok(())
}
#[test]
fn a_failed_execution_releases_the_key_for_the_retry() -> Result<(), Failure> {
let store = store(4)?;
let key = IdempotencyKey::new("req-1");
let first = store.claim(&key)?;
let token = first
.token()
.cloned()
.ok_or_else(|| Failure::Unexpected("no token".to_owned()))?;
store.abandon(&token)?;
let second = store.claim(&key)?;
if !second.is_execution_mine() {
return Err(Failure::Unexpected("not re-claimed".to_owned()));
}
store.complete(
second
.token()
.ok_or_else(|| Failure::Unexpected("no token".to_owned()))?,
"second".to_owned(),
)?;
match store.claim(&key)? {
Claim::Completed(result) if result == "second" => Ok(()),
other => Err(Failure::Unexpected(format!("{other:?}"))),
}
}
#[test]
fn is_shareable() -> Result<(), Failure> {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<InMemoryIdempotencyStore<String>>();
let _ = store(1)?;
Ok(())
}
#[test]
fn the_adapter_owns_no_second_state() -> Result<(), Failure> {
let store = store(3)?;
if store.capacity()? != 3 {
return Err(Failure::Unexpected("capacity mismatch".to_owned()));
}
let mut machine = CompletedResults::<String>::with_capacity(
NonZeroUsize::new(3).ok_or(Failure::ZeroCapacity)?,
);
let key = IdempotencyKey::new("req-1");
if !machine.claim(&key)?.is_execution_mine() {
return Err(Failure::Unexpected("machine refused".to_owned()));
}
Ok(())
}
}