Skip to main content

reliar_store_postgres/outbox/
error.rs

1//! Hand-rolled error enums for the outbox side of the PostgreSQL provider (ADR 0008).
2//!
3//! No `thiserror`, no `anyhow`. Every `Display` is payload/credential-free: a decode failure
4//! names the message id and a truncated detail, never the offending bytes. Classification is a
5//! **per-variant table, never a blanket rule** — a `Database` failure is classified by the
6//! wrapped SQLSTATE's class, not assumed transient.
7
8use core::fmt;
9
10use reliar_core::{Classify, FailureKind, MessageId};
11use reliar_outbox::OutboxRecordId;
12
13use crate::error::{classify_sqlstate, is_undefined_table};
14
15/// A failure of a [`crate::PostgresOutboxStore`] `OutboxStore`/`OutboxDeadLetters` *call* —
16/// never a property of one row's content. Row-content problems surface as
17/// [`reliar_outbox::PoisonedRow`]s instead (ADR 0008).
18///
19/// [`Classify`] tells a dispatcher whether a failed call is worth retrying. The bare
20/// `PostgresOutboxStore` below leans on its default type parameter, gated on the default
21/// `json` feature; without it this block still shows the shape but is not compiled.
22#[cfg_attr(not(feature = "json"), doc = "```ignore")]
23#[cfg_attr(feature = "json", doc = "```no_run")]
24/// # async fn run(store: reliar_store_postgres::PostgresOutboxStore) -> Result<(), Box<dyn std::error::Error>> {
25/// use reliar_core::Classify;
26/// use reliar_outbox::{AcquireRequest, OutboxStore, WorkerId};
27///
28/// let request = AcquireRequest::new(WorkerId::generate());
29/// if let Err(err) = store.acquire(request).await {
30///     eprintln!("acquire failed ({:?}): {err}", err.kind());
31/// }
32/// # Ok(())
33/// # }
34/// ```
35#[derive(Debug)]
36#[non_exhaustive]
37pub enum PostgresOutboxError {
38    /// The relation does not resolve on this connection's `search_path` (SQLSTATE `42P01`).
39    /// Either `migrate()` has not run, or the connection's `search_path` does not resolve the
40    /// unqualified name `outbox` to the migrated schema. **Permanent** — the table does not
41    /// appear on its own. Reliar does not check this at construction (ADR 0047); this is the
42    /// first statement reporting it, with PostgreSQL's own message attached as the `source`.
43    /// Mapped from SQLSTATE `42P01` on **every** call.
44    NotMigrated {
45        /// The underlying `42P01` error, returned from [`std::error::Error::source`].
46        source: sqlx::Error,
47    },
48
49    /// Connection lost, statement timeout, pool exhausted, deadlock, or any other `sqlx`
50    /// failure not mapped to a more specific variant above. Classified by the wrapped
51    /// SQLSTATE's **class** (never blanket-transient — see the `Classify` impl below).
52    Database {
53        /// The underlying `sqlx` error.
54        source: sqlx::Error,
55    },
56
57    /// A claimed or listed row could not be turned into an `OutboxRecord` (a corrupt JSONB
58    /// remainder, an unparseable promoted column). Surfaces as a poisoned row, never as an
59    /// `acquire`/`list_dead` failure. **Permanent** — the bytes on disk do not change between
60    /// attempts. Carries both ids (ADR 0044 A.2) — `id` and `message_id` are plain `uuid` columns
61    /// and are always readable even when the envelope columns that failed to decode are not.
62    Decode {
63        /// The row's own identity.
64        id: OutboxRecordId,
65        /// The row's message id.
66        message_id: MessageId,
67        /// A short, payload-free description of what failed to decode.
68        detail: String,
69    },
70
71    /// The row's `metadata_version` is not one this build knows how to read. **Permanent** —
72    /// it needs a newer reader, not another try. Carries both ids, see [`Self::Decode`].
73    UnknownMetadataVersion {
74        /// The row's own identity.
75        id: OutboxRecordId,
76        /// The row's message id.
77        message_id: MessageId,
78        /// The unrecognised version.
79        version: i32,
80    },
81
82    /// `enqueue` inserted a `MessageId` that already exists (`ix_outbox_message_id` violation,
83    /// ADR 0044 §1). **Permanent** — a reused id never succeeds on retry; the row is already
84    /// there. Unlike [`Self::Decode`]/[`Self::UnknownMetadataVersion`] this carries only the
85    /// message id: the caller already knows it, and the row it collided with is not this call's
86    /// concern (ADR 0044 A.2 — a `pk_outbox` collision, a *record*-id repeat, is a different,
87    /// non-caller error and stays `Database`).
88    DuplicateMessage {
89        /// The id the caller tried to reuse.
90        id: MessageId,
91    },
92}
93
94impl fmt::Display for PostgresOutboxError {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::NotMigrated { source } => write!(
98                f,
99                "the outbox table does not resolve on this connection's search_path: {source}; \
100                 run reliar_store_postgres::migrate(&pool, ..) and put the migrated schema first \
101                 on search_path — in the connection URL \
102                 (options=-c search_path=reliar,public) or with ALTER ROLE <role> SET \
103                 search_path = reliar, public"
104            ),
105            Self::Database { source } => write!(f, "database error: {source}"),
106            Self::Decode {
107                id,
108                message_id,
109                detail,
110            } => write!(
111                f,
112                "row {id} (message {message_id}) could not be decoded: {detail}"
113            ),
114            Self::UnknownMetadataVersion {
115                id,
116                message_id,
117                version,
118            } => write!(
119                f,
120                "row {id} (message {message_id}) carries unknown metadata_version {version}"
121            ),
122            Self::DuplicateMessage { id } => {
123                write!(f, "message id {id} already exists in the outbox")
124            }
125        }
126    }
127}
128
129impl std::error::Error for PostgresOutboxError {
130    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
131        match self {
132            Self::NotMigrated { source } | Self::Database { source } => Some(source),
133            Self::Decode { .. }
134            | Self::UnknownMetadataVersion { .. }
135            | Self::DuplicateMessage { .. } => None,
136        }
137    }
138}
139
140/// Per-variant classification table — **no blanket "everything else is
141/// transient"**. A wrong verdict is not cosmetic: `Transient` burns the dispatcher's retry
142/// budget on a failure that can never succeed; `Permanent` kills a message that would have gone
143/// through on the next attempt.
144impl Classify for PostgresOutboxError {
145    fn kind(&self) -> FailureKind {
146        match self {
147            Self::NotMigrated { .. }
148            | Self::DuplicateMessage { .. }
149            | Self::Decode { .. }
150            | Self::UnknownMetadataVersion { .. } => FailureKind::Permanent,
151            Self::Database { source } => classify_sqlstate(source),
152        }
153    }
154}
155
156impl From<sqlx::Error> for PostgresOutboxError {
157    fn from(source: sqlx::Error) -> Self {
158        map_operational_error(source)
159    }
160}
161
162/// Maps a `sqlx::Error` to a typed error, keying on SQLSTATE alone — never on message text or a
163/// constraint name — so `42P01` maps to `NotMigrated` **on every path**, and everything else falls
164/// through to `Database` for [`classify_sqlstate`] to classify.
165pub(crate) fn map_operational_error(err: sqlx::Error) -> PostgresOutboxError {
166    if is_undefined_table(&err) {
167        return PostgresOutboxError::NotMigrated { source: err };
168    }
169
170    PostgresOutboxError::Database { source: err }
171}
172
173impl crate::error::FromDatabaseError for PostgresOutboxError {
174    fn from_database_error(err: sqlx::Error) -> Self {
175        map_operational_error(err)
176    }
177}
178
179/// [`crate::PostgresOutboxStore`]'s [`reliar_outbox::OutboxEnqueue::enqueue_envelope`] failures. Enqueuing
180/// runs on the **host's** write path, where the host decides whether to retry its own
181/// transaction, so this implements [`Classify`] on the same rules as [`PostgresOutboxError`]
182/// rather than making the host re-derive which SQLSTATEs are worth retrying.
183///
184/// A duplicate [`reliar_core::MessageId`] aborts the caller's transaction rather than silently
185/// losing the message. The bare `PostgresOutboxStore` below leans on its default type
186/// parameter, gated on the default `json` feature; without it this block still shows the shape
187/// but is not compiled.
188#[cfg_attr(not(feature = "json"), doc = "```ignore")]
189#[cfg_attr(feature = "json", doc = "```no_run")]
190/// # async fn run(
191/// #     store: reliar_store_postgres::PostgresOutboxStore,
192/// #     pool: sqlx::PgPool,
193/// # ) -> Result<(), Box<dyn std::error::Error>> {
194/// use reliar_core::{Classify, Message};
195/// use reliar_outbox::OutboxEnqueue;
196///
197/// #[derive(serde::Serialize, serde::Deserialize)]
198/// struct OrderPlaced;
199/// impl Message for OrderPlaced {
200///     const TYPE: &'static str = "orders.placed";
201///     const VERSION: u16 = 1;
202/// }
203///
204/// let mut tx = pool.begin().await?;
205/// if let Err(err) = store.enqueue(&mut tx, OrderPlaced).await {
206///     eprintln!("enqueue failed ({:?}): {err}", err.kind());
207///     tx.rollback().await?;
208/// }
209/// # Ok(())
210/// # }
211/// ```
212#[derive(Debug)]
213#[non_exhaustive]
214pub enum EnqueueError<E> {
215    /// The configured [`reliar_core::Serializer`] rejected the body. **Permanent** — the same
216    /// body serializes the same way every time.
217    Serialize {
218        /// The serializer's own error.
219        source: E,
220    },
221
222    /// The envelope's `MessageId` already exists (`ix_outbox_message_id` violation, ADR 0044
223    /// §1) — `enqueue` uses a plain `INSERT` with no `ON CONFLICT`, so a reused id aborts the
224    /// caller's transaction rather than silently losing a message. **Permanent** — the id is
225    /// already taken.
226    Duplicate {
227        /// The id the caller tried to reuse.
228        id: MessageId,
229    },
230
231    /// Any other `sqlx` failure, classified by SQLSTATE exactly as
232    /// [`PostgresOutboxError::Database`] (including `42P01`, which classifies permanent under
233    /// the `42*` rule).
234    Database {
235        /// The underlying `sqlx` error.
236        source: sqlx::Error,
237    },
238}
239
240impl<E: fmt::Display> fmt::Display for EnqueueError<E> {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match self {
243            Self::Serialize { source } => {
244                write!(f, "failed to serialize the envelope body: {source}")
245            }
246            Self::Duplicate { id } => write!(f, "message id {id} already exists in the outbox"),
247            Self::Database { source } => write!(f, "database error: {source}"),
248        }
249    }
250}
251
252impl<E: std::error::Error + 'static> std::error::Error for EnqueueError<E> {
253    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
254        match self {
255            Self::Serialize { source } => Some(source),
256            Self::Database { source } => Some(source),
257            Self::Duplicate { .. } => None,
258        }
259    }
260}
261
262impl<E: std::error::Error + Send + Sync + 'static> Classify for EnqueueError<E> {
263    fn kind(&self) -> FailureKind {
264        match self {
265            Self::Serialize { .. } | Self::Duplicate { .. } => FailureKind::Permanent,
266            Self::Database { source } => classify_sqlstate(source),
267        }
268    }
269}
270
271/// Maps a `sqlx::Error` from an `enqueue` `INSERT` to a typed error, keying on the constraint
272/// **name** — never on message text — so `ix_outbox_message_id` maps to `Duplicate` (ADR 0044
273/// §1: `enqueue` never binds `id`, so the only conflict an `INSERT` can hit is a reused
274/// `message_id`) and every other failure (including `42P01`) stays `Database`, for
275/// [`classify_sqlstate`] to classify.
276pub(crate) fn map_enqueue_error<E>(id: MessageId, err: sqlx::Error) -> EnqueueError<E> {
277    if is_constraint_violation(&err, "ix_outbox_message_id") {
278        return EnqueueError::Duplicate { id };
279    }
280
281    EnqueueError::Database { source: err }
282}
283
284/// `true` when `err` is a unique/check-constraint violation on `constraint`. Keys on the
285/// **name**, never on message text — that naming discipline is what keeps this map stable
286/// across PostgreSQL versions.
287pub(crate) fn is_constraint_violation(err: &sqlx::Error, constraint: &str) -> bool {
288    match err {
289        sqlx::Error::Database(db) => db.constraint() == Some(constraint),
290        _ => false,
291    }
292}