Skip to main content

cratefield_core/
outbox.rs

1//! Durable outbox (issue #128): the pair of the [`Inbox`](crate::Inbox). Where
2//! the inbox makes *inbound* effects exactly-once, the outbox makes *outbound*
3//! work at-least-once even across a crash.
4//!
5//! `Defer` (Workers `wait_until`, native `tokio::spawn`) is an execution
6//! *opportunity*, not durable delivery: a process exit, an execution deadline
7//! or a downstream failure loses the confirmation mail or the cross-module
8//! action after the database mutation already committed. The outbox closes that
9//! gap:
10//!
11//! 1. A module writes an outbox row **inside the same batch** as its state
12//!    change — [`enqueue_statement`](Outbox::enqueue_statement) returns a
13//!    [`Statement`] the module appends to its own `db.batch_atomic(..)`, so the row
14//!    commits atomically with the change or not at all.
15//! 2. It then uses `Defer` only to *attempt* immediate delivery: lease due rows
16//!    with [`claim_due`](Outbox::claim_due), deliver, and
17//!    [`complete`](Outbox::complete) (delete) or [`retry_later`](Outbox::retry_later).
18//! 3. The venture's scheduled entry point drains whatever immediate delivery
19//!    missed, on the same lease + bounded-retry path —
20//!    [`drain_within`](Outbox::drain_within) is that drain with a
21//!    [`ScheduledBudget`] applied: at most N due items per tick, and a stop
22//!    when the invocation's budget is spent.
23//!
24//! Leasing is race-free without a portable `RETURNING`: `claim_due` selects due
25//! rows, then wins each one with a guarded `UPDATE … WHERE locked_until IS NULL
26//! OR locked_until < now` (the same first-writer-wins the inbox uses), so two
27//! drainers never deliver the same row. Consumers must still be idempotent
28//! (pair the topic with an [`Inbox`](crate::Inbox) key) — the outbox guarantees
29//! at-least-once, not exactly-once.
30
31use crate::ports::{Clock, Database, DbError, Statement};
32use crate::scheduled::ScheduledBudget;
33use sea_query::{Alias, Expr, Order, Query};
34use std::collections::VecDeque;
35use std::future::Future;
36use time::{Duration, OffsetDateTime};
37
38fn iden(name: &str) -> Alias {
39    Alias::new(name)
40}
41
42/// The RFC 3339 spelling every outbox timestamp is stored with: to the
43/// second, normalized to UTC, because lexicographic order on that spelling
44/// is chronological order, and that is what the due and lease compares
45/// rely on — and what makes a timestamp a handler produced in any offset
46/// comparable with one the drain computed.
47fn rfc3339(at: OffsetDateTime) -> String {
48    use time::format_description::well_known::Rfc3339;
49    let at = at.to_offset(time::UtcOffset::UTC);
50    at.replace_nanosecond(0)
51        .unwrap_or(at)
52        .format(&Rfc3339)
53        .unwrap_or_default()
54}
55
56/// A leased outbox record handed to a drainer.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct OutboxRecord {
59    pub id: String,
60    /// What kind of work this is (the module routes on it).
61    pub topic: String,
62    /// The opaque payload the module wrote (typically JSON).
63    pub payload: String,
64    /// How many delivery attempts have already failed.
65    pub attempts: i64,
66}
67
68/// A durable work queue over the `Database` port. Construct it with the table
69/// the owning module declares (e.g. `"<module>_outbox"`).
70#[derive(Debug, Clone)]
71pub struct Outbox {
72    table: String,
73}
74
75impl Outbox {
76    #[must_use]
77    pub fn new(table: impl Into<String>) -> Self {
78        Self {
79            table: table.into(),
80        }
81    }
82
83    /// The portable DDL for the outbox table. The owning module ships this as a
84    /// forward-only migration.
85    #[must_use]
86    pub fn create_table_sql(&self) -> String {
87        format!(
88            "CREATE TABLE IF NOT EXISTS {table} (\n    \
89             id TEXT PRIMARY KEY,\n    \
90             topic TEXT NOT NULL,\n    \
91             payload TEXT NOT NULL,\n    \
92             subject TEXT,\n    \
93             attempts INTEGER NOT NULL DEFAULT 0,\n    \
94             next_attempt_at TEXT NOT NULL,\n    \
95             locked_until TEXT,\n    \
96             created_at TEXT NOT NULL\n);",
97            table = self.table
98        )
99    }
100
101    /// The `INSERT` that enqueues one unit of work. Return it into the module's
102    /// **own** `db.batch_atomic(..)` alongside the state change, so the row is durable
103    /// exactly when the change is. `id` is a caller-supplied ULID; `at` is an
104    /// RFC 3339 timestamp used for both `created_at` and the initial
105    /// `next_attempt_at` (deliver as soon as possible).
106    ///
107    /// `subject` is the person the work is for — an account id, a waitlist
108    /// entry id — or `None` for work that names nobody. Writing it is what
109    /// makes the queued row reachable for export and erasure (issue #266):
110    /// the payload JSON is the module's own dialect and no predicate can
111    /// match into it. Pass it for every per-person job even when it feels
112    /// redundant with the payload; `None` rows drain identically but are
113    /// returned for nobody's subject, so a forgotten `Some` is a silent
114    /// hole in the erasure catalogue rather than an error.
115    #[must_use]
116    pub fn enqueue_statement(
117        &self,
118        id: &str,
119        topic: &str,
120        payload: &str,
121        subject: Option<&str>,
122        at: &str,
123    ) -> Statement {
124        let mut insert = Query::insert();
125        insert
126            .into_table(iden(&self.table))
127            .columns([
128                "id",
129                "topic",
130                "payload",
131                "subject",
132                "attempts",
133                "next_attempt_at",
134                "created_at",
135            ])
136            .values_panic([
137                id.to_owned().into(),
138                topic.to_owned().into(),
139                payload.to_owned().into(),
140                subject.map(str::to_owned).into(),
141                0i64.into(),
142                at.to_owned().into(),
143                at.to_owned().into(),
144            ]);
145        Statement::render(&insert)
146    }
147
148    /// Leases up to `limit` records that are due (`next_attempt_at <= now`) and
149    /// not already leased, marking each `locked_until = lease_until` so a
150    /// concurrent drainer skips it. Returns only the rows this caller won.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`DbError`] if a read or lease write fails.
155    pub async fn claim_due(
156        &self,
157        db: &dyn Database,
158        now: &str,
159        lease_until: &str,
160        limit: u64,
161    ) -> Result<Vec<OutboxRecord>, DbError> {
162        let mut select = Query::select();
163        select
164            .columns([iden("id"), iden("topic"), iden("payload"), iden("attempts")])
165            .from(iden(&self.table))
166            .and_where(Expr::col(iden("next_attempt_at")).lte(now))
167            .and_where(
168                Expr::col(iden("locked_until"))
169                    .is_null()
170                    .or(Expr::col(iden("locked_until")).lt(now)),
171            )
172            .order_by(iden("next_attempt_at"), Order::Asc)
173            .limit(limit);
174        let rows = db.query(&Statement::render(&select)).await?;
175
176        let mut leased = Vec::new();
177        for row in &rows.rows {
178            let id = row.get::<String>("id").unwrap_or_default();
179            // Win the lease with a guarded update: exactly one drainer's write
180            // takes, and only it processes the row.
181            let mut lease = Query::update();
182            lease
183                .table(iden(&self.table))
184                .value(iden("locked_until"), lease_until)
185                .and_where(Expr::col(iden("id")).eq(id.as_str()))
186                .and_where(
187                    Expr::col(iden("locked_until"))
188                        .is_null()
189                        .or(Expr::col(iden("locked_until")).lt(now)),
190                );
191            if db.execute(&Statement::render(&lease)).await? == 1 {
192                leased.push(OutboxRecord {
193                    id,
194                    topic: row.get::<String>("topic").unwrap_or_default(),
195                    payload: row.get::<String>("payload").unwrap_or_default(),
196                    attempts: row.get::<i64>("attempts").unwrap_or(0),
197                });
198            }
199        }
200        Ok(leased)
201    }
202
203    /// Removes a record after successful delivery.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`DbError`] if the delete fails.
208    pub async fn complete(&self, db: &dyn Database, id: &str) -> Result<(), DbError> {
209        let mut delete = Query::delete();
210        delete
211            .from_table(iden(&self.table))
212            .and_where(Expr::col(iden("id")).eq(id));
213        db.execute(&Statement::render(&delete)).await?;
214        Ok(())
215    }
216
217    /// Reschedules a record after a failed delivery: increments `attempts`,
218    /// sets the next attempt time (the caller applies its own backoff), and
219    /// clears the lease so a drainer can pick it up again.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`DbError`] if the update fails.
224    pub async fn retry_later(
225        &self,
226        db: &dyn Database,
227        id: &str,
228        next_attempt_at: &str,
229    ) -> Result<(), DbError> {
230        let mut update = Query::update();
231        update
232            .table(iden(&self.table))
233            .value(iden("attempts"), Expr::col(iden("attempts")).add(1))
234            .value(iden("next_attempt_at"), next_attempt_at)
235            .value(iden("locked_until"), Option::<String>::None)
236            .and_where(Expr::col(iden("id")).eq(id));
237        db.execute(&Statement::render(&update)).await?;
238        Ok(())
239    }
240
241    /// Reschedules a record **without** counting an attempt: sets the next
242    /// attempt time and clears the lease. Two uses. First, the per-item
243    /// cursor for recurring work — one outbox row per thing to poll,
244    /// rescheduled by the handler to its next poll time, so "process N due
245    /// items per tick" is just a bounded claim. Second, how
246    /// [`drain_within`](Outbox::drain_within) releases claimed-but-unprocessed
247    /// rows when the budget runs out: a release is not a failure and must
248    /// not read as one.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`DbError`] if the update fails.
253    pub async fn reschedule(
254        &self,
255        db: &dyn Database,
256        id: &str,
257        next_attempt_at: &str,
258    ) -> Result<(), DbError> {
259        let mut update = Query::update();
260        update
261            .table(iden(&self.table))
262            .value(iden("next_attempt_at"), next_attempt_at)
263            .value(iden("locked_until"), Option::<String>::None)
264            .and_where(Expr::col(iden("id")).eq(id));
265        db.execute(&Statement::render(&update)).await?;
266        Ok(())
267    }
268
269    /// Drains due records under a [`ScheduledBudget`]: process at most
270    /// `opts.limit` items this tick, and fewer when the budget cannot carry
271    /// them. This is the "N due items per tick" helper — a recurring poller
272    /// writes one outbox row per thing to poll and returns
273    /// [`Processed::NextAt`] with the row's next poll time, which
274    /// [`reschedule`](Outbox::reschedule) applies **without** counting an
275    /// attempt, so the row comes back due exactly when it should and a
276    /// poll that keeps failing still counts toward the bounded retry the
277    /// [`RetryAt`](Processed::RetryAt) path enforces.
278    ///
279    /// The budget is checked twice: the claim itself is capped at what the
280    /// remaining subrequests can carry (`remaining /
281    /// opts.subrequests_per_item`, uncapped when subrequests are unbounded
282    /// or `subrequests_per_item` is `0`), and each record is checked again
283    /// before its handler runs — a budget that runs out mid-batch releases
284    /// that record and every claimed one after it through
285    /// [`reschedule`](Outbox::reschedule), so they are immediately claimable
286    /// on the next tick, and sets `stopped_by_budget` on the report.
287    ///
288    /// # Errors
289    ///
290    /// Returns [`DbError`] when a claim, complete or reschedule write
291    /// fails. Records already handled before the failure are finished; the
292    /// rest keep their lease until it lapses — at-least-once, as ever.
293    pub async fn drain_within<F, Fut>(
294        &self,
295        db: &dyn Database,
296        clock: &dyn Clock,
297        budget: &ScheduledBudget,
298        opts: DrainOptions,
299        handle: F,
300    ) -> Result<DrainReport, DbError>
301    where
302        F: FnMut(OutboxRecord) -> Fut,
303        Fut: Future<Output = Processed>,
304    {
305        let mut handle = handle;
306        let mut report = DrainReport::default();
307        let now = clock.now();
308        // A time-only drain (`subrequests_per_item == 0`) answers to the
309        // wall half of the budget alone — the subrequest count never
310        // applies to it.
311        let out_of_budget = if opts.subrequests_per_item == 0 {
312            budget.expired(now)
313        } else {
314            budget.exhausted(now)
315        };
316        if out_of_budget {
317            report.stopped_by_budget = true;
318            return Ok(report);
319        }
320        // Claim no more than the budget can carry, so a bounded invocation
321        // does not lease rows it will only have to release. Divide first:
322        // the cap is how many whole items fit in the remaining budget.
323        let limit = match budget.remaining_subrequests() {
324            Some(remaining) if opts.subrequests_per_item > 0 => {
325                (u64::from(remaining) / u64::from(opts.subrequests_per_item)).min(opts.limit)
326            }
327            _ => opts.limit,
328        };
329        let capped_by_budget = limit < opts.limit;
330        let now_iso = rfc3339(now);
331        let lease_until = rfc3339(now.saturating_add(opts.lease));
332        let mut queue: VecDeque<OutboxRecord> = self
333            .claim_due(db, &now_iso, &lease_until, limit)
334            .await?
335            .into();
336        // A cap below `limit` only means "the budget cut this tick short"
337        // when the claim filled it; fewer rows than the cap were simply all
338        // the rows there were.
339        if capped_by_budget && queue.len() as u64 >= limit {
340            report.stopped_by_budget = true;
341        }
342        while let Some(record) = queue.pop_front() {
343            let now = clock.now();
344            // With `subrequests_per_item == 0`, `try_spend` cannot refuse,
345            // so this check is the expiry alone — matching the start.
346            if budget.expired(now) || !budget.try_spend(opts.subrequests_per_item) {
347                report.stopped_by_budget = true;
348                // The budget ran out: this record and every claimed one
349                // behind it go back, unleased and uncounted, due now.
350                for unspent in &queue {
351                    self.reschedule(db, &unspent.id, &now_iso).await?;
352                    report.released += 1;
353                }
354                self.reschedule(db, &record.id, &now_iso).await?;
355                report.released += 1;
356                break;
357            }
358            // The handler takes the record; the row's id is all the
359            // completion writes need of it afterwards.
360            let id = record.id.clone();
361            match handle(record).await {
362                Processed::Done => self.complete(db, &id).await?,
363                Processed::RetryAt(at) => {
364                    self.retry_later(db, &id, &rfc3339(at)).await?;
365                }
366                Processed::NextAt(at) => {
367                    self.reschedule(db, &id, &rfc3339(at)).await?;
368                }
369            }
370            report.processed += 1;
371        }
372        Ok(report)
373    }
374}
375
376/// How many items a [`drain_within`](Outbox::drain_within) may process in
377/// one tick, under what lease, and what one item costs the budget.
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub struct DrainOptions {
380    /// At most this many records are claimed and handled per tick.
381    pub limit: u64,
382    /// How long a claimed record stays leased while its handler runs. A
383    /// handler that overruns it hands the record to a concurrent drainer —
384    /// at-least-once, so pair the topic with an [`Inbox`](crate::Inbox) key.
385    pub lease: Duration,
386    /// Subrequest-shaped steps one handled record costs the
387    /// [`ScheduledBudget`]. `0` makes the budget time-only.
388    pub subrequests_per_item: u32,
389}
390
391/// What the handler made of one record.
392#[derive(Debug)]
393pub enum Processed {
394    /// Delivered: remove the record.
395    Done,
396    /// Failed: reschedule with `attempts` incremented — the caller applies
397    /// its own backoff through the timestamp. Enough failures and the
398    /// bounded-retry posture of the module applies.
399    RetryAt(OffsetDateTime),
400    /// Not due yet — recurring work: reschedule to `at` **without**
401    /// counting an attempt. This is the per-item cursor: one row per thing
402    /// to poll, moved to its next poll time.
403    NextAt(OffsetDateTime),
404}
405
406/// What one [`drain_within`](Outbox::drain_within) pass did.
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
408#[non_exhaustive]
409pub struct DrainReport {
410    /// Records handled to an outcome (`Done`, `RetryAt` or `NextAt`).
411    pub processed: u64,
412    /// Claimed records handed back unprocessed because the budget ran out.
413    pub released: u64,
414    /// The budget cut this tick short — at the start check, by the claim
415    /// cap, or mid-batch — and more rows may still be due. `false` means
416    /// every due row the budget allowed was handled and nothing waited
417    /// behind the budget. Run again next tick either way.
418    pub stopped_by_budget: bool,
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn create_table_sql_is_portable_ddl() {
427        let sql = Outbox::new("mail_outbox").create_table_sql();
428        assert!(sql.contains("CREATE TABLE IF NOT EXISTS mail_outbox"));
429        assert!(sql.contains("next_attempt_at TEXT NOT NULL"));
430        assert!(sql.contains("locked_until TEXT"));
431        // Nullable, not NOT NULL: the migration story is ADD COLUMN on a
432        // live table, and NOT NULL would force a backfill that cannot be
433        // honest about rows whose payload never parsed.
434        assert!(sql.contains("subject TEXT"));
435    }
436
437    #[test]
438    fn enqueue_statement_inserts_the_row() {
439        let stmt = Outbox::new("mail_outbox").enqueue_statement(
440            "01J",
441            "confirmation",
442            "{\"to\":\"a@b\"}",
443            Some("acct-1"),
444            "2026-09-07T00:00:00Z",
445        );
446        assert!(stmt.sql.contains("INSERT INTO"));
447        assert!(stmt.sql.contains("mail_outbox"));
448        assert!(stmt.sql.contains("subject"));
449        // id, topic, payload, subject, attempts, next_attempt_at, created_at
450        assert_eq!(stmt.values.0.len(), 7);
451        assert_eq!(stmt.values.0[3], "acct-1".into());
452    }
453
454    #[test]
455    fn enqueue_statement_without_a_subject_binds_null() {
456        // The shape a row written before the migration has: the column
457        // exists, the value does not. The drain treats both the same.
458        let stmt = Outbox::new("mail_outbox").enqueue_statement(
459            "01J",
460            "confirmation",
461            "{\"to\":\"a@b\"}",
462            None,
463            "2026-09-07T00:00:00Z",
464        );
465        assert!(stmt.sql.contains("subject"));
466        assert_eq!(
467            stmt.values.0[3],
468            sea_query::Value::from(Option::<String>::None)
469        );
470    }
471}