use crate::error::{RuntimeError, RuntimeResult};
use crate::idempotency_file::{
append_entry, encoded_record_bytes, load_entries, read_entry, rewrite_entries, RecordLocation,
StoreFileState, MAX_ACTIVE_IDEMPOTENCY_RECORDS, MAX_IDEMPOTENCY_FILE_BYTES,
MAX_PERSISTED_IDEMPOTENCY_RECORDS,
};
use crate::ids::validate_identifier;
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub const IDEMPOTENCY_FORMAT_V1: &str = "# appcore-idempotency-v1";
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub enum IdempotencyStatus {
Pending,
Resolved {
response_status: u16,
response_body: String,
},
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct IdempotencyRecord {
pub key: String,
pub request_hash: String,
pub status: IdempotencyStatus,
pub created_at_ms: u64,
}
pub trait IdempotencyStore: Send + Sync {
fn get(&self, key: &str) -> RuntimeResult<Option<IdempotencyRecord>>;
fn insert(&mut self, record: IdempotencyRecord) -> RuntimeResult<()>;
fn len(&self) -> usize;
fn remove(&mut self, _key: &str) -> RuntimeResult<()> {
Ok(())
}
fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Default)]
pub struct InMemoryIdempotencyStore {
seen: HashMap<String, IdempotencyRecord>,
}
impl fmt::Debug for InMemoryIdempotencyStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("InMemoryIdempotencyStore")
.field("entry_count", &self.seen.len())
.finish()
}
}
impl InMemoryIdempotencyStore {
pub fn new() -> Self {
Self::default()
}
}
impl IdempotencyStore for InMemoryIdempotencyStore {
fn get(&self, key: &str) -> RuntimeResult<Option<IdempotencyRecord>> {
Ok(self.seen.get(key).cloned())
}
fn insert(&mut self, record: IdempotencyRecord) -> RuntimeResult<()> {
validate_key(&record.key)?;
ensure_active_capacity(&self.seen, &record.key)?;
self.seen.insert(record.key.clone(), record);
Ok(())
}
fn len(&self) -> usize {
self.seen.len()
}
fn remove(&mut self, key: &str) -> RuntimeResult<()> {
self.seen.remove(key);
Ok(())
}
}
pub struct FileIdempotencyStore {
file_path: PathBuf,
ttl_ms: Option<u64>,
seen: HashMap<String, RecordLocation>,
file_state: StoreFileState,
}
impl fmt::Debug for FileIdempotencyStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FileIdempotencyStore")
.field("file_path", &self.file_path)
.field("ttl_ms", &self.ttl_ms)
.field("entry_count", &self.seen.len())
.finish()
}
}
fn map_idempotency_io(operation: &'static str, err: std::io::Error) -> RuntimeError {
RuntimeError::IdempotencyStoreIo {
operation,
message: err.to_string(),
}
}
impl FileIdempotencyStore {
pub fn new(path: impl AsRef<Path>) -> RuntimeResult<Self> {
Self::new_with_ttl(path, None)
}
pub fn new_with_ttl(path: impl AsRef<Path>, ttl_ms: Option<u64>) -> RuntimeResult<Self> {
let file_path = path.as_ref().to_path_buf();
ensure_parent_dir(&file_path)?;
if !file_path.exists() {
rewrite_entries(&file_path, &HashMap::new(), None, |_, _| true)?;
}
let loaded = load_entries(&file_path)?;
let (seen, file_state) = if loaded.needs_rewrite {
let rewritten = rewrite_entries(&file_path, &loaded.entries, None, |_, _| true)?;
(rewritten.entries, rewritten.file_state)
} else {
(loaded.entries, loaded.file_state)
};
let ttl_ms = match ttl_ms {
Some(0) => None,
other => other,
};
Ok(Self {
file_path,
ttl_ms,
seen,
file_state,
})
}
pub fn file_path(&self) -> &Path {
&self.file_path
}
pub fn compact(&mut self, now_ms: u64) -> RuntimeResult<usize> {
let before = self.seen.len();
let ttl_ms = self.ttl_ms;
let rewritten = rewrite_entries(&self.file_path, &self.seen, None, |_, location| {
!is_expired(location.created_at_ms, ttl_ms, now_ms)
})?;
let removed = before.saturating_sub(rewritten.entries.len());
self.seen = rewritten.entries;
self.file_state = rewritten.file_state;
Ok(removed)
}
}
impl IdempotencyStore for FileIdempotencyStore {
fn get(&self, key: &str) -> RuntimeResult<Option<IdempotencyRecord>> {
let now_ms = now_ms();
if let Some(location) = self.seen.get(key) {
if is_expired(location.created_at_ms, self.ttl_ms, now_ms) {
Ok(None)
} else {
read_entry(&self.file_path, key, location).map(Some)
}
} else {
Ok(None)
}
}
fn insert(&mut self, record: IdempotencyRecord) -> RuntimeResult<()> {
validate_key(&record.key)?;
ensure_active_capacity(&self.seen, &record.key)?;
let record_bytes = encoded_record_bytes(&record)?;
if self.requires_rewrite(record_bytes) {
return self.replace_and_rewrite(record);
}
let appended = append_entry(&self.file_path, &record, self.file_state.bytes)?;
self.file_state.bytes = self.file_state.bytes.saturating_add(appended.written_bytes);
self.file_state.records = self.file_state.records.saturating_add(1);
self.seen.insert(record.key, appended.location);
Ok(())
}
fn len(&self) -> usize {
let now_ms = now_ms();
self.seen
.values()
.filter(|location| !is_expired(location.created_at_ms, self.ttl_ms, now_ms))
.count()
}
fn remove(&mut self, key: &str) -> RuntimeResult<()> {
if self.seen.contains_key(key) {
let rewritten = rewrite_entries(&self.file_path, &self.seen, None, |candidate, _| {
candidate != key
})?;
self.seen = rewritten.entries;
self.file_state = rewritten.file_state;
}
Ok(())
}
}
impl FileIdempotencyStore {
fn requires_rewrite(&self, record_bytes: u64) -> bool {
self.file_state.records >= MAX_PERSISTED_IDEMPOTENCY_RECORDS
|| self
.file_state
.bytes
.checked_add(record_bytes.saturating_add(1))
.is_none_or(|bytes| bytes > MAX_IDEMPOTENCY_FILE_BYTES)
}
fn replace_and_rewrite(&mut self, record: IdempotencyRecord) -> RuntimeResult<()> {
let rewritten = rewrite_entries(&self.file_path, &self.seen, Some(&record), |_, _| true)?;
self.seen = rewritten.entries;
self.file_state = rewritten.file_state;
Ok(())
}
}
fn ensure_parent_dir(file_path: &Path) -> RuntimeResult<()> {
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).map_err(|e| map_idempotency_io("create_store_parent_dir", e))?;
}
Ok(())
}
fn validate_key(key: &str) -> RuntimeResult<()> {
match validate_identifier("IdempotencyKey", key) {
Ok(()) => Ok(()),
Err(RuntimeError::InvalidIdentifier {
reason: "empty", ..
}) => Err(RuntimeError::InvalidIdempotencyKey { reason: "empty" }),
Err(_) => Err(RuntimeError::InvalidIdempotencyKey {
reason: "invalid_char",
}),
}
}
fn ensure_active_capacity<T>(entries: &HashMap<String, T>, key: &str) -> RuntimeResult<()> {
if !entries.contains_key(key) && entries.len() >= MAX_ACTIVE_IDEMPOTENCY_RECORDS {
return Err(RuntimeError::IdempotencyStoreIo {
operation: "validate_store",
message: "active record limit exceeded".to_string(),
});
}
Ok(())
}
fn is_expired(created_at_ms: u64, ttl_ms: Option<u64>, now_ms: u64) -> bool {
if created_at_ms == 0 {
return false;
}
match ttl_ms {
Some(ttl) => now_ms.saturating_sub(created_at_ms) > ttl,
None => false,
}
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
#[path = "idempotency_tests.rs"]
mod tests;