use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use camel_api::{CamelError, IdempotentRepository};
use dashmap::DashMap;
pub const DEFAULT_MAX_ENTRIES: usize = 100_000;
#[derive(Debug)]
struct Entry {
seq: u64,
}
#[derive(Debug)]
pub struct MemoryIdempotentRepository {
name: String,
keys: DashMap<String, Entry>,
max_entries: usize,
next_seq: AtomicU64,
write_guard: Mutex<()>,
}
impl MemoryIdempotentRepository {
pub fn new(name: impl Into<String>) -> Self {
Self::new_with_max_entries(name, DEFAULT_MAX_ENTRIES)
}
pub fn new_with_max_entries(name: impl Into<String>, max_entries: usize) -> Self {
assert!(max_entries > 0, "max_entries must be > 0");
Self {
name: name.into(),
keys: DashMap::new(),
max_entries,
next_seq: AtomicU64::new(0),
write_guard: Mutex::new(()),
}
}
pub fn max_entries(&self) -> usize {
self.max_entries
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
#[async_trait::async_trait]
impl IdempotentRepository for MemoryIdempotentRepository {
fn name(&self) -> &str {
&self.name
}
async fn contains(&self, key: &str) -> Result<bool, CamelError> {
Ok(self.keys.contains_key(key))
}
async fn add(&self, key: &str) -> Result<bool, CamelError> {
let _w = self.write_guard.lock().unwrap_or_else(|p| p.into_inner());
if !self.keys.contains_key(key) && self.keys.len() >= self.max_entries {
let oldest = self
.keys
.iter()
.min_by_key(|e| e.value().seq)
.map(|e| e.key().clone());
if let Some(k) = oldest {
self.keys.remove(&k);
}
}
let prior = self.keys.insert(
key.to_string(),
Entry {
seq: self.next_seq.fetch_add(1, Ordering::Relaxed),
},
);
Ok(prior.is_none())
}
async fn remove(&self, key: &str) -> Result<(), CamelError> {
self.keys.remove(key);
Ok(())
}
async fn clear(&self) -> Result<(), CamelError> {
self.keys.clear();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn new_repo() -> MemoryIdempotentRepository {
MemoryIdempotentRepository::new("test")
}
#[tokio::test]
async fn add_then_contains() {
let repo = new_repo();
repo.add("key-1").await.unwrap();
assert!(repo.contains("key-1").await.unwrap());
}
#[tokio::test]
async fn second_add_returns_false() {
let repo = new_repo();
assert!(repo.add("key-1").await.unwrap(), "first add should be true");
assert!(
!repo.add("key-1").await.unwrap(),
"second add should be false"
);
}
#[tokio::test]
async fn remove_then_contains_false() {
let repo = new_repo();
repo.add("key-1").await.unwrap();
repo.remove("key-1").await.unwrap();
assert!(!repo.contains("key-1").await.unwrap());
}
#[tokio::test]
async fn clear_removes_all() {
let repo = new_repo();
repo.add("a").await.unwrap();
repo.add("b").await.unwrap();
repo.clear().await.unwrap();
assert!(!repo.contains("a").await.unwrap());
assert!(!repo.contains("b").await.unwrap());
}
#[tokio::test]
async fn contains_returns_false_for_missing_key() {
let repo = new_repo();
assert!(!repo.contains("nonexistent").await.unwrap());
}
#[tokio::test]
async fn remove_nonexistent_is_ok() {
let repo = new_repo();
repo.remove("never-added").await.unwrap();
}
#[test]
fn name_returns_configured_name() {
let repo = MemoryIdempotentRepository::new("my-cache");
assert_eq!(repo.name(), "my-cache");
}
#[tokio::test]
async fn test_idempotent_repo_caps_at_max_entries_evicting_oldest() {
let repo = MemoryIdempotentRepository::new_with_max_entries("test", 4);
for i in 0..4 {
repo.add(&format!("k{i}")).await.unwrap();
}
assert_eq!(repo.len(), 4);
repo.add("k4").await.unwrap();
assert_eq!(repo.len(), 4, "cap must be enforced");
assert!(
!repo.contains("k0").await.unwrap(),
"oldest (k0) must be evicted"
);
for i in 1..=4 {
assert!(
repo.contains(&format!("k{i}")).await.unwrap(),
"k{i} must be present after eviction"
);
}
}
#[tokio::test]
async fn test_idempotent_evicted_key_readmitted_as_new() {
let repo = MemoryIdempotentRepository::new_with_max_entries("test", 2);
repo.add("k0").await.unwrap();
repo.add("k1").await.unwrap();
repo.add("k2").await.unwrap(); assert!(!repo.contains("k0").await.unwrap(), "k0 must be evicted");
assert!(
repo.add("k0").await.unwrap(),
"evicted key must re-admit as new (documented at-most-once ceiling)"
);
}
#[test]
fn test_idempotent_repo_default_max_entries_is_100_000() {
let repo = MemoryIdempotentRepository::new("test");
assert_eq!(repo.max_entries(), DEFAULT_MAX_ENTRIES);
}
#[test]
fn test_idempotent_repo_new_with_max_entries_sets_cap() {
let repo = MemoryIdempotentRepository::new_with_max_entries("test", 7);
assert_eq!(repo.max_entries(), 7);
}
}