use std::{sync::Arc, time::Duration};
use dashmap::DashMap;
use serde_json::Value;
use tokio::time::Instant;
use xxhash_rust::xxh3::xxh3_64;
#[derive(Debug)]
#[non_exhaustive]
pub enum IdempotencyCheck {
New,
Replay(StoredResponse),
Conflict,
}
#[derive(Debug, Clone)]
#[cfg_attr(
feature = "redis-idempotency",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct StoredResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Option<Value>,
}
struct Entry {
response: StoredResponse,
body_hash: u64,
created_at: Instant,
}
pub trait IdempotencyStore: Send + Sync {
fn check(
&self,
key: &ScopedIdempotencyKey,
body_hash: u64,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = IdempotencyCheck> + Send + '_>>;
fn store(
&self,
key: ScopedIdempotencyKey,
body_hash: u64,
response: StoredResponse,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>>;
}
pub struct InMemoryIdempotencyStore {
entries: DashMap<String, Entry>,
ttl: Duration,
max_entries: usize,
}
impl InMemoryIdempotencyStore {
#[must_use]
pub fn new(ttl: Duration, max_entries: usize) -> Self {
Self {
entries: DashMap::new(),
ttl,
max_entries,
}
}
fn evict_expired(&self) {
let expired_keys: Vec<String> = self
.entries
.iter()
.filter(|e| e.created_at.elapsed() > self.ttl)
.take(100)
.map(|e| e.key().clone())
.collect();
for key in expired_keys {
self.entries.remove(&key);
}
}
fn find_oldest_key(&self) -> Option<String> {
self.entries.iter().min_by_key(|e| e.created_at).map(|e| e.key().clone())
}
}
impl IdempotencyStore for InMemoryIdempotencyStore {
fn check(
&self,
key: &ScopedIdempotencyKey,
body_hash: u64,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = IdempotencyCheck> + Send + '_>> {
let key = key.as_str();
let result = if let Some(entry) = self.entries.get(key) {
if entry.created_at.elapsed() > self.ttl {
drop(entry);
self.entries.remove(key);
IdempotencyCheck::New
} else if entry.body_hash == body_hash {
IdempotencyCheck::Replay(entry.response.clone())
} else {
IdempotencyCheck::Conflict
}
} else {
IdempotencyCheck::New
};
Box::pin(std::future::ready(result))
}
fn store(
&self,
key: ScopedIdempotencyKey,
body_hash: u64,
response: StoredResponse,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>> {
self.evict_expired();
if self.entries.len() >= self.max_entries {
if let Some(oldest_key) = self.find_oldest_key() {
self.entries.remove(&oldest_key);
}
}
self.entries.insert(
key.as_str().to_string(),
Entry {
response,
body_hash,
created_at: Instant::now(),
},
);
Box::pin(std::future::ready(()))
}
}
#[cfg(feature = "redis-idempotency")]
mod redis_store;
#[cfg(feature = "redis-idempotency")]
pub use redis_store::RedisIdempotencyStore;
#[must_use]
pub fn hash_body(body: &Value) -> u64 {
let normalized = fraiseql_core::apq::normalize_json_value(body.clone());
let bytes = serde_json::to_string(&normalized).unwrap_or_else(|_| normalized.to_string());
xxh3_64(bytes.as_bytes())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdempotencyScope {
pub tenant: Option<String>,
pub principal: Option<String>,
pub method: String,
pub path: String,
}
impl IdempotencyScope {
#[must_use]
pub fn key(&self, client_key: &str) -> ScopedIdempotencyKey {
let tenant = self.tenant.as_deref().unwrap_or("");
let principal = self.principal.as_deref().map_or_else(String::new, |p| format!("@{p}"));
let mut out = String::with_capacity(
tenant.len()
+ principal.len()
+ self.method.len()
+ self.path.len()
+ client_key.len()
+ 20,
);
for segment in [
tenant,
principal.as_str(),
self.method.as_str(),
self.path.as_str(),
client_key,
] {
out.push_str(&segment.len().to_string());
out.push(':');
out.push_str(segment);
out.push('|');
}
ScopedIdempotencyKey(out)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopedIdempotencyKey(String);
impl ScopedIdempotencyKey {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
pub const GRAPHQL_IDEMPOTENCY_TTL_SECS: u64 = 86_400;
#[must_use]
pub fn create_store(ttl_seconds: u64) -> Arc<dyn IdempotencyStore> {
Arc::new(InMemoryIdempotencyStore::new(Duration::from_secs(ttl_seconds), 10_000))
}
#[cfg(feature = "redis-idempotency")]
#[must_use]
pub fn create_store_with_redis(
ttl_seconds: u64,
redis_pool: Option<redis::aio::ConnectionManager>,
) -> Arc<dyn IdempotencyStore> {
if let Some(pool) = redis_pool {
Arc::new(RedisIdempotencyStore::new(pool, Duration::from_secs(ttl_seconds)))
} else {
create_store(ttl_seconds)
}
}
#[cfg(test)]
mod tests;