//! In-memory staging for "Undo Send" (not persisted). A send is either //! cancelled or dispatched; the transition happens under one mutex. use std::collections::HashMap; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; /// How long a finished (sent/cancelled/failed) entry is kept around so a late /// status poll from the client still finds it. const RESULT_RETENTION_SECS: u64 = 120; #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum PendingOutcome { Pending, Dispatching, Sent { message_id: String }, Cancelled, Failed { error: String }, } struct PendingEntry { outcome: PendingOutcome, recorded_at: u64, } fn now_epoch() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } #[derive(Default)] pub struct UndoSendRegistry { entries: Mutex>, } impl UndoSendRegistry { pub fn new() -> Self { Self::default() } fn sweep(entries: &mut HashMap) { let now = now_epoch(); entries.retain(|_, entry| { matches!( entry.outcome, PendingOutcome::Pending | PendingOutcome::Dispatching ) || now.saturating_sub(entry.recorded_at) < RESULT_RETENTION_SECS }); } /// Registers a new pending send and returns its id. pub fn stage(&self) -> String { let id = uuid::Uuid::new_v4().to_string(); let mut entries = self.entries.lock().expect("undo-send registry lock"); Self::sweep(&mut entries); entries.insert( id.clone(), PendingEntry { outcome: PendingOutcome::Pending, recorded_at: now_epoch(), }, ); id } /// Cancels a still-pending send. Returns `true` if this call is the one /// that cancelled it; `false` if it was already dispatching/finished or /// the id is unknown/expired. pub fn cancel(&self, id: &str) -> bool { let mut entries = self.entries.lock().expect("undo-send registry lock"); match entries.get_mut(id) { Some(entry) if entry.outcome == PendingOutcome::Pending => { entry.outcome = PendingOutcome::Cancelled; entry.recorded_at = now_epoch(); true } _ => false, } } /// Claims the send for dispatch; `false` if it was cancelled. pub fn begin_dispatch(&self, id: &str) -> bool { let mut entries = self.entries.lock().expect("undo-send registry lock"); match entries.get_mut(id) { Some(entry) if entry.outcome == PendingOutcome::Pending => { entry.outcome = PendingOutcome::Dispatching; true } _ => false, } } pub fn record_sent(&self, id: &str, message_id: String) { let mut entries = self.entries.lock().expect("undo-send registry lock"); if let Some(entry) = entries.get_mut(id) { entry.outcome = PendingOutcome::Sent { message_id }; entry.recorded_at = now_epoch(); } } pub fn record_failed(&self, id: &str, error: String) { let mut entries = self.entries.lock().expect("undo-send registry lock"); if let Some(entry) = entries.get_mut(id) { entry.outcome = PendingOutcome::Failed { error }; entry.recorded_at = now_epoch(); } } pub fn status(&self, id: &str) -> Option { let mut entries = self.entries.lock().expect("undo-send registry lock"); Self::sweep(&mut entries); entries.get(id).map(|entry| entry.outcome.clone()) } } #[cfg(test)] mod tests { use super::*; #[test] fn a_pending_send_can_be_cancelled_exactly_once() { let registry = UndoSendRegistry::new(); let id = registry.stage(); assert_eq!(registry.status(&id), Some(PendingOutcome::Pending)); assert!(registry.cancel(&id)); assert_eq!(registry.status(&id), Some(PendingOutcome::Cancelled)); // A second cancel is a no-op: the first one already won the race. assert!(!registry.cancel(&id)); } #[test] fn dispatch_cannot_start_once_cancelled() { let registry = UndoSendRegistry::new(); let id = registry.stage(); assert!(registry.cancel(&id)); assert!(!registry.begin_dispatch(&id)); } #[test] fn cancel_cannot_win_once_dispatch_has_begun() { let registry = UndoSendRegistry::new(); let id = registry.stage(); assert!(registry.begin_dispatch(&id)); assert!(!registry.cancel(&id)); registry.record_sent(&id, "msg-1".to_string()); assert_eq!( registry.status(&id), Some(PendingOutcome::Sent { message_id: "msg-1".to_string() }) ); } #[test] fn a_dispatch_that_fails_is_recorded_as_failed() { let registry = UndoSendRegistry::new(); let id = registry.stage(); assert!(registry.begin_dispatch(&id)); registry.record_failed(&id, "SMTP timed out".to_string()); assert_eq!( registry.status(&id), Some(PendingOutcome::Failed { error: "SMTP timed out".to_string() }) ); } #[test] fn an_unknown_id_has_no_status() { let registry = UndoSendRegistry::new(); assert_eq!(registry.status("does-not-exist"), None); assert!(!registry.cancel("does-not-exist")); assert!(!registry.begin_dispatch("does-not-exist")); } }