cratefield_core/cooldown.rs
1//! Durable per-subject send cooldown (issue #133): the rate limiter is a
2//! distributed counter whose transport can fail open, so a mail-triggering
3//! route needs a **backstop the database enforces**: at most one send per
4//! subject per window, race-free without a transaction.
5//!
6//! The claim is a guarded `UPDATE` (re-claim only once the window has
7//! passed) followed by an `INSERT ... ON CONFLICT DO NOTHING` (first send
8//! for a never-seen subject) — exactly one of the two statements reports
9//! one affected row per window, on both SQLite/D1 and Postgres. Same
10//! primitive the [`Inbox`] dedup ledger uses, with a time window added.
11//!
12//! ```ignore
13//! let cooldown = SendCooldown::new("waitlist_send_cooldown");
14//! if cooldown.try_acquire(&*db, &subject, &now, &cutoff).await? {
15//! // this request owns the window: send the mail
16//! } // else: a concurrent or repeat request inside the window: skip the send
17//! ```
18//!
19//! [`Inbox`]: crate::idempotency::Inbox
20
21use crate::ports::{Database, DbError, Statement};
22use sea_query::{Alias, Expr, OnConflict, Query};
23
24fn iden(name: &str) -> Alias {
25 Alias::new(name)
26}
27
28/// A cooldown ledger over the `Database` port. Construct it with the table
29/// the owning module declares (e.g. `"<module>_send_cooldown"`); the
30/// owning module ships [`create_table_sql`](Self::create_table_sql) as a
31/// migration.
32///
33/// Timestamps are RFC 3339 strings from the `Clock` port, the same shape
34/// the house stores in `TEXT` columns everywhere: lexicographic order on
35/// that format is chronological order, which is what the window compare
36/// relies on.
37#[derive(Debug, Clone)]
38pub struct SendCooldown {
39 table: String,
40}
41
42impl SendCooldown {
43 #[must_use]
44 pub fn new(table: impl Into<String>) -> Self {
45 Self {
46 table: table.into(),
47 }
48 }
49
50 /// The portable DDL for the cooldown table (renders identically on
51 /// SQLite/D1 and Postgres, ADR 0004).
52 #[must_use]
53 pub fn create_table_sql(&self) -> String {
54 format!(
55 "CREATE TABLE IF NOT EXISTS {table} (\n \
56 subject TEXT PRIMARY KEY,\n \
57 last_sent_at TEXT NOT NULL\n);",
58 table = self.table
59 )
60 }
61
62 /// Claims the send window for `subject`: `true` for the **one** caller
63 /// whose claim lands, `false` for anyone inside the window.
64 ///
65 /// `now` is the RFC 3339 timestamp to record, `cutoff` the instant
66 /// (`now - window`) after which an expired claim may be renewed. The
67 /// update-then-insert pair is race-free: the guarded `UPDATE` can be
68 /// won by exactly one writer, and the conflict-insert covers the row
69 /// not existing (yet).
70 ///
71 /// # Errors
72 ///
73 /// Returns [`DbError`] if either write fails.
74 pub async fn try_acquire(
75 &self,
76 db: &dyn Database,
77 subject: &str,
78 now: &str,
79 cutoff: &str,
80 ) -> Result<bool, DbError> {
81 let mut renew = Query::update();
82 renew
83 .table(iden(&self.table))
84 .value(iden("last_sent_at"), now)
85 .and_where(Expr::col(iden("subject")).eq(subject))
86 .and_where(Expr::col(iden("last_sent_at")).lt(cutoff));
87 if db.execute(&Statement::render(&renew)).await? == 1 {
88 return Ok(true);
89 }
90 let mut first = Query::insert();
91 first
92 .into_table(iden(&self.table))
93 .columns(["subject", "last_sent_at"])
94 .values_panic([subject.to_owned().into(), now.to_owned().into()])
95 .on_conflict(OnConflict::column(iden("subject")).do_nothing().to_owned());
96 Ok(db.execute(&Statement::render(&first)).await? == 1)
97 }
98
99 /// Releases a claim so the window is not consumed by a send that never
100 /// started (e.g. the mailer port answered `NotConfigured`).
101 ///
102 /// # Errors
103 ///
104 /// Returns [`DbError`] if the delete fails.
105 pub async fn release(&self, db: &dyn Database, subject: &str) -> Result<(), DbError> {
106 let mut delete = Query::delete();
107 delete
108 .from_table(iden(&self.table))
109 .and_where(Expr::col(iden("subject")).eq(subject));
110 db.execute(&Statement::render(&delete)).await?;
111 Ok(())
112 }
113
114 /// Deletes claims older than `before` (rows whose window has closed
115 /// anyway). Returns the number removed; a module calls this from its
116 /// scheduled handler.
117 ///
118 /// # Errors
119 ///
120 /// Returns [`DbError`] if the delete fails.
121 pub async fn prune(&self, db: &dyn Database, before: &str) -> Result<u64, DbError> {
122 let mut delete = Query::delete();
123 delete
124 .from_table(iden(&self.table))
125 .and_where(Expr::col(iden("last_sent_at")).lt(before));
126 db.execute(&Statement::render(&delete)).await
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn create_table_sql_is_portable_ddl() {
136 let sql = SendCooldown::new("waitlist_send_cooldown").create_table_sql();
137 assert!(sql.contains("CREATE TABLE IF NOT EXISTS waitlist_send_cooldown"));
138 assert!(sql.contains("subject TEXT PRIMARY KEY"));
139 assert!(sql.contains("last_sent_at TEXT NOT NULL"));
140 }
141}