179 lines
5.6 KiB
Rust
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"));
|
|
}
|
|
}
|