use std::collections::{HashMap, VecDeque};
use chrono::{DateTime, Utc};
use super::dto::{
CommandIdentity, CommandRecord, CommandResult, CommandState, ErrorCode,
COMMAND_RECORD_TTL_SECS, MAX_COMMAND_RECORDS,
};
#[derive(Debug, Clone)]
pub enum IdempotencyLookup {
Unknown,
Replay(Box<CommandRecord>),
Mismatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReserveError {
Capacity,
}
#[derive(Debug, Clone)]
pub struct CommandOutcome {
pub state: CommandState,
pub result_revision: u64,
pub detail: Option<String>,
pub error_code: Option<ErrorCode>,
pub result: Option<CommandResult>,
}
#[derive(Debug)]
pub struct CommandRegistry {
records: HashMap<String, CommandRecord>,
record_order: VecDeque<String>,
keys: HashMap<String, (String, CommandIdentity)>,
key_order: VecDeque<String>,
max_records: usize,
ttl_secs: i64,
}
impl Default for CommandRegistry {
fn default() -> Self {
Self::new(MAX_COMMAND_RECORDS, COMMAND_RECORD_TTL_SECS)
}
}
impl CommandRegistry {
pub fn new(max_records: usize, ttl_secs: i64) -> Self {
Self {
records: HashMap::new(),
record_order: VecDeque::new(),
keys: HashMap::new(),
key_order: VecDeque::new(),
max_records,
ttl_secs,
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn command_len(&self) -> usize {
self.records.len()
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn idempotency_len(&self) -> usize {
self.keys.len()
}
pub fn get(&self, command_id: &str) -> Option<&CommandRecord> {
self.records.get(command_id)
}
pub fn lookup(
&mut self,
key: &str,
identity: &CommandIdentity,
now: DateTime<Utc>,
) -> IdempotencyLookup {
self.expire(now);
let Some((command_id, bound)) = self.keys.get(key) else {
return IdempotencyLookup::Unknown;
};
if bound != identity {
return IdempotencyLookup::Mismatch;
}
match self.records.get(command_id) {
Some(record) => IdempotencyLookup::Replay(Box::new(record.clone())),
None => IdempotencyLookup::Mismatch,
}
}
pub fn reserve(
&mut self,
key: &str,
identity: CommandIdentity,
record: CommandRecord,
now: DateTime<Utc>,
) -> Result<(), ReserveError> {
self.expire(now);
if !self.can_admit_record() || !self.can_admit_key() {
return Err(ReserveError::Capacity);
}
let command_id = record.command_id.clone();
self.records.insert(command_id.clone(), record);
self.record_order.push_back(command_id.clone());
self.keys
.insert(key.to_string(), (command_id, identity.clone()));
self.key_order.push_back(key.to_string());
Ok(())
}
pub fn complete(&mut self, command_id: &str, outcome: CommandOutcome) -> Option<CommandRecord> {
let record = self.records.get_mut(command_id)?;
record.state = outcome.state;
record.result_revision = Some(outcome.result_revision);
record.detail = outcome.detail;
record.error_code = outcome.error_code;
record.result = outcome.result;
record.completed_at = Some(Utc::now().to_rfc3339());
Some(record.clone())
}
fn expire(&mut self, now: DateTime<Utc>) {
let expired: Vec<String> = self
.records
.iter()
.filter(|(_, record)| self.is_expired(record, now))
.map(|(id, _)| id.clone())
.collect();
for command_id in expired {
self.forget(&command_id);
}
}
fn is_expired(&self, record: &CommandRecord, now: DateTime<Utc>) -> bool {
if record.state.is_in_progress() {
return false;
}
let Some(completed_at) = record.completed_at.as_deref() else {
return false;
};
let Ok(completed) = DateTime::parse_from_rfc3339(completed_at) else {
return false;
};
(now - completed.with_timezone(&Utc)).num_seconds() >= self.ttl_secs
}
fn forget(&mut self, command_id: &str) {
self.records.remove(command_id);
self.record_order.retain(|id| id != command_id);
let bound_keys: Vec<String> = self
.keys
.iter()
.filter(|(_, (id, _))| id == command_id)
.map(|(key, _)| key.clone())
.collect();
for key in bound_keys {
self.keys.remove(&key);
self.key_order.retain(|k| k != &key);
}
}
fn can_admit_record(&mut self) -> bool {
while self.records.len() >= self.max_records {
let Some(victim) = self
.record_order
.iter()
.find(|id| {
self.records
.get(*id)
.is_some_and(|r| !r.state.is_in_progress())
})
.cloned()
else {
return false;
};
self.forget(&victim);
}
true
}
fn can_admit_key(&mut self) -> bool {
while self.keys.len() >= self.max_records {
let Some(victim) = self
.key_order
.iter()
.find(|key| {
self.keys.get(*key).is_some_and(|(command_id, _)| {
self.records
.get(command_id)
.is_none_or(|r| !r.state.is_in_progress())
})
})
.cloned()
else {
return false;
};
self.keys.remove(&victim);
self.key_order.retain(|k| k != &victim);
}
true
}
}