Files
RustBox/src/undo_send.rs
T
Mateusz Gruszczyński 99ee3385ac
RustBox CI / rustbox (push) Canceled after 0s
v0.2.25
2026-11-05 08:33:13 +01:00

179 lines
5.6 KiB
Rust

//! 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<HashMap<String, PendingEntry>>,
}
impl UndoSendRegistry {
pub fn new() -> Self {
Self::default()
}
fn sweep(entries: &mut HashMap<String, PendingEntry>) {
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<PendingOutcome> {
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"));
}
}