Skip to main content

autumn_web/
outbox.rs

1//! Transactional outbox and inbox (issue #3062).
2//!
3//! Write a message on the connection of your business transaction. It commits
4//! or rolls back with your data. After commit, a relay sends it to the handler
5//! of its topic. A crash cannot lose a committed message.
6//!
7//! The relay sends a message **at least once**. A crash after the handler and
8//! before the relay marks the message sent causes a second send. Use
9//! [`Inbox::seen`] in the consumer to drop the copy.
10//!
11//! ```rust,ignore
12//! use autumn_web::outbox::{Inbox, Outbox};
13//!
14//! db.tx(|conn| Box::pin(async move {
15//!     insert_order(conn, &order).await?;
16//!     outbox.write(conn, &order.id, "order.placed", &order).await?;
17//!     Ok::<_, AutumnError>(())
18//! })).await?;
19//!
20//! // Consumer, registered with `AppBuilder::outbox_handler("order.placed", ..)`.
21//! async fn on_order_placed(state: AppState, message: OutboxMessage) -> AutumnResult<()> {
22//!     let mut conn = state.pool().expect("database").get().await?;
23//!     autumn_web::db::scoped_transaction(&mut *conn, |conn| async move {
24//!         if Inbox::new("ledger").seen(conn, &message.id).await? {
25//!             return Ok(()); // a copy: already done
26//!         }
27//!         post_to_ledger(conn, &message).await
28//!     }.scope_boxed()).await
29//! }
30//! ```
31//!
32//! See `docs/guide/outbox.md`.
33
34// autumn-determinism-gate: production code in this module must read time and
35// mint identifiers through the framework's injected seams (ClockSource /
36// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
37// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
38// (issue #1797). Justify exceptions with
39// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
40#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
41
42use std::collections::HashMap;
43use std::future::Future;
44use std::pin::Pin;
45use std::sync::Arc;
46use std::time::Duration;
47
48use chrono::{DateTime, TimeZone as _, Utc};
49use diesel::sql_types::{BigInt, Integer, Nullable, Text};
50use diesel_async::pooled_connection::deadpool::Pool;
51use diesel_async::{RunQueryDsl as _, SimpleAsyncConnection as _};
52use futures::FutureExt as _;
53use serde::de::DeserializeOwned;
54use serde::{Deserialize, Serialize};
55use serde_json::Value;
56use tokio_util::sync::CancellationToken;
57
58use crate::config::OutboxConfig;
59use crate::db::RuntimeConnection;
60use crate::entropy::Entropy;
61use crate::events::Event;
62use crate::time::ClockSource;
63use crate::{AppState, AutumnError, AutumnResult};
64
65/// Topic of a durable event listener call. See [`Outbox::publish`].
66pub const TOPIC_EVENT: &str = "autumn.event";
67/// Topic of a job enqueue. See [`Outbox::enqueue_job`].
68pub const TOPIC_JOB: &str = "autumn.job";
69/// Topic of a mail delivery. See [`Outbox::deliver_mail`].
70#[cfg(feature = "mail")]
71pub const TOPIC_MAIL: &str = "autumn.mail";
72/// Topic of a webhook dispatch. See [`Outbox::dispatch_webhook`].
73#[cfg(feature = "http-client")]
74pub const TOPIC_WEBHOOK: &str = "autumn.webhook";
75
76/// Topics that start with this prefix belong to the framework.
77pub const RESERVED_TOPIC_PREFIX: &str = "autumn.";
78
79/// How often the relay worker deletes old rows.
80const PURGE_INTERVAL: Duration = Duration::from_secs(60);
81
82// ── Schema ───────────────────────────────────────────────────────────────────
83
84/// DDL of the outbox and inbox tables (Postgres).
85///
86/// The relay applies it at boot when `outbox.enabled = true`. If the database
87/// role of the app cannot run `CREATE TABLE`, apply it before the deploy.
88/// Times are epoch milliseconds from the app clock.
89#[cfg(not(feature = "sqlite"))]
90pub const SCHEMA_SQL: &str = "\
91CREATE TABLE IF NOT EXISTS autumn_outbox (
92    seq           BIGSERIAL PRIMARY KEY,
93    id            TEXT      NOT NULL UNIQUE,
94    aggregate     TEXT      NOT NULL,
95    topic         TEXT      NOT NULL,
96    payload       TEXT      NOT NULL,
97    created_at    BIGINT    NOT NULL,
98    available_at  BIGINT    NOT NULL,
99    attempts      INTEGER   NOT NULL DEFAULT 0,
100    last_error    TEXT,
101    claim_token   TEXT,
102    locked_until  BIGINT,
103    dispatched_at BIGINT,
104    dead_at       BIGINT
105);
106CREATE INDEX IF NOT EXISTS idx_autumn_outbox_pending
107    ON autumn_outbox (aggregate, seq)
108    WHERE dispatched_at IS NULL AND dead_at IS NULL;
109CREATE INDEX IF NOT EXISTS idx_autumn_outbox_claim
110    ON autumn_outbox (seq)
111    WHERE dispatched_at IS NULL AND dead_at IS NULL;
112CREATE INDEX IF NOT EXISTS idx_autumn_outbox_dispatched
113    ON autumn_outbox (dispatched_at)
114    WHERE dispatched_at IS NOT NULL;
115CREATE TABLE IF NOT EXISTS autumn_inbox (
116    consumer   TEXT   NOT NULL,
117    message_id TEXT   NOT NULL,
118    seen_at    BIGINT NOT NULL,
119    PRIMARY KEY (consumer, message_id)
120);
121CREATE INDEX IF NOT EXISTS idx_autumn_inbox_seen_at ON autumn_inbox (seen_at);";
122
123/// DDL of the outbox and inbox tables (`SQLite`). See the Postgres variant.
124#[cfg(feature = "sqlite")]
125pub const SCHEMA_SQL: &str = "\
126CREATE TABLE IF NOT EXISTS autumn_outbox (
127    seq           INTEGER PRIMARY KEY AUTOINCREMENT,
128    id            TEXT    NOT NULL UNIQUE,
129    aggregate     TEXT    NOT NULL,
130    topic         TEXT    NOT NULL,
131    payload       TEXT    NOT NULL,
132    created_at    BIGINT  NOT NULL,
133    available_at  BIGINT  NOT NULL,
134    attempts      INTEGER NOT NULL DEFAULT 0,
135    last_error    TEXT,
136    claim_token   TEXT,
137    locked_until  BIGINT,
138    dispatched_at BIGINT,
139    dead_at       BIGINT
140);
141CREATE INDEX IF NOT EXISTS idx_autumn_outbox_pending
142    ON autumn_outbox (aggregate, seq)
143    WHERE dispatched_at IS NULL AND dead_at IS NULL;
144CREATE INDEX IF NOT EXISTS idx_autumn_outbox_dispatched
145    ON autumn_outbox (dispatched_at)
146    WHERE dispatched_at IS NOT NULL;
147CREATE TABLE IF NOT EXISTS autumn_inbox (
148    consumer   TEXT   NOT NULL,
149    message_id TEXT   NOT NULL,
150    seen_at    BIGINT NOT NULL,
151    PRIMARY KEY (consumer, message_id)
152);
153CREATE INDEX IF NOT EXISTS idx_autumn_inbox_seen_at ON autumn_inbox (seen_at);";
154
155/// Advisory lock key that serialises the boot DDL across replicas.
156#[cfg(not(feature = "sqlite"))]
157const SCHEMA_LOCK_KEY: i64 = 0x0A17_0B0C_0000_3062;
158
159/// Create the outbox and inbox tables if they do not exist.
160///
161/// On Postgres, it sends no DDL when both tables exist. Thus a role that
162/// cannot create or own tables can use tables made by a migration. An
163/// advisory lock serializes replicas that create the tables together.
164///
165/// # Errors
166///
167/// Returns an error when no connection is available or the DDL fails.
168pub async fn ensure_schema(pool: &Pool<RuntimeConnection>) -> AutumnResult<()> {
169    let mut conn = pool.get().await.map_err(|error| {
170        AutumnError::service_unavailable_msg(format!("outbox schema: no connection: {error}"))
171    })?;
172    crate::backend_select! {
173        pg => {
174            if tables_exist(&mut conn, &["autumn_outbox", "autumn_inbox"]).await? {
175                // Off the startup path: the build waits for every older
176                // transaction on the table, however long that takes.
177                let pool = pool.clone();
178                // The build holds a pooled connection until every older
179                // transaction on the table ends. A pool of one has none to spare.
180                if pool.status().max_size < 2 {
181                    tracing::warn!(
182                        "pool too small to build idx_autumn_outbox_claim in the background; run \
183                         `{CLAIM_INDEX_SQL}` by hand to speed up the outbox claim"
184                    );
185                    return Ok(());
186                }
187                tokio::spawn(async move {
188                    if let Err(error) = build_claim_index(&pool).await {
189                        tracing::warn!(%error, "could not build idx_autumn_outbox_claim; the outbox claim stays slow on a large backlog of sent rows");
190                    }
191                });
192                return Ok(());
193            }
194            conn.batch_execute(&format!(
195                "SELECT pg_advisory_xact_lock({SCHEMA_LOCK_KEY});\n{SCHEMA_SQL}"
196            ))
197            .await
198            .map_err(|error| sql_error("outbox schema", &error))
199        },
200        sqlite => {
201            conn.batch_execute(SCHEMA_SQL)
202                .await
203                .map_err(|error| sql_error("outbox schema", &error))
204        },
205    }
206}
207
208/// Add the claim index to tables made before it existed (Postgres).
209///
210/// The claim reads pending rows in `seq` order. Without this index it walks
211/// the primary key past every sent row that the purge has not deleted yet.
212/// The build is `CONCURRENTLY`, so it blocks no writes, and the index covers
213/// only pending rows. [`ensure_schema`] runs it in a background task, because
214/// the build waits for older transactions on the table and must not delay
215/// boot. A role that cannot create indexes keeps working, only slower: the
216/// failure is a warning. After a failed build, drop the invalid
217/// `idx_autumn_outbox_claim` and restart.
218#[cfg(not(feature = "sqlite"))]
219async fn build_claim_index(pool: &Pool<RuntimeConnection>) -> Result<(), String> {
220    let mut conn = pool.get().await.map_err(|error| error.to_string())?;
221    let present = tables_exist(&mut conn, &["idx_autumn_outbox_claim"])
222        .await
223        .map_err(|error| error.to_string())?;
224    if present {
225        return Ok(());
226    }
227    conn.batch_execute(CLAIM_INDEX_SQL)
228        .await
229        .map_err(|error| error.to_string())
230}
231
232#[cfg(not(feature = "sqlite"))]
233const CLAIM_INDEX_SQL: &str = "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_autumn_outbox_claim \
234     ON autumn_outbox (seq) WHERE dispatched_at IS NULL AND dead_at IS NULL";
235
236/// `true` when every table in `tables` exists (Postgres).
237#[cfg(not(feature = "sqlite"))]
238pub(crate) async fn tables_exist(
239    conn: &mut RuntimeConnection,
240    tables: &[&str],
241) -> AutumnResult<bool> {
242    #[derive(diesel::QueryableByName)]
243    struct Present {
244        #[diesel(sql_type = diesel::sql_types::Bool)]
245        present: bool,
246    }
247    for table in tables {
248        let row = diesel::sql_query("SELECT to_regclass($1) IS NOT NULL AS present")
249            .bind::<Text, _>(*table)
250            .get_result::<Present>(&mut *conn)
251            .await
252            .map_err(|error| sql_error("schema probe", &error))?;
253        if !row.present {
254            return Ok(false);
255        }
256    }
257    Ok(true)
258}
259
260// ── SQL ──────────────────────────────────────────────────────────────────────
261//
262// Statements are written once with Postgres `$n` placeholders, in bind order.
263// `sql()` turns them into `SQLite` `?` placeholders and drops the row-lock
264// clause. `SQLite` has one writer, so one `UPDATE ... RETURNING` claim is
265// atomic there.
266
267const INSERT_SQL: &str = "INSERT INTO autumn_outbox \
268     (id, aggregate, topic, payload, created_at, available_at) \
269     VALUES ($1, $2, $3, $4, $5, $6)";
270
271const MARK_SENT_SQL: &str = "UPDATE autumn_outbox \
272     SET dispatched_at = $1, claim_token = NULL, locked_until = NULL, last_error = NULL \
273     WHERE seq = $2 AND claim_token = $3";
274
275const NACK_SQL: &str = "UPDATE autumn_outbox \
276     SET last_error = $1, available_at = $2, dead_at = $3, \
277         claim_token = NULL, locked_until = NULL \
278     WHERE seq = $4 AND claim_token = $5";
279
280/// Dead-letter a message whose attempts crashes used. The claim that found
281/// it counted no attempt, so the count goes back to the limit.
282const EXHAUSTED_SQL: &str = "UPDATE autumn_outbox \
283     SET attempts = $1, last_error = $2, dead_at = $3, \
284         claim_token = NULL, locked_until = NULL \
285     WHERE seq = $4 AND claim_token = $5";
286
287/// Give back the rows of a claim that the relay did not handle. The claim
288/// counted an attempt; this takes it back. The caller adds the `seq IN`
289/// list of the unhandled rows, so a handled row keeps its attempt.
290const RELEASE_SQL: &str = "UPDATE autumn_outbox \
291     SET claim_token = NULL, locked_until = NULL, attempts = attempts - 1 \
292     WHERE claim_token = $1 AND dispatched_at IS NULL AND dead_at IS NULL \
293       AND seq IN";
294
295const PURGE_OUTBOX_SQL: &str = "DELETE FROM autumn_outbox \
296     WHERE dispatched_at IS NOT NULL AND dispatched_at < $1";
297
298const PURGE_INBOX_SQL: &str = "DELETE FROM autumn_inbox WHERE seen_at < $1";
299
300const INBOX_INSERT_SQL: &str = "INSERT INTO autumn_inbox (consumer, message_id, seen_at) \
301     VALUES ($1, $2, $3) ON CONFLICT DO NOTHING";
302
303const DEAD_LETTERS_SQL: &str = "SELECT seq, id, aggregate, topic, payload, attempts, \
304     created_at, last_error, dead_at FROM autumn_outbox \
305     WHERE dead_at IS NOT NULL ORDER BY seq LIMIT $1";
306
307const REQUEUE_SQL: &str = "UPDATE autumn_outbox \
308     SET dead_at = NULL, attempts = 0, last_error = NULL, available_at = $1 \
309     WHERE id = $2 AND dead_at IS NOT NULL AND dispatched_at IS NULL";
310
311/// Claim the oldest ready messages, and count one attempt for each.
312///
313/// A message is ready when its aggregate has no older pending message. Thus
314/// the relay sends the messages of one aggregate in order. A crash or an
315/// expired lease also uses an attempt, so a message that kills the relay
316/// goes to the dead letters.
317fn claim_sql(topics_in_list: &str) -> String {
318    let lock = crate::backend_select! {
319        pg => { " FOR UPDATE SKIP LOCKED" },
320        sqlite => { "" },
321    };
322    // Convert the placeholders before the topic list goes in.
323    sql(&format!(
324        "UPDATE autumn_outbox SET claim_token = $1, locked_until = $2, attempts = attempts + 1 \
325         WHERE seq IN ( \
326           SELECT o.seq FROM autumn_outbox o \
327           WHERE o.dispatched_at IS NULL AND o.dead_at IS NULL \
328             AND o.available_at <= $3 \
329             AND (o.locked_until IS NULL OR o.locked_until <= $4) \
330             AND o.topic IN (__TOPICS__) \
331             AND NOT EXISTS ( \
332               SELECT 1 FROM autumn_outbox p \
333               WHERE p.aggregate = o.aggregate AND p.seq < o.seq \
334                 AND p.dispatched_at IS NULL AND p.dead_at IS NULL) \
335           ORDER BY o.seq \
336           LIMIT $5{lock}) \
337         RETURNING seq, id, aggregate, topic, payload, attempts, created_at"
338    ))
339    .replace("__TOPICS__", topics_in_list)
340}
341
342/// Convert a Postgres statement for the compiled backend.
343fn sql(pg: &str) -> String {
344    crate::backend_select! {
345        pg => { pg.to_owned() },
346        sqlite => { to_sqlite_placeholders(pg) },
347    }
348}
349
350/// Replace each `$n` placeholder with `?`. Binds must follow text order.
351#[cfg_attr(not(any(feature = "sqlite", test)), allow(dead_code))]
352pub(crate) fn to_sqlite_placeholders(pg: &str) -> String {
353    let mut out = String::with_capacity(pg.len());
354    let mut chars = pg.chars().peekable();
355    while let Some(c) = chars.next() {
356        if c == '$' && chars.peek().is_some_and(char::is_ascii_digit) {
357            while chars.peek().is_some_and(char::is_ascii_digit) {
358                chars.next();
359            }
360            out.push('?');
361        } else {
362            out.push(c);
363        }
364    }
365    out
366}
367
368fn sql_error(context: &str, error: &diesel::result::Error) -> AutumnError {
369    AutumnError::internal_server_error_msg(format!("{context}: {error}"))
370}
371
372const fn to_millis(time: DateTime<Utc>) -> i64 {
373    time.timestamp_millis()
374}
375
376fn from_millis(millis: i64) -> DateTime<Utc> {
377    Utc.timestamp_millis_opt(millis)
378        .single()
379        .unwrap_or(DateTime::<Utc>::MIN_UTC)
380}
381
382fn duration_millis(duration: Duration) -> i64 {
383    i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
384}
385
386// ── Messages ─────────────────────────────────────────────────────────────────
387
388/// A message the relay gives to a handler.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390#[non_exhaustive]
391pub struct OutboxMessage {
392    /// Stable id. It is the same on each send; use it with [`Inbox::seen`].
393    pub id: String,
394    /// Ordering key. The relay sends the messages of one aggregate in order.
395    pub aggregate: String,
396    /// Routing key. It selects the handler.
397    pub topic: String,
398    /// The JSON payload.
399    pub payload: Value,
400    /// The attempt number, starting at 1. In a [`DeadLetter`], the number of
401    /// attempts made.
402    pub attempt: u32,
403    /// When the message was written (app clock).
404    pub created_at: DateTime<Utc>,
405}
406
407impl OutboxMessage {
408    /// A message at attempt 1. Use it to test a handler without a relay.
409    #[must_use]
410    pub fn new(
411        id: impl Into<String>,
412        aggregate: impl Into<String>,
413        topic: impl Into<String>,
414        payload: Value,
415        created_at: DateTime<Utc>,
416    ) -> Self {
417        Self {
418            id: id.into(),
419            aggregate: aggregate.into(),
420            topic: topic.into(),
421            payload,
422            attempt: 1,
423            created_at,
424        }
425    }
426
427    /// Deserialize the payload.
428    ///
429    /// # Errors
430    ///
431    /// Returns a `400` error when the payload does not match `T`.
432    pub fn payload_as<T: DeserializeOwned>(&self) -> AutumnResult<T> {
433        // The serde text can quote payload values, so log only its kind.
434        serde_json::from_value(self.payload.clone()).map_err(|error| {
435            AutumnError::bad_request_msg(format!(
436                "outbox message {} ({}): payload does not match ({:?})",
437                self.id,
438                self.topic,
439                error.classify()
440            ))
441        })
442    }
443}
444
445/// A message the relay stopped after `outbox.max_attempts` failures.
446#[derive(Debug, Clone, PartialEq, Eq)]
447#[non_exhaustive]
448pub struct DeadLetter {
449    /// The message.
450    pub message: OutboxMessage,
451    /// The error of the last attempt.
452    pub last_error: Option<String>,
453    /// When the relay stopped the message (app clock).
454    pub dead_at: DateTime<Utc>,
455}
456
457#[derive(diesel::QueryableByName)]
458struct ClaimedRow {
459    #[diesel(sql_type = BigInt)]
460    seq: i64,
461    #[diesel(sql_type = Text)]
462    id: String,
463    #[diesel(sql_type = Text)]
464    aggregate: String,
465    #[diesel(sql_type = Text)]
466    topic: String,
467    #[diesel(sql_type = Text)]
468    payload: String,
469    #[diesel(sql_type = Integer)]
470    attempts: i32,
471    #[diesel(sql_type = BigInt)]
472    created_at: i64,
473}
474
475#[derive(diesel::QueryableByName)]
476struct DeadRow {
477    #[diesel(sql_type = BigInt)]
478    seq: i64,
479    #[diesel(sql_type = Text)]
480    id: String,
481    #[diesel(sql_type = Text)]
482    aggregate: String,
483    #[diesel(sql_type = Text)]
484    topic: String,
485    #[diesel(sql_type = Text)]
486    payload: String,
487    #[diesel(sql_type = Integer)]
488    attempts: i32,
489    #[diesel(sql_type = BigInt)]
490    created_at: i64,
491    #[diesel(sql_type = Nullable<Text>)]
492    last_error: Option<String>,
493    #[diesel(sql_type = Nullable<BigInt>)]
494    dead_at: Option<i64>,
495}
496
497fn parse_payload(id: &str, raw: &str) -> Value {
498    serde_json::from_str(raw).unwrap_or_else(|error| {
499        tracing::warn!(message_id = %id, %error, "outbox payload is not JSON; sending it as a string");
500        Value::String(raw.to_owned())
501    })
502}
503
504impl ClaimedRow {
505    fn message(&self) -> OutboxMessage {
506        OutboxMessage {
507            id: self.id.clone(),
508            aggregate: self.aggregate.clone(),
509            topic: self.topic.clone(),
510            payload: parse_payload(&self.id, &self.payload),
511            attempt: u32::try_from(self.attempts).unwrap_or(0).max(1),
512            created_at: from_millis(self.created_at),
513        }
514    }
515}
516
517// ── Writer ───────────────────────────────────────────────────────────────────
518
519/// Writes messages into the outbox on the caller's connection.
520///
521/// Give it the connection of the open transaction. Then the message commits
522/// or rolls back with the business data.
523///
524/// Get one with [`Outbox::new`], or extract it in a handler.
525#[derive(Clone)]
526pub struct Outbox {
527    state: AppState,
528}
529
530impl std::fmt::Debug for Outbox {
531    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532        f.debug_struct("Outbox").finish_non_exhaustive()
533    }
534}
535
536impl axum::extract::FromRequestParts<AppState> for Outbox {
537    type Rejection = AutumnError;
538
539    async fn from_request_parts(
540        _parts: &mut http::request::Parts,
541        state: &AppState,
542    ) -> Result<Self, Self::Rejection> {
543        Ok(Self::new(state))
544    }
545}
546
547impl Outbox {
548    /// An outbox writer for `state`. It uses the clock and entropy of `state`.
549    #[must_use]
550    pub fn new(state: &AppState) -> Self {
551        Self {
552            state: state.clone(),
553        }
554    }
555
556    /// The installed relay. Without it, nothing sends a message, so a write
557    /// is an error.
558    fn relay(&self) -> AutumnResult<Arc<OutboxRelay>> {
559        self.state.extension::<OutboxRelay>().ok_or_else(|| {
560            AutumnError::internal_server_error_msg("the outbox is off; set outbox.enabled = true")
561        })
562    }
563
564    async fn insert(
565        &self,
566        conn: &mut RuntimeConnection,
567        aggregate: Option<&str>,
568        topic: &str,
569        body: &Value,
570    ) -> AutumnResult<String> {
571        self.relay()?;
572        insert_message(
573            conn,
574            self.state.entropy(),
575            self.state.clock(),
576            aggregate,
577            topic,
578            body,
579        )
580        .await
581    }
582
583    /// Write one message. Returns its id.
584    ///
585    /// The relay sends the messages of one `aggregate` in write order. Use
586    /// the id of the changed entity, for example `"order:42"`.
587    ///
588    /// # Errors
589    ///
590    /// Returns an error when the outbox is off, `topic` has no handler (a
591    /// typing error, or a reserved `autumn.` topic), the payload does not
592    /// serialize, or the insert fails.
593    pub async fn write<T: Serialize + Sync + ?Sized>(
594        &self,
595        conn: &mut RuntimeConnection,
596        aggregate: &str,
597        topic: &str,
598        payload: &T,
599    ) -> AutumnResult<String> {
600        if topic.starts_with(RESERVED_TOPIC_PREFIX) || !self.relay()?.handlers.contains_key(topic) {
601            return Err(AutumnError::bad_request_msg(format!(
602                "outbox topic {topic:?} has no handler; register it with outbox_handler"
603            )));
604        }
605        let payload = to_json(payload, &format!("outbox payload for {topic}"))?;
606        self.insert(conn, Some(aggregate), topic, &payload).await
607    }
608
609    /// Publish `event` to its durable listeners through the outbox.
610    ///
611    /// Writes one message per durable listener. After commit, the relay
612    /// enqueues the job of each listener, so the listener keeps its own
613    /// retry settings. Sync listeners run now, as with
614    /// [`Events::publish`](crate::events::Events::publish).
615    ///
616    /// # Errors
617    ///
618    /// Returns an error when the outbox is off, the event does not
619    /// serialize, or an insert fails. Sync listeners run in all cases.
620    pub async fn publish<E: Event>(
621        &self,
622        conn: &mut RuntimeConnection,
623        event: &E,
624    ) -> AutumnResult<()> {
625        let payload = to_json(event, &format!("event {}", E::NAME))?;
626        if let Some(recorder) = self.state.extension::<crate::events::EventRecorder>() {
627            recorder.record(E::NAME, payload.clone());
628        }
629        let Some(registry) = self.state.extension::<crate::events::EventRegistry>() else {
630            return Ok(());
631        };
632        let listeners = registry.listeners_for(E::NAME);
633        let mut first_error = None;
634        for job_name in listeners
635            .iter()
636            .filter(|listener| listener.mode == crate::events::DispatchMode::Durable)
637            .filter_map(|listener| listener.job_name.as_deref())
638        {
639            let body = self.job_body(job_name, payload.clone());
640            if let Err(error) = self.insert(conn, None, TOPIC_EVENT, &body).await {
641                first_error.get_or_insert(error);
642            }
643        }
644        crate::events::run_sync_listeners(&self.state, listeners, &payload).await;
645        first_error.map_or(Ok(()), Err)
646    }
647
648    /// Enqueue job `name` through the outbox. Returns the message id.
649    ///
650    /// Use it on the Redis and `SQLite` job backends, where
651    /// [`enqueue_in_tx`](crate::job::enqueue_in_tx) cannot join the
652    /// transaction. The relay enqueues the job after commit. A crash can
653    /// enqueue it twice, so make the job idempotent. After the enqueue, the
654    /// job backend holds the job: the `local` backend loses it in a crash.
655    ///
656    /// # Errors
657    ///
658    /// Returns an error when `args` does not serialize or the insert fails.
659    pub async fn enqueue_job<A: Serialize + Sync + ?Sized>(
660        &self,
661        conn: &mut RuntimeConnection,
662        name: &str,
663        args: &A,
664    ) -> AutumnResult<String> {
665        let args = to_json(args, &format!("job {name} args"))?;
666        let body = self.job_body(name, args);
667        self.insert(conn, None, TOPIC_JOB, &body).await
668    }
669
670    /// The stored body of a job enqueue, as the after-commit path keeps it.
671    ///
672    /// The args get the schema-version envelope of the job now, not at relay
673    /// time, so a message that outlives a deploy keeps the version it was
674    /// written at. The enqueuing tenant goes in too: the relay runs in its own
675    /// task, and without it the job loses its tenant lane and cost tenant.
676    fn job_body(&self, name: &str, args: Value) -> Value {
677        let client = self
678            .state
679            .extension::<crate::job::JobClient>()
680            .or_else(crate::job::global_job_client);
681        let args = match client {
682            Some(client) => client.wrap_payload_version(name, args),
683            None => args,
684        };
685        let mut body = serde_json::json!({ "name": name, "args": args });
686        if let Some(tenant) = crate::cost::enqueuing_tenant() {
687            body["tenant"] = Value::String(tenant);
688        }
689        body
690    }
691
692    /// Deliver `mail` through the outbox. Returns the message id.
693    ///
694    /// The relay sends it with the app [`Mailer`](crate::mail::Mailer) after
695    /// commit. It applies the mailer defaults now, as `deliver_later` does.
696    ///
697    /// # Errors
698    ///
699    /// Returns an error when the insert fails.
700    #[cfg(feature = "mail")]
701    pub async fn deliver_mail(
702        &self,
703        conn: &mut RuntimeConnection,
704        mail: crate::mail::Mail,
705    ) -> AutumnResult<String> {
706        let mail = match self.state.extension::<crate::mail::Mailer>() {
707            Some(mailer) => mailer.prepare_deferred(mail),
708            None => mail,
709        };
710        let body = to_json(&mail, "mail")?;
711        self.insert(conn, None, TOPIC_MAIL, &body).await
712    }
713
714    /// Dispatch webhook `topic` through the outbox. Returns the message id.
715    ///
716    /// After commit, the relay calls
717    /// [`WebhookOutboundManager::dispatch`](crate::webhook_outbound::WebhookOutboundManager::dispatch).
718    /// Each delivery id comes from the message id. A second relay send skips
719    /// a delivery that has a result. Before a result, it can enqueue the
720    /// delivery again, with the same `webhook-id`.
721    ///
722    /// # Errors
723    ///
724    /// Returns an error when `payload` does not serialize or the insert fails.
725    #[cfg(feature = "http-client")]
726    pub async fn dispatch_webhook<T: Serialize + Sync + ?Sized>(
727        &self,
728        conn: &mut RuntimeConnection,
729        topic: &str,
730        payload: &T,
731    ) -> AutumnResult<String> {
732        let payload = to_json(payload, &format!("webhook payload for {topic}"))?;
733        let body = serde_json::json!({ "topic": topic, "payload": payload });
734        self.insert(conn, None, TOPIC_WEBHOOK, &body).await
735    }
736
737    /// The dead letters, oldest first.
738    ///
739    /// # Errors
740    ///
741    /// Returns an error when the query fails.
742    pub async fn dead_letters(
743        &self,
744        conn: &mut RuntimeConnection,
745        limit: usize,
746    ) -> AutumnResult<Vec<DeadLetter>> {
747        let rows = diesel::sql_query(sql(DEAD_LETTERS_SQL))
748            .bind::<BigInt, _>(i64::try_from(limit).unwrap_or(i64::MAX))
749            .load::<DeadRow>(conn)
750            .await
751            .map_err(|error| sql_error("outbox dead letters", &error))?;
752        Ok(rows
753            .into_iter()
754            .map(|row| DeadLetter {
755                message: ClaimedRow {
756                    seq: row.seq,
757                    id: row.id,
758                    aggregate: row.aggregate,
759                    topic: row.topic,
760                    payload: row.payload,
761                    attempts: row.attempts,
762                    created_at: row.created_at,
763                }
764                .message(),
765                last_error: row.last_error,
766                dead_at: from_millis(row.dead_at.unwrap_or_default()),
767            })
768            .collect())
769    }
770
771    /// Send dead letter `id` again, from attempt 1. Returns `false` when `id`
772    /// is not a dead letter.
773    ///
774    /// Later messages of the same aggregate can already be sent, so a
775    /// requeued message can arrive out of order.
776    ///
777    /// # Errors
778    ///
779    /// Returns an error when the update fails.
780    pub async fn requeue(&self, conn: &mut RuntimeConnection, id: &str) -> AutumnResult<bool> {
781        let changed = diesel::sql_query(sql(REQUEUE_SQL))
782            .bind::<BigInt, _>(to_millis(self.state.clock().now()))
783            .bind::<Text, _>(id)
784            .execute(&mut *conn)
785            .await
786            .map_err(|error| sql_error("outbox requeue", &error))?;
787        if changed > 0 {
788            #[cfg(feature = "sqlite")]
789            mark_fleet_outbox(conn).await?;
790        }
791        Ok(changed > 0)
792    }
793}
794
795fn to_json<T: Serialize + ?Sized>(value: &T, what: &str) -> AutumnResult<Value> {
796    serde_json::to_value(value).map_err(|error| {
797        AutumnError::internal_server_error_msg(format!("{what} does not serialize: {error}"))
798    })
799}
800
801/// Insert one row. With no `aggregate`, the message id is the aggregate, so
802/// the message has no order constraint.
803async fn insert_message(
804    conn: &mut RuntimeConnection,
805    entropy: &dyn Entropy,
806    clock: &dyn ClockSource,
807    aggregate: Option<&str>,
808    topic: &str,
809    payload: &Value,
810) -> AutumnResult<String> {
811    let id = entropy.uuid_v4().to_string();
812    let now = to_millis(clock.now());
813    diesel::sql_query(sql(INSERT_SQL))
814        .bind::<Text, _>(&id)
815        .bind::<Text, _>(aggregate.unwrap_or(&id))
816        .bind::<Text, _>(topic)
817        .bind::<Text, _>(payload.to_string())
818        .bind::<BigInt, _>(now)
819        .bind::<BigInt, _>(now)
820        .execute(&mut *conn)
821        .await
822        .map_err(|error| sql_error("outbox write", &error))?;
823    #[cfg(feature = "sqlite")]
824    mark_fleet_outbox(conn).await?;
825    Ok(id)
826}
827
828/// Mark this connection's fleet database as holding outbox rows, in
829/// the caller's transaction (issue 3234). A no-op on any other database.
830#[cfg(feature = "sqlite")]
831async fn mark_fleet_outbox(conn: &mut RuntimeConnection) -> AutumnResult<()> {
832    crate::db::fleet_sweeper::mark_connection(conn, crate::db::fleet_sweeper::PendingKind::Outbox)
833        .await
834}
835
836// ── Inbox ────────────────────────────────────────────────────────────────────
837
838/// Drops copies of a message for one consumer.
839///
840/// Call [`seen`](Self::seen) in the same transaction as the side effect of the
841/// consumer. Then the record and the effect commit together.
842#[derive(Debug, Clone, PartialEq, Eq)]
843pub struct Inbox {
844    consumer: String,
845}
846
847impl Inbox {
848    /// The inbox of `consumer`. Each consumer has its own set of ids.
849    #[must_use]
850    pub fn new(consumer: impl Into<String>) -> Self {
851        Self {
852            consumer: consumer.into(),
853        }
854    }
855
856    /// Record `message_id`. Returns `true` when it was already recorded: the
857    /// message is a copy, so skip it.
858    ///
859    /// # Errors
860    ///
861    /// Returns an error when the insert fails.
862    pub async fn seen(&self, conn: &mut RuntimeConnection, message_id: &str) -> AutumnResult<bool> {
863        let inserted = diesel::sql_query(sql(INBOX_INSERT_SQL))
864            .bind::<Text, _>(&self.consumer)
865            .bind::<Text, _>(message_id)
866            .bind::<BigInt, _>(to_millis(crate::time::ambient_now()))
867            .execute(conn)
868            .await
869            .map_err(|error| sql_error("inbox", &error))?;
870        Ok(inserted == 0)
871    }
872}
873
874// ── Relay ────────────────────────────────────────────────────────────────────
875
876type HandlerFuture = Pin<Box<dyn Future<Output = AutumnResult<()>> + Send + 'static>>;
877
878/// A topic handler. It gets the app state and the message.
879pub type OutboxHandler = Arc<dyn Fn(AppState, OutboxMessage) -> HandlerFuture + Send + Sync>;
880
881/// Topic handlers collected by the app builder.
882#[derive(Clone, Default)]
883pub(crate) struct OutboxHandlers {
884    handlers: HashMap<String, OutboxHandler>,
885}
886
887impl std::fmt::Debug for OutboxHandlers {
888    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
889        f.debug_struct("OutboxHandlers")
890            .field("topics", &self.handlers.keys().collect::<Vec<_>>())
891            .finish()
892    }
893}
894
895/// `true` when `topic` uses only `A-Z a-z 0-9 . _ : -`. The relay puts
896/// topics in its SQL text, so no other character is allowed.
897fn is_valid_topic(topic: &str) -> bool {
898    !topic.is_empty()
899        && topic
900            .bytes()
901            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
902}
903
904impl OutboxHandlers {
905    /// Set the handler of `topic`. A second call for one topic replaces it.
906    ///
907    /// # Panics
908    ///
909    /// Panics when `topic` is empty, has a character other than
910    /// `A-Z a-z 0-9 . _ : -`, or starts with `autumn.` (reserved).
911    pub(crate) fn insert<F, Fut>(&mut self, topic: impl Into<String>, handler: F)
912    where
913        F: Fn(AppState, OutboxMessage) -> Fut + Send + Sync + 'static,
914        Fut: Future<Output = AutumnResult<()>> + Send + 'static,
915    {
916        let topic = topic.into();
917        assert!(
918            is_valid_topic(&topic),
919            "outbox topic {topic:?} must not be empty and must use only A-Z a-z 0-9 . _ : -"
920        );
921        assert!(
922            !topic.starts_with(RESERVED_TOPIC_PREFIX),
923            "outbox topic {topic:?}: the `{RESERVED_TOPIC_PREFIX}` prefix is reserved"
924        );
925        self.insert_unchecked(topic, handler);
926    }
927
928    fn insert_unchecked<F, Fut>(&mut self, topic: String, handler: F)
929    where
930        F: Fn(AppState, OutboxMessage) -> Fut + Send + Sync + 'static,
931        Fut: Future<Output = AutumnResult<()>> + Send + 'static,
932    {
933        let handler: OutboxHandler =
934            Arc::new(move |state, message| Box::pin(handler(state, message)));
935        self.handlers.insert(topic, handler);
936    }
937
938    /// Add every handler of `other`. A handler of `other` replaces one of
939    /// `self` for the same topic.
940    pub(crate) fn extend(&mut self, other: Self) {
941        self.handlers.extend(other.handlers);
942    }
943
944    pub(crate) fn is_empty(&self) -> bool {
945        self.handlers.is_empty()
946    }
947}
948
949/// The relay of one app: config and topic handlers. The app installs it on
950/// [`AppState`] when `outbox.enabled = true`.
951#[derive(Clone)]
952pub struct OutboxRelay {
953    config: OutboxConfig,
954    handlers: Arc<HashMap<String, OutboxHandler>>,
955    topics_in_list: Arc<str>,
956    /// Indexes (in `relay_pools` order) of pools with no tables yet: the boot
957    /// could not reach them. The relay tries again before each drain.
958    schema_pending: Arc<std::sync::Mutex<std::collections::HashSet<usize>>>,
959}
960
961impl std::fmt::Debug for OutboxRelay {
962    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
963        f.debug_struct("OutboxRelay")
964            .field("config", &self.config)
965            .field("topics", &self.topics_in_list)
966            .finish_non_exhaustive()
967    }
968}
969
970impl OutboxRelay {
971    /// A relay with the built-in topics and `handlers`.
972    pub(crate) fn new(config: OutboxConfig, handlers: OutboxHandlers) -> Self {
973        let mut all = handlers;
974        all.insert_unchecked(TOPIC_EVENT.to_owned(), run_job_enqueue);
975        all.insert_unchecked(TOPIC_JOB.to_owned(), run_job_enqueue);
976        #[cfg(feature = "mail")]
977        all.insert_unchecked(TOPIC_MAIL.to_owned(), run_mail_delivery);
978        #[cfg(feature = "http-client")]
979        all.insert_unchecked(TOPIC_WEBHOOK.to_owned(), run_webhook_dispatch);
980
981        let mut topics: Vec<&String> = all.handlers.keys().collect();
982        topics.sort();
983        // `is_valid_topic` keeps quotes out; doubling them is a second guard.
984        let topics_in_list = topics
985            .iter()
986            .map(|topic| format!("'{}'", topic.replace('\'', "''")))
987            .collect::<Vec<_>>()
988            .join(", ");
989        Self {
990            config,
991            handlers: Arc::new(all.handlers),
992            topics_in_list: topics_in_list.into(),
993            schema_pending: Arc::default(),
994        }
995    }
996
997    fn pending_schema(&self) -> std::sync::MutexGuard<'_, std::collections::HashSet<usize>> {
998        self.schema_pending
999            .lock()
1000            .unwrap_or_else(std::sync::PoisonError::into_inner)
1001    }
1002
1003    /// Create the tables on pool `index` if the boot could not.
1004    async fn ensure_pending_schema(
1005        &self,
1006        index: usize,
1007        pool: &Pool<RuntimeConnection>,
1008    ) -> AutumnResult<()> {
1009        if !self.pending_schema().contains(&index) {
1010            return Ok(());
1011        }
1012        ensure_schema(pool).await?;
1013        self.pending_schema().remove(&index);
1014        Ok(())
1015    }
1016
1017    /// The relay settings.
1018    #[must_use]
1019    pub const fn config(&self) -> &OutboxConfig {
1020        &self.config
1021    }
1022
1023    /// `true` when the relay has a handler for `topic`.
1024    #[must_use]
1025    pub fn handles(&self, topic: &str) -> bool {
1026        self.handlers.contains_key(topic)
1027    }
1028}
1029
1030/// Install the relay on `state` when `config.enabled`. With the `mail`
1031/// feature, also send `deliver_later` through the outbox, unless the app
1032/// set its own queue.
1033pub(crate) fn install(state: &AppState, config: &OutboxConfig, handlers: OutboxHandlers) {
1034    if !config.enabled {
1035        if !handlers.is_empty() {
1036            tracing::warn!(
1037                "outbox handlers are registered but outbox.enabled = false; the relay does not run"
1038            );
1039        }
1040        return;
1041    }
1042    let Some(pool) = mail_pool(state) else {
1043        tracing::warn!("outbox.enabled = true needs a database; the relay does not run");
1044        return;
1045    };
1046    state.insert_extension(OutboxRelay::new(config.clone(), handlers));
1047    #[cfg(feature = "mail")]
1048    if state
1049        .extension::<crate::mail::MailDeliveryQueueHandle>()
1050        .is_none()
1051    {
1052        state.insert_extension(crate::mail::MailDeliveryQueueHandle::new(OutboxMailQueue {
1053            pool,
1054            entropy: state.entropy_arc(),
1055            clock: state.clock_arc(),
1056        }));
1057    }
1058    #[cfg(not(feature = "mail"))]
1059    drop(pool);
1060}
1061
1062/// Whether [`install`] gives `deliver_later` an outbox queue.
1063///
1064/// Replay installs no relay, so its mailer asks this to show the queue
1065/// production had (#2351).
1066#[cfg(all(feature = "mail", feature = "reporting"))]
1067pub(crate) fn installs_mail_queue(state: &AppState, config: &OutboxConfig) -> bool {
1068    config.enabled && mail_pool(state).is_some()
1069}
1070
1071tokio::task_local! {
1072    static CURRENT_MESSAGE_ID: String;
1073}
1074
1075/// The id of the message the relay is handling on this task, if any.
1076///
1077/// Use it in code that the handler calls and that does not get the
1078/// [`OutboxMessage`], for example with [`Inbox::seen`].
1079#[must_use]
1080pub fn current_message_id() -> Option<String> {
1081    CURRENT_MESSAGE_ID.try_with(Clone::clone).ok()
1082}
1083
1084/// Send up to `max` ready messages from each relay pool (the app pool and
1085/// each shard) now. Returns how many the relay handled (sent or failed).
1086///
1087/// The relay worker calls this in a loop. Tests and `Sim::run_to_idle` call
1088/// it to drain the outbox without a worker.
1089///
1090/// # Errors
1091///
1092/// Returns an error when the relay is not installed (`outbox.enabled =
1093/// false`), the app has no database, or a claim fails.
1094pub async fn drain(state: &AppState, max: usize) -> AutumnResult<usize> {
1095    drain_until(state, max, None).await
1096}
1097
1098/// [`drain`] that stops between messages when `shutdown` is cancelled.
1099async fn drain_until(
1100    state: &AppState,
1101    max: usize,
1102    shutdown: Option<&CancellationToken>,
1103) -> AutumnResult<usize> {
1104    let relay = state.extension::<OutboxRelay>().ok_or_else(|| {
1105        AutumnError::internal_server_error_msg(
1106            "outbox relay is not installed; set outbox.enabled = true",
1107        )
1108    })?;
1109    // Each pool gets its own budget, so a busy app pool cannot starve a
1110    // shard.
1111    // A failed pool (a shard that is down) does not stop the others. The
1112    // drain fails only when every pool fails.
1113    let pools = relay_pools(state)?;
1114    let mut handled = 0;
1115    let mut failures = Vec::new();
1116    for (index, pool) in pools.iter().enumerate() {
1117        let drained = match relay.ensure_pending_schema(index, pool).await {
1118            Ok(()) => drain_pool(state, &relay, pool, max, shutdown).await,
1119            Err(error) => Err(error),
1120        };
1121        match drained {
1122            Ok(count) => handled += count,
1123            Err(error) => {
1124                tracing::warn!(%error, "outbox relay could not drain one pool; it goes on with the others");
1125                failures.push(error);
1126            }
1127        }
1128    }
1129    fail_if_every_pool_failed(failures, pools.len())?;
1130    Ok(handled)
1131}
1132
1133/// The pool `deliver_later` writes on. Mail has no shard key: it is the app
1134/// pool, or the first shard when there is no app pool.
1135fn mail_pool(state: &AppState) -> Option<Pool<RuntimeConnection>> {
1136    relay_pools(state).ok()?.into_iter().next()
1137}
1138
1139/// Relay the outbox of one fleet database for the fleet sweeper (issue 3234):
1140/// send up to `max` ready messages, then purge old rows. In a tenant fleet,
1141/// `tenant` is the database's tenant, and handlers run in its scope.
1142///
1143/// # Errors
1144///
1145/// Returns an error when the relay is not installed, or a claim or the purge
1146/// fails.
1147#[cfg(feature = "sqlite")]
1148pub(crate) async fn relay_fleet_database(
1149    state: &AppState,
1150    pool: &Pool<RuntimeConnection>,
1151    max: usize,
1152    shutdown: &CancellationToken,
1153    tenant: Option<String>,
1154) -> AutumnResult<usize> {
1155    let relay = state
1156        .extension::<OutboxRelay>()
1157        .ok_or_else(|| AutumnError::internal_server_error_msg("outbox relay is not installed"))?;
1158    ensure_schema(pool).await?;
1159    let drain = drain_pool(state, &relay, pool, max, Some(shutdown));
1160    let handled = match tenant {
1161        Some(tenant) => {
1162            crate::tenancy::CURRENT_TENANT
1163                .scope(Some(tenant), drain)
1164                .await?
1165        }
1166        None => drain.await?,
1167    };
1168    let retention = duration_millis(Duration::from_millis(relay.config.retention_ms));
1169    let outbox_cutoff = to_millis(state.clock().now()).saturating_sub(retention);
1170    let inbox_cutoff = to_millis(crate::time::ambient_now()).saturating_sub(retention);
1171    purge_pool(pool, outbox_cutoff, inbox_cutoff).await?;
1172    Ok(handled)
1173}
1174
1175/// Whether a database holds outbox messages that are neither sent nor dead.
1176/// `false` when it has no outbox table.
1177///
1178/// # Errors
1179///
1180/// Returns an error when the query fails.
1181#[cfg(feature = "sqlite")]
1182pub(crate) async fn has_pending(conn: &mut RuntimeConnection) -> AutumnResult<bool> {
1183    #[derive(diesel::QueryableByName)]
1184    struct Pending {
1185        #[diesel(sql_type = BigInt)]
1186        n: i64,
1187    }
1188    let table: Pending = diesel::sql_query(
1189        "SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'autumn_outbox'",
1190    )
1191    .get_result(&mut *conn)
1192    .await
1193    .map_err(|error| sql_error("outbox probe", &error))?;
1194    if table.n == 0 {
1195        return Ok(false);
1196    }
1197    let pending: Pending = diesel::sql_query(
1198        "SELECT COUNT(*) AS n FROM (SELECT 1 FROM autumn_outbox \
1199         WHERE dispatched_at IS NULL AND dead_at IS NULL LIMIT 1)",
1200    )
1201    .get_result(&mut *conn)
1202    .await
1203    .map_err(|error| sql_error("outbox probe", &error))?;
1204    Ok(pending.n > 0)
1205}
1206
1207/// The pools the relay drains: the app pool, if any, and the primary pool of
1208/// each shard. A write on a shard connection puts its row in that shard.
1209fn relay_pools(state: &AppState) -> AutumnResult<Vec<Pool<RuntimeConnection>>> {
1210    let mut pools: Vec<Pool<RuntimeConnection>> = state.pool().cloned().into_iter().collect();
1211    if let Some(shards) = state.shards() {
1212        pools.extend(shards.iter().map(|shard| shard.primary_pool().clone()));
1213    }
1214    if pools.is_empty() {
1215        return Err(AutumnError::internal_server_error_msg(
1216            "outbox relay needs a database",
1217        ));
1218    }
1219    Ok(pools)
1220}
1221
1222/// Create the outbox tables on every pool the relay drains. A pool that
1223/// fails (a shard that is down) does not stop the boot: the relay tries
1224/// again before it drains that pool.
1225///
1226/// # Errors
1227///
1228/// Returns an error when every pool fails.
1229pub(crate) async fn ensure_relay_schema(state: &AppState) -> AutumnResult<()> {
1230    let relay = state.extension::<OutboxRelay>();
1231    let pools = relay_pools(state)?;
1232    let mut failures = Vec::new();
1233    for (index, pool) in pools.iter().enumerate() {
1234        if let Err(error) = ensure_schema(pool).await {
1235            tracing::warn!(%error, "outbox could not create its tables on one pool; the relay tries again later");
1236            if let Some(relay) = &relay {
1237                relay.pending_schema().insert(index);
1238            }
1239            failures.push(error);
1240        }
1241    }
1242    fail_if_every_pool_failed(failures, pools.len())
1243}
1244
1245/// A multi-pool step fails only when every pool fails. Then it returns the
1246/// first error.
1247fn fail_if_every_pool_failed(failures: Vec<AutumnError>, pools: usize) -> AutumnResult<()> {
1248    if failures.len() == pools
1249        && let Some(error) = failures.into_iter().next()
1250    {
1251        return Err(error);
1252    }
1253    Ok(())
1254}
1255
1256async fn drain_pool(
1257    state: &AppState,
1258    relay: &OutboxRelay,
1259    pool: &Pool<RuntimeConnection>,
1260    max: usize,
1261    shutdown: Option<&CancellationToken>,
1262) -> AutumnResult<usize> {
1263    let lease = duration_millis(Duration::from_millis(relay.config.lease_ms));
1264    let mut handled = 0;
1265    while handled < max {
1266        let batch = (max - handled).min(relay.config.batch_size.max(1));
1267        let claim_token = state.entropy().uuid_v4().to_string();
1268        let lease_end = to_millis(state.clock().now()).saturating_add(lease);
1269        let mut rows = claim(relay, pool, state, &claim_token, batch).await?;
1270        if rows.is_empty() {
1271            break;
1272        }
1273        rows.sort_by_key(|row| row.seq);
1274        let total = rows.len();
1275        let mut done = 0;
1276        for row in &rows {
1277            // After the lease, another relay can own the rest of the batch.
1278            // On shutdown, the next process sends it.
1279            let remaining = lease_end.saturating_sub(to_millis(state.clock().now()));
1280            if remaining <= 0 || shutdown.is_some_and(CancellationToken::is_cancelled) {
1281                break;
1282            }
1283            let budget = Duration::from_millis(u64::try_from(remaining).unwrap_or(0));
1284            handle_row(relay, pool, state, &claim_token, row, budget).await;
1285            done += 1;
1286        }
1287        handled += done;
1288        if done < total {
1289            let unhandled: Vec<i64> = rows[done..].iter().map(|row| row.seq).collect();
1290            release(pool, &claim_token, &unhandled).await;
1291            break;
1292        }
1293    }
1294    Ok(handled)
1295}
1296
1297async fn claim(
1298    relay: &OutboxRelay,
1299    pool: &Pool<RuntimeConnection>,
1300    state: &AppState,
1301    claim_token: &str,
1302    batch: usize,
1303) -> AutumnResult<Vec<ClaimedRow>> {
1304    let now = to_millis(state.clock().now());
1305    let lease = duration_millis(Duration::from_millis(relay.config.lease_ms));
1306    let mut conn = pool.get().await.map_err(|error| {
1307        AutumnError::service_unavailable_msg(format!("outbox claim: no connection: {error}"))
1308    })?;
1309    diesel::sql_query(claim_sql(&relay.topics_in_list))
1310        .bind::<Text, _>(claim_token)
1311        .bind::<BigInt, _>(now.saturating_add(lease))
1312        .bind::<BigInt, _>(now)
1313        .bind::<BigInt, _>(now)
1314        .bind::<BigInt, _>(i64::try_from(batch).unwrap_or(i64::MAX))
1315        .load::<ClaimedRow>(&mut conn)
1316        .await
1317        .map_err(|error| sql_error("outbox claim", &error))
1318}
1319
1320/// Give back the unhandled rows `seqs` of a claim, so a relay can claim them
1321/// at once. On failure, they come back when the lease ends.
1322async fn release(pool: &Pool<RuntimeConnection>, claim_token: &str, seqs: &[i64]) {
1323    if seqs.is_empty() {
1324        return;
1325    }
1326    // `seq` values are integers from the claim, so the list is safe SQL text.
1327    let list = seqs
1328        .iter()
1329        .map(i64::to_string)
1330        .collect::<Vec<_>>()
1331        .join(", ");
1332    let statement = format!("{} ({list})", sql(RELEASE_SQL));
1333    let released = match pool.get().await {
1334        Ok(mut conn) => diesel::sql_query(statement)
1335            .bind::<Text, _>(claim_token)
1336            .execute(&mut conn)
1337            .await
1338            .map_err(|error| sql_error("outbox release", &error)),
1339        Err(error) => Err(AutumnError::service_unavailable_msg(format!(
1340            "outbox release: no connection: {error}"
1341        ))),
1342    };
1343    if let Err(error) = released {
1344        tracing::warn!(%error, "outbox relay could not release its claim; the lease ends it");
1345    }
1346}
1347
1348async fn handle_row(
1349    relay: &OutboxRelay,
1350    pool: &Pool<RuntimeConnection>,
1351    state: &AppState,
1352    claim_token: &str,
1353    row: &ClaimedRow,
1354    budget: Duration,
1355) {
1356    let message = row.message();
1357    if message.attempt > relay.config.max_attempts {
1358        // Claims that a crash or an expired lease ended used the attempts.
1359        // This claim is not an attempt: keep the count at the limit.
1360        let recorded = dead_letter_exhausted(relay, pool, state, claim_token, row.seq).await;
1361        tracing::error!(
1362            message_id = %message.id,
1363            topic = %message.topic,
1364            "outbox message used its attempts in crashes or expired leases; moved to dead letters"
1365        );
1366        if let Err(error) = recorded {
1367            tracing::warn!(message_id = %message.id, %error, "outbox relay could not record the dead letter");
1368        }
1369        return;
1370    }
1371    let outcome = run_handler(relay, state, &message, budget).await;
1372    let recorded = match outcome {
1373        Ok(()) => mark_sent(pool, state, claim_token, row.seq).await,
1374        Err(error) => {
1375            let attempt = message.attempt;
1376            let dead = attempt >= relay.config.max_attempts;
1377            if dead {
1378                tracing::error!(
1379                    message_id = %message.id,
1380                    topic = %message.topic,
1381                    attempt,
1382                    %error,
1383                    "outbox message failed its last attempt; moved to dead letters"
1384                );
1385            } else {
1386                tracing::warn!(
1387                    message_id = %message.id,
1388                    topic = %message.topic,
1389                    attempt,
1390                    %error,
1391                    "outbox message failed; the relay sends it again"
1392                );
1393            }
1394            nack(
1395                relay,
1396                pool,
1397                state,
1398                claim_token,
1399                row.seq,
1400                attempt,
1401                dead,
1402                &error,
1403            )
1404            .await
1405        }
1406    };
1407    if let Err(error) = recorded {
1408        tracing::warn!(
1409            message_id = %message.id,
1410            %error,
1411            "outbox relay could not record the outcome; the message is sent again after the lease"
1412        );
1413    }
1414}
1415
1416/// Run the handler of `message`. A panic, or a handler that runs past the
1417/// lease (`budget`), is a failure.
1418async fn run_handler(
1419    relay: &OutboxRelay,
1420    state: &AppState,
1421    message: &OutboxMessage,
1422    budget: Duration,
1423) -> AutumnResult<()> {
1424    let Some(handler) = relay.handlers.get(&message.topic) else {
1425        return Err(AutumnError::internal_server_error_msg(format!(
1426            "no outbox handler for topic {}",
1427            message.topic
1428        )));
1429    };
1430    // A handler can panic when it makes its future, before the first poll.
1431    let Ok(future) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1432        handler(state.clone(), message.clone())
1433    })) else {
1434        return Err(AutumnError::internal_server_error_msg(
1435            "outbox handler panicked",
1436        ));
1437    };
1438    let future = std::panic::AssertUnwindSafe(future).catch_unwind();
1439    let run = CURRENT_MESSAGE_ID.scope(message.id.clone(), future);
1440    match tokio::time::timeout(budget, run).await {
1441        Ok(Ok(result)) => result,
1442        Ok(Err(_)) => Err(AutumnError::internal_server_error_msg(
1443            "outbox handler panicked",
1444        )),
1445        Err(_) => Err(AutumnError::internal_server_error_msg(
1446            "outbox handler ran past the lease",
1447        )),
1448    }
1449}
1450
1451async fn mark_sent(
1452    pool: &Pool<RuntimeConnection>,
1453    state: &AppState,
1454    claim_token: &str,
1455    seq: i64,
1456) -> AutumnResult<()> {
1457    let mut conn = pool.get().await.map_err(|error| {
1458        AutumnError::service_unavailable_msg(format!("outbox mark: no connection: {error}"))
1459    })?;
1460    diesel::sql_query(sql(MARK_SENT_SQL))
1461        .bind::<BigInt, _>(to_millis(state.clock().now()))
1462        .bind::<BigInt, _>(seq)
1463        .bind::<Text, _>(claim_token)
1464        .execute(&mut conn)
1465        .await
1466        .map_err(|error| sql_error("outbox mark", &error))?;
1467    Ok(())
1468}
1469
1470async fn dead_letter_exhausted(
1471    relay: &OutboxRelay,
1472    pool: &Pool<RuntimeConnection>,
1473    state: &AppState,
1474    claim_token: &str,
1475    seq: i64,
1476) -> AutumnResult<()> {
1477    let mut conn = pool.get().await.map_err(|error| {
1478        AutumnError::service_unavailable_msg(format!("outbox dead letter: no connection: {error}"))
1479    })?;
1480    diesel::sql_query(sql(EXHAUSTED_SQL))
1481        .bind::<Integer, _>(i32::try_from(relay.config.max_attempts).unwrap_or(i32::MAX))
1482        .bind::<Text, _>("no attempts left: earlier attempts ended in a crash or an expired lease")
1483        .bind::<BigInt, _>(to_millis(state.clock().now()))
1484        .bind::<BigInt, _>(seq)
1485        .bind::<Text, _>(claim_token)
1486        .execute(&mut conn)
1487        .await
1488        .map_err(|error| sql_error("outbox dead letter", &error))?;
1489    Ok(())
1490}
1491
1492#[allow(clippy::too_many_arguments)]
1493async fn nack(
1494    relay: &OutboxRelay,
1495    pool: &Pool<RuntimeConnection>,
1496    state: &AppState,
1497    claim_token: &str,
1498    seq: i64,
1499    attempt: u32,
1500    dead: bool,
1501    error: &AutumnError,
1502) -> AutumnResult<()> {
1503    let now = to_millis(state.clock().now());
1504    let delay = retry_delay_ms(&relay.config, attempt, state.entropy());
1505    let mut conn = pool.get().await.map_err(|error| {
1506        AutumnError::service_unavailable_msg(format!("outbox nack: no connection: {error}"))
1507    })?;
1508    diesel::sql_query(sql(NACK_SQL))
1509        .bind::<Text, _>(error.to_string())
1510        .bind::<BigInt, _>(now.saturating_add(i64::try_from(delay).unwrap_or(i64::MAX)))
1511        .bind::<Nullable<BigInt>, _>(dead.then_some(now))
1512        .bind::<BigInt, _>(seq)
1513        .bind::<Text, _>(claim_token)
1514        .execute(&mut conn)
1515        .await
1516        .map_err(|error| sql_error("outbox nack", &error))?;
1517    Ok(())
1518}
1519
1520/// Delay before attempt `attempt + 1`: exponential, capped, with equal
1521/// jitter (a random value in `[delay / 2, delay]`).
1522fn retry_delay_ms(config: &OutboxConfig, attempt: u32, entropy: &dyn Entropy) -> u64 {
1523    let exponent = attempt.saturating_sub(1).min(32);
1524    let delay = config
1525        .initial_backoff_ms
1526        .saturating_mul(1_u64 << exponent)
1527        .min(config.max_backoff_ms);
1528    let half = delay / 2;
1529    half + entropy.next_u64() % (delay - half + 1)
1530}
1531
1532/// Delete sent messages and inbox entries older than `outbox.retention_ms`.
1533/// Returns the deleted (outbox, inbox) row counts. Dead letters stay.
1534///
1535/// The outbox cutoff uses the app clock. The inbox cutoff uses the ambient
1536/// clock, as [`Inbox::seen`] does.
1537///
1538/// # Errors
1539///
1540/// Returns an error when the relay is not installed or a delete fails.
1541pub async fn purge(state: &AppState) -> AutumnResult<(usize, usize)> {
1542    let relay = state
1543        .extension::<OutboxRelay>()
1544        .ok_or_else(|| AutumnError::internal_server_error_msg("outbox relay is not installed"))?;
1545    let retention = duration_millis(Duration::from_millis(relay.config.retention_ms));
1546    let outbox_cutoff = to_millis(state.clock().now()).saturating_sub(retention);
1547    let inbox_cutoff = to_millis(crate::time::ambient_now()).saturating_sub(retention);
1548    let pools = relay_pools(state)?;
1549    let (mut outbox, mut inbox) = (0, 0);
1550    let mut failures = Vec::new();
1551    for pool in &pools {
1552        match purge_pool(pool, outbox_cutoff, inbox_cutoff).await {
1553            Ok((o, i)) => {
1554                outbox += o;
1555                inbox += i;
1556            }
1557            Err(error) => {
1558                tracing::warn!(%error, "outbox purge failed on one pool; it goes on with the others");
1559                failures.push(error);
1560            }
1561        }
1562    }
1563    fail_if_every_pool_failed(failures, pools.len())?;
1564    Ok((outbox, inbox))
1565}
1566
1567async fn purge_pool(
1568    pool: &Pool<RuntimeConnection>,
1569    outbox_cutoff: i64,
1570    inbox_cutoff: i64,
1571) -> AutumnResult<(usize, usize)> {
1572    let mut conn = pool.get().await.map_err(|error| {
1573        AutumnError::service_unavailable_msg(format!("outbox purge: no connection: {error}"))
1574    })?;
1575    let outbox = diesel::sql_query(sql(PURGE_OUTBOX_SQL))
1576        .bind::<BigInt, _>(outbox_cutoff)
1577        .execute(&mut conn)
1578        .await
1579        .map_err(|error| sql_error("outbox purge", &error))?;
1580    let inbox = diesel::sql_query(sql(PURGE_INBOX_SQL))
1581        .bind::<BigInt, _>(inbox_cutoff)
1582        .execute(&mut conn)
1583        .await
1584        .map_err(|error| sql_error("inbox purge", &error))?;
1585    Ok((outbox, inbox))
1586}
1587
1588/// Run the relay until `shutdown`. It polls every `outbox.poll_interval_ms`,
1589/// and drains again at once after a full batch. On shutdown it stops between
1590/// messages and gives back the rest of its claim.
1591pub(crate) fn start_relay_worker(
1592    state: AppState,
1593    shutdown: CancellationToken,
1594) -> Option<tokio::task::JoinHandle<()>> {
1595    let relay = state.extension::<OutboxRelay>()?;
1596    let poll = Duration::from_millis(relay.config.poll_interval_ms.max(1));
1597    let batch = relay.config.batch_size.max(1);
1598    Some(tokio::spawn(async move {
1599        let mut last_purge: Option<crate::time::MonotonicInstant> = None;
1600        while !shutdown.is_cancelled() {
1601            let full = match drain_until(&state, batch, Some(&shutdown)).await {
1602                Ok(handled) => handled >= batch,
1603                Err(error) => {
1604                    tracing::warn!(%error, "outbox relay poll failed");
1605                    false
1606                }
1607            };
1608            let now = state.monotonic();
1609            if last_purge.is_none_or(|at| now.saturating_duration_since(at) >= PURGE_INTERVAL) {
1610                last_purge = Some(now);
1611                if let Err(error) = purge(&state).await {
1612                    tracing::warn!(%error, "outbox purge failed");
1613                }
1614            }
1615            if full {
1616                continue;
1617            }
1618            tokio::select! {
1619                () = shutdown.cancelled() => break,
1620                () = tokio::time::sleep(poll) => {}
1621            }
1622        }
1623    }))
1624}
1625
1626// ── Built-in topic handlers ──────────────────────────────────────────────────
1627
1628#[derive(Deserialize)]
1629struct JobBody {
1630    name: String,
1631    args: Value,
1632    /// The enqueuing tenant. Rows written before it was stored have none.
1633    #[serde(default)]
1634    tenant: Option<String>,
1635}
1636
1637pub(crate) async fn run_job_enqueue(state: AppState, message: OutboxMessage) -> AutumnResult<()> {
1638    let body: JobBody = message.payload_as()?;
1639    // The enqueue tenant only: it sets the job's lane and cost tenant, and
1640    // does not scope repository queries the way `CURRENT_TENANT` would.
1641    crate::cost::with_enqueue_tenant(body.tenant, async move {
1642        match state.extension::<crate::job::JobClient>() {
1643            Some(client) => client.enqueue(&body.name, body.args).await,
1644            None => crate::job::enqueue(&body.name, body.args).await,
1645        }
1646    })
1647    .await
1648}
1649
1650#[cfg(feature = "mail")]
1651async fn run_mail_delivery(state: AppState, message: OutboxMessage) -> AutumnResult<()> {
1652    let mail: crate::mail::Mail = message.payload_as()?;
1653    let mailer = state.extension::<crate::mail::Mailer>().ok_or_else(|| {
1654        AutumnError::internal_server_error_msg("outbox mail: no mailer installed")
1655    })?;
1656    mailer
1657        .send(mail)
1658        .await
1659        .map_err(|error| AutumnError::internal_server_error_msg(format!("outbox mail: {error}")))
1660}
1661
1662#[cfg(feature = "http-client")]
1663#[derive(Deserialize)]
1664struct WebhookBody {
1665    topic: String,
1666    payload: Value,
1667}
1668
1669#[cfg(feature = "http-client")]
1670async fn run_webhook_dispatch(state: AppState, message: OutboxMessage) -> AutumnResult<()> {
1671    let body: WebhookBody = message.payload_as()?;
1672    let manager = state
1673        .extension::<crate::webhook_outbound::WebhookOutboundManager>()
1674        .ok_or_else(|| {
1675            AutumnError::internal_server_error_msg("outbox webhook: no webhook manager installed")
1676        })?;
1677    manager
1678        .dispatch_for_message(&state, &message.id, &body.topic, &body.payload)
1679        .await
1680}
1681
1682// ── Mail queue ───────────────────────────────────────────────────────────────
1683
1684/// A [`MailDeliveryQueue`](crate::mail::MailDeliveryQueue) that writes each
1685/// mail to the outbox.
1686///
1687/// Installed when `outbox.enabled = true` and the app sets no queue. The row
1688/// is written on its own connection, after the commit of the caller. To write
1689/// it in the transaction, use [`Outbox::deliver_mail`].
1690#[cfg(feature = "mail")]
1691pub struct OutboxMailQueue {
1692    pool: Pool<RuntimeConnection>,
1693    entropy: Arc<dyn Entropy>,
1694    clock: Arc<dyn ClockSource>,
1695}
1696
1697#[cfg(feature = "mail")]
1698impl OutboxMailQueue {
1699    /// A queue that writes to the outbox of `state`.
1700    ///
1701    /// # Errors
1702    ///
1703    /// Returns an error when the outbox is off (no relay sends the mail) or
1704    /// `state` has no database.
1705    pub fn from_state(state: &AppState) -> AutumnResult<Self> {
1706        if state.extension::<OutboxRelay>().is_none() {
1707            return Err(AutumnError::internal_server_error_msg(
1708                "OutboxMailQueue needs the relay; set outbox.enabled = true",
1709            ));
1710        }
1711        let pool = mail_pool(state).ok_or_else(|| {
1712            AutumnError::internal_server_error_msg("OutboxMailQueue needs a database")
1713        })?;
1714        Ok(Self {
1715            pool,
1716            entropy: state.entropy_arc(),
1717            clock: state.clock_arc(),
1718        })
1719    }
1720}
1721
1722#[cfg(feature = "mail")]
1723impl crate::mail::MailDeliveryQueue for OutboxMailQueue {
1724    fn enqueue<'a>(
1725        &'a self,
1726        mail: crate::mail::Mail,
1727    ) -> Pin<Box<dyn Future<Output = Result<(), crate::mail::MailError>> + Send + 'a>> {
1728        Box::pin(async move {
1729            let queue_error = crate::mail::MailError::RuntimeUnavailable;
1730            let body =
1731                serde_json::to_value(&mail).map_err(|error| queue_error(error.to_string()))?;
1732            let mut conn = self
1733                .pool
1734                .get()
1735                .await
1736                .map_err(|error| queue_error(format!("outbox mail: no connection: {error}")))?;
1737            insert_message(
1738                &mut conn,
1739                self.entropy.as_ref(),
1740                self.clock.as_ref(),
1741                None,
1742                TOPIC_MAIL,
1743                &body,
1744            )
1745            .await
1746            .map(drop)
1747            .map_err(|error| queue_error(error.to_string()))
1748        })
1749    }
1750}
1751
1752/// Kani proofs over the relay retry delay. Run with
1753/// `cargo kani -p autumn-web --lib`.
1754#[cfg(kani)]
1755mod kani_proofs {
1756    use super::*;
1757
1758    #[derive(Debug)]
1759    struct AnyDraw(u64);
1760
1761    impl Entropy for AnyDraw {
1762        fn next_u64(&self) -> u64 {
1763            self.0
1764        }
1765        fn fill_bytes(&self, dest: &mut [u8]) {
1766            dest.fill(0);
1767        }
1768    }
1769
1770    /// Equal jitter: the delay is in `[ceiling / 2, ceiling]` with
1771    /// `ceiling = min(max, initial * 2^(attempt - 1))`, and the sum never
1772    /// overflows, for every config, attempt and draw.
1773    #[kani::proof]
1774    fn retry_delay_is_equal_jitter_without_overflow() {
1775        let config = OutboxConfig {
1776            initial_backoff_ms: kani::any(),
1777            max_backoff_ms: kani::any(),
1778            ..OutboxConfig::default()
1779        };
1780        let attempt: u32 = kani::any();
1781        let delay = retry_delay_ms(&config, attempt, &AnyDraw(kani::any()));
1782        let exponent = attempt.saturating_sub(1).min(32);
1783        let ceiling = config
1784            .initial_backoff_ms
1785            .saturating_mul(1_u64 << exponent)
1786            .min(config.max_backoff_ms);
1787        assert!(delay <= ceiling);
1788        assert!(delay >= ceiling / 2);
1789    }
1790}
1791
1792#[cfg(test)]
1793mod tests;