use crate::result::ResultTombstone;
use crate::TaskId;
use std::collections::HashMap;
use std::sync::RwLock;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResultExistence {
Present,
Tombstoned(ResultTombstone),
Absent,
}
impl ResultExistence {
#[inline]
#[must_use]
pub const fn is_present(&self) -> bool {
matches!(self, ResultExistence::Present)
}
#[inline]
#[must_use]
pub const fn is_tombstoned(&self) -> bool {
matches!(self, ResultExistence::Tombstoned(_))
}
#[inline]
#[must_use]
pub const fn is_absent(&self) -> bool {
matches!(self, ResultExistence::Absent)
}
#[inline]
#[must_use]
pub const fn was_deleted(&self) -> bool {
matches!(self, ResultExistence::Tombstoned(_))
}
#[inline]
#[must_use]
pub const fn tombstone(&self) -> Option<&ResultTombstone> {
match self {
ResultExistence::Tombstoned(t) => Some(t),
_ => None,
}
}
#[inline]
#[must_use]
pub fn into_tombstone(self) -> Option<ResultTombstone> {
match self {
ResultExistence::Tombstoned(t) => Some(t),
_ => None,
}
}
}
pub trait TombstoneExt {
fn age(&self) -> Duration;
fn is_expired(&self) -> bool;
fn time_until_expiration(&self) -> Option<Duration>;
}
impl TombstoneExt for ResultTombstone {
fn age(&self) -> Duration {
let now = chrono::Utc::now();
let diff = now - self.deleted_at;
diff.to_std().unwrap_or(Duration::ZERO)
}
fn is_expired(&self) -> bool {
match self.tombstone_ttl {
Some(ttl) => self.age() >= ttl,
None => false,
}
}
fn time_until_expiration(&self) -> Option<Duration> {
self.tombstone_ttl.and_then(|ttl| {
let age = self.age();
if age >= ttl {
None
} else {
Some(ttl - age)
}
})
}
}
#[derive(Debug, Default)]
pub struct TombstoneRegistry {
inner: RwLock<HashMap<TaskId, ResultTombstone>>,
}
impl TombstoneRegistry {
#[must_use]
pub fn new() -> Self {
Self {
inner: RwLock::new(HashMap::new()),
}
}
pub fn insert(&self, tombstone: ResultTombstone) -> Option<ResultTombstone> {
let task_id = tombstone.task_id;
let mut guard = self.inner.write().unwrap_or_else(|e| e.into_inner());
guard.insert(task_id, tombstone)
}
pub fn mark_forgotten(
&self,
task_id: TaskId,
reason: Option<String>,
) -> Option<ResultTombstone> {
let mut tombstone = ResultTombstone::new(task_id);
if let Some(reason) = reason {
tombstone = tombstone.with_reason(reason);
}
self.insert(tombstone)
}
#[must_use]
pub fn lookup(&self, task_id: TaskId) -> ResultExistence {
let guard = self.inner.read().unwrap_or_else(|e| e.into_inner());
match guard.get(&task_id) {
Some(tombstone) if !tombstone.is_expired() => {
ResultExistence::Tombstoned(tombstone.clone())
}
_ => ResultExistence::Absent,
}
}
#[must_use]
pub fn is_tombstoned(&self, task_id: TaskId) -> bool {
let guard = self.inner.read().unwrap_or_else(|e| e.into_inner());
guard
.get(&task_id)
.is_some_and(|tombstone| !tombstone.is_expired())
}
#[must_use]
pub fn get(&self, task_id: TaskId) -> Option<ResultTombstone> {
let guard = self.inner.read().unwrap_or_else(|e| e.into_inner());
guard.get(&task_id).cloned()
}
pub fn remove(&self, task_id: TaskId) -> Option<ResultTombstone> {
let mut guard = self.inner.write().unwrap_or_else(|e| e.into_inner());
guard.remove(&task_id)
}
#[must_use]
pub fn len(&self) -> usize {
let guard = self.inner.read().unwrap_or_else(|e| e.into_inner());
guard.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
let guard = self.inner.read().unwrap_or_else(|e| e.into_inner());
guard.is_empty()
}
pub fn purge_expired(&self) -> usize {
let mut guard = self.inner.write().unwrap_or_else(|e| e.into_inner());
let before = guard.len();
guard.retain(|_, tombstone| !tombstone.is_expired());
before - guard.len()
}
pub fn clear(&self) -> usize {
let mut guard = self.inner.write().unwrap_or_else(|e| e.into_inner());
let count = guard.len();
guard.clear();
count
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn absent_when_never_recorded() {
let registry = TombstoneRegistry::new();
let task_id = Uuid::new_v4();
assert_eq!(registry.lookup(task_id), ResultExistence::Absent);
assert!(registry.lookup(task_id).is_absent());
assert!(!registry.is_tombstoned(task_id));
assert!(!registry.lookup(task_id).was_deleted());
}
#[test]
fn distinguishes_deleted_from_absent() {
let registry = TombstoneRegistry::new();
let deleted = Uuid::new_v4();
let never = Uuid::new_v4();
registry.insert(ResultTombstone::new(deleted).with_reason("forget"));
let existence = registry.lookup(deleted);
assert!(existence.is_tombstoned());
assert!(existence.was_deleted());
assert!(registry.is_tombstoned(deleted));
assert_eq!(
existence.tombstone().and_then(|t| t.reason.clone()),
Some("forget".to_string())
);
let existence = registry.lookup(never);
assert!(existence.is_absent());
assert!(!existence.was_deleted());
assert!(!registry.is_tombstoned(never));
}
#[test]
fn mark_forgotten_helper() {
let registry = TombstoneRegistry::new();
let task_id = Uuid::new_v4();
let prev = registry.mark_forgotten(task_id, Some("expired ttl".to_string()));
assert!(prev.is_none());
assert!(registry.is_tombstoned(task_id));
let prev = registry.mark_forgotten(task_id, None);
assert!(prev.is_some());
assert_eq!(prev.and_then(|t| t.reason), Some("expired ttl".to_string()));
}
#[test]
fn remove_restores_absent() {
let registry = TombstoneRegistry::new();
let task_id = Uuid::new_v4();
registry.insert(ResultTombstone::new(task_id));
assert!(registry.is_tombstoned(task_id));
let removed = registry.remove(task_id);
assert!(removed.is_some());
assert_eq!(registry.lookup(task_id), ResultExistence::Absent);
}
#[test]
fn expired_tombstone_is_absent_on_lookup() {
let registry = TombstoneRegistry::new();
let task_id = Uuid::new_v4();
let mut tombstone = ResultTombstone::new(task_id).with_ttl(Duration::from_secs(1));
tombstone.deleted_at = chrono::Utc::now() - chrono::Duration::seconds(10);
registry.insert(tombstone);
assert!(registry.get(task_id).is_some());
assert!(!registry.is_tombstoned(task_id));
assert_eq!(registry.lookup(task_id), ResultExistence::Absent);
}
#[test]
fn purge_expired_removes_only_expired() {
let registry = TombstoneRegistry::new();
let live = Uuid::new_v4();
let expired = Uuid::new_v4();
registry.insert(ResultTombstone::new(live).with_ttl(Duration::from_secs(3600)));
let mut old = ResultTombstone::new(expired).with_ttl(Duration::from_secs(1));
old.deleted_at = chrono::Utc::now() - chrono::Duration::seconds(10);
registry.insert(old);
assert_eq!(registry.len(), 2);
let purged = registry.purge_expired();
assert_eq!(purged, 1);
assert_eq!(registry.len(), 1);
assert!(registry.is_tombstoned(live));
assert!(registry.get(expired).is_none());
}
#[test]
fn tombstone_ext_age_and_expiry() {
let task_id = Uuid::new_v4();
let no_ttl = ResultTombstone::new(task_id);
assert!(!no_ttl.is_expired());
assert!(no_ttl.time_until_expiration().is_none());
let fresh = ResultTombstone::new(task_id).with_ttl(Duration::from_secs(3600));
assert!(!fresh.is_expired());
assert!(fresh.time_until_expiration().is_some());
let mut old = ResultTombstone::new(task_id).with_ttl(Duration::from_secs(1));
old.deleted_at = chrono::Utc::now() - chrono::Duration::seconds(10);
assert!(old.is_expired());
assert!(old.time_until_expiration().is_none());
assert!(old.age() >= Duration::from_secs(9));
}
#[test]
fn into_tombstone_consumes() {
let task_id = Uuid::new_v4();
let existence = ResultExistence::Tombstoned(ResultTombstone::new(task_id));
let tombstone = existence.into_tombstone();
assert_eq!(tombstone.map(|t| t.task_id), Some(task_id));
assert!(ResultExistence::Absent.into_tombstone().is_none());
assert!(ResultExistence::Present.into_tombstone().is_none());
}
#[test]
fn clear_and_counts() {
let registry = TombstoneRegistry::new();
assert!(registry.is_empty());
registry.insert(ResultTombstone::new(Uuid::new_v4()));
registry.insert(ResultTombstone::new(Uuid::new_v4()));
assert_eq!(registry.len(), 2);
assert!(!registry.is_empty());
assert_eq!(registry.clear(), 2);
assert!(registry.is_empty());
}
}