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}