1 parent
6e74b33f03
commit
3b9110c8c8
44 files changed
+3057
-128
No files matched your search
@@ -0,0 +1,194 @@
|
||||
//! In-memory staging for "Undo Send".
|
||||
//!
|
||||
//! When `undo_send_window_secs > 0`, a fully built outgoing message is held
|
||||
//! in memory for that many seconds before it is actually dispatched to SMTP,
|
||||
//! so the sender can cancel it. Nothing here is persisted: a server restart
|
||||
//! during the window loses any message still staged, the same way a message
|
||||
//! already queued by any mail server can be lost if the process is killed
|
||||
//! mid-flight. The window is meant to be short (seconds, not minutes) for
|
||||
//! exactly this reason.
|
||||
//!
|
||||
//! State transitions are `Pending -> Cancelled` (a user clicks "Undo") or
|
||||
//! `Pending -> Dispatching -> Sent | Failed` (the window elapses). Both the
|
||||
//! cancel handler and the delayed dispatch task race to leave `Pending`, but
|
||||
//! only one of them can win because the transition happens under a single
|
||||
//! mutex: whichever reaches it first decides the outcome.
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the delayed dispatch task once its wait is over, right
|
||||
/// before it would contact SMTP. Returns `true` if this call is the one
|
||||
/// that claimed the send (i.e. it was not cancelled in the meantime);
|
||||
/// the caller must then actually dispatch and record the result.
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user