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 unqualified name `outbox` does not resolve, or resolves to a different schema than
39    /// configured. Carries the configured schema and the observed `search_path`; the `ALTER
40    /// ROLE` remedy is in the `Display` text. **Permanent.**
41    SchemaNotOnSearchPath {
42        /// The schema `PostgresOutboxSettings::schema` named.
43        configured: String,
44        /// The `search_path` Postgres reported at construction.
45        observed: String,
46    },
47
48    /// `outbox` resolved to the configured schema, but the relation itself is missing —
49    /// `migrate()` has not been run. **Permanent.** Mapped from SQLSTATE `42P01` on **every**
50    /// path, not just startup verification.
51    NotMigrated {
52        /// The configured schema.
53        schema: String,
54    },
55
56    /// `outbox` resolved to the configured schema and the relation exists, but it has not
57    /// finished a required migration — `missing` names the first column that is either absent or
58    /// present but still nullable (a **completion marker**, not a bare inventory check: `id`
59    /// exists from migration `0005` onward but stays nullable until `0010`'s `SET NOT NULL`, so a
60    /// schema stuck anywhere in `0005`–`0009` is reported the same as one stuck at `0004`).
61    /// Checked at [`crate::PostgresOutboxStore::connect`], **after** the `search_path`
62    /// verification above and only once the relation is confirmed to exist there: a wrong
63    /// `search_path` or a missing relation each already has its own variant, so this one means
64    /// specifically "the right table, an old shape" (ADR 0044 Amendment A.4, marker corrected by
65    /// Amendment A.5) — today, `message_id` or `id` (migrations `0005`–`0010`). The remedy is
66    /// `migrate(&pool, ..)`, never a `search_path` fix. **Permanent** — the column will not
67    /// satisfy itself.
68    SchemaOutOfDate {
69        /// The configured schema.
70        schema: String,
71        /// The first required column this build did not find satisfied (absent, or present but
72        /// still nullable) on the resolved relation.
73        missing: &'static str,
74    },
75
76    /// Connection lost, statement timeout, pool exhausted, deadlock, or any other `sqlx`
77    /// failure not mapped to a more specific variant above. Classified by the wrapped
78    /// SQLSTATE's **class** (never blanket-transient — see the `Classify` impl below).
79    Database {
80        /// The underlying `sqlx` error.
81        source: sqlx::Error,
82    },
83
84    /// A claimed or listed row could not be turned into an `OutboxRecord` (a corrupt JSONB
85    /// remainder, an unparseable promoted column). Surfaces as a poisoned row, never as an
86    /// `acquire`/`list_dead` failure. **Permanent** — the bytes on disk do not change between
87    /// attempts. Carries both ids (ADR 0044 A.2) — `id` and `message_id` are plain `uuid` columns
88    /// and are always readable even when the envelope columns that failed to decode are not.
89    Decode {
90        /// The row's own identity.
91        id: OutboxRecordId,
92        /// The row's message id.
93        message_id: MessageId,
94        /// A short, payload-free description of what failed to decode.
95        detail: String,
96    },
97
98    /// The row's `metadata_version` is not one this build knows how to read. **Permanent** —
99    /// it needs a newer reader, not another try. Carries both ids, see [`Self::Decode`].
100    UnknownMetadataVersion {
101        /// The row's own identity.
102        id: OutboxRecordId,
103        /// The row's message id.
104        message_id: MessageId,
105        /// The unrecognised version.
106        version: i32,
107    },
108
109    /// `enqueue` inserted a `MessageId` that already exists (`ix_outbox_message_id` violation,
110    /// ADR 0044 §1). **Permanent** — a reused id never succeeds on retry; the row is already
111    /// there. Unlike [`Self::Decode`]/[`Self::UnknownMetadataVersion`] this carries only the
112    /// message id: the caller already knows it, and the row it collided with is not this call's
113    /// concern (ADR 0044 A.2 — a `pk_outbox` collision, a *record*-id repeat, is a different,
114    /// non-caller error and stays `Database`).
115    DuplicateMessage {
116        /// The id the caller tried to reuse.
117        id: MessageId,
118    },
119
120    /// `PostgresOutboxSettings::schema` or `MigrateOptions::schema` is not a valid PostgreSQL
121    /// identifier (`[a-z_][a-z0-9_$]*`, at most 63 bytes, **lowercase only**) — checked once,
122    /// before it is ever interpolated into `SET search_path`/`dangerous_set_table_name`.
123    /// Lowercase-only rather than merely case-insensitive: PostgreSQL folds an
124    /// *unquoted* identifier to lowercase, so an uppercase configured name and the schema it
125    /// actually resolves to would silently disagree unless every one of `migrate()`'s,
126    /// this crate's own schema check's and the host's own `search_path` configuration happened to
127    /// quote it the same way everywhere — rejecting it up front removes the whole class of
128    /// mismatch. **Permanent** — configuration, not weather.
129    InvalidSchema {
130        /// The rejected schema name.
131        schema: String,
132    },
133
134    /// The connected server's `server_version_num` is below [`crate::MIN_SERVER_VERSION_NUM`]
135    /// (PostgreSQL 18, ADR 0041) — **no older-version fallback**. Checked
136    /// at [`crate::PostgresOutboxStore::connect`], **before** the `search_path` verification
137    /// above: a wrong server version explains a missing relation, and the reverse is never
138    /// true. Carries no connection string, host, or credentials. **Permanent.**
139    UnsupportedServerVersion {
140        /// [`crate::MIN_SERVER_VERSION_NUM`], restated on the value so this variant is
141        /// self-describing without a second lookup.
142        required: u32,
143        /// The `server_version_num` this connection reported.
144        detected: u32,
145    },
146}
147
148impl fmt::Display for PostgresOutboxError {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Self::SchemaNotOnSearchPath {
152                configured,
153                observed,
154            } => write!(
155                f,
156                "outbox did not resolve to schema \"{configured}\" (observed search_path: \
157                 \"{observed}\"); set search_path so \"{configured}\" comes first, e.g. \
158                 ALTER ROLE <role> SET search_path = {configured}, public"
159            ),
160            Self::NotMigrated { schema } => write!(
161                f,
162                "relation \"{schema}.outbox\" does not exist; call \
163                 reliar_store_postgres::migrate(&pool, ..) before constructing the store"
164            ),
165            Self::SchemaOutOfDate { schema, missing } => write!(
166                f,
167                "relation \"{schema}.outbox\" does not satisfy required column \"{missing}\" \
168                 (absent, or present but still nullable — migrations 0005-0010 have not all \
169                 completed); run reliar_store_postgres::migrate(&pool, ..) before constructing \
170                 the store"
171            ),
172            Self::Database { source } => write!(f, "database error: {source}"),
173            Self::Decode {
174                id,
175                message_id,
176                detail,
177            } => write!(
178                f,
179                "row {id} (message {message_id}) could not be decoded: {detail}"
180            ),
181            Self::UnknownMetadataVersion {
182                id,
183                message_id,
184                version,
185            } => write!(
186                f,
187                "row {id} (message {message_id}) carries unknown metadata_version {version}"
188            ),
189            Self::DuplicateMessage { id } => {
190                write!(f, "message id {id} already exists in the outbox")
191            }
192            Self::InvalidSchema { schema } => write!(
193                f,
194                "{schema:?} is not a valid PostgreSQL identifier (expected \
195                 [a-z_][a-z0-9_$]*, at most 63 bytes, lowercase only — PostgreSQL folds an \
196                 unquoted identifier to lowercase, so an uppercase name would resolve \
197                 inconsistently)"
198            ),
199            Self::UnsupportedServerVersion { required, detected } => write!(
200                f,
201                "PostgreSQL 18 or newer is required (server_version_num >= {required}); \
202                 detected {detected} — there is no supported way to run Reliar below the floor"
203            ),
204        }
205    }
206}
207
208impl std::error::Error for PostgresOutboxError {
209    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
210        match self {
211            Self::Database { source } => Some(source),
212            _ => None,
213        }
214    }
215}
216
217/// Per-variant classification table — **no blanket "everything else is
218/// transient"**. A wrong verdict is not cosmetic: `Transient` burns the dispatcher's retry
219/// budget on a failure that can never succeed; `Permanent` kills a message that would have gone
220/// through on the next attempt.
221impl Classify for PostgresOutboxError {
222    fn kind(&self) -> FailureKind {
223        match self {
224            Self::SchemaNotOnSearchPath { .. }
225            | Self::NotMigrated { .. }
226            | Self::SchemaOutOfDate { .. }
227            | Self::InvalidSchema { .. }
228            | Self::DuplicateMessage { .. }
229            | Self::Decode { .. }
230            | Self::UnknownMetadataVersion { .. }
231            | Self::UnsupportedServerVersion { .. } => FailureKind::Permanent,
232            Self::Database { source } => classify_sqlstate(source),
233        }
234    }
235}
236
237impl From<sqlx::Error> for PostgresOutboxError {
238    fn from(source: sqlx::Error) -> Self {
239        Self::Database { source }
240    }
241}
242
243/// Maps a `sqlx::Error` to a typed error, keying on SQLSTATE and constraint **name** — never on
244/// message text — so `42P01` maps to `NotMigrated` **on every path**, not just startup
245/// verification, and everything else falls through to `Database` for
246/// [`classify_sqlstate`] to classify.
247pub(crate) fn map_operational_error(schema: &str, err: sqlx::Error) -> PostgresOutboxError {
248    if is_undefined_table(&err) {
249        return PostgresOutboxError::NotMigrated {
250            schema: schema.to_owned(),
251        };
252    }
253
254    PostgresOutboxError::Database { source: err }
255}
256
257/// [`crate::PostgresOutboxStore`]'s [`reliar_outbox::OutboxEnqueue::enqueue_envelope`] failures. Enqueuing
258/// runs on the **host's** write path, where the host decides whether to retry its own
259/// transaction, so this implements [`Classify`] on the same rules as [`PostgresOutboxError`]
260/// rather than making the host re-derive which SQLSTATEs are worth retrying.
261///
262/// A duplicate [`reliar_core::MessageId`] aborts the caller's transaction rather than silently
263/// losing the message. The bare `PostgresOutboxStore` below leans on its default type
264/// parameter, gated on the default `json` feature; without it this block still shows the shape
265/// but is not compiled.
266#[cfg_attr(not(feature = "json"), doc = "```ignore")]
267#[cfg_attr(feature = "json", doc = "```no_run")]
268/// # async fn run(
269/// #     store: reliar_store_postgres::PostgresOutboxStore,
270/// #     pool: sqlx::PgPool,
271/// # ) -> Result<(), Box<dyn std::error::Error>> {
272/// use reliar_core::{Classify, Message};
273/// use reliar_outbox::OutboxEnqueue;
274///
275/// #[derive(serde::Serialize, serde::Deserialize)]
276/// struct OrderPlaced;
277/// impl Message for OrderPlaced {
278///     const TYPE: &'static str = "orders.placed";
279///     const VERSION: u16 = 1;
280/// }
281///
282/// let mut tx = pool.begin().await?;
283/// if let Err(err) = store.enqueue(&mut tx, OrderPlaced).await {
284///     eprintln!("enqueue failed ({:?}): {err}", err.kind());
285///     tx.rollback().await?;
286/// }
287/// # Ok(())
288/// # }
289/// ```
290#[derive(Debug)]
291#[non_exhaustive]
292pub enum EnqueueError<E> {
293    /// The configured [`reliar_core::Serializer`] rejected the body. **Permanent** — the same
294    /// body serializes the same way every time.
295    Serialize {
296        /// The serializer's own error.
297        source: E,
298    },
299
300    /// The envelope's `MessageId` already exists (`ix_outbox_message_id` violation, ADR 0044
301    /// §1) — `enqueue` uses a plain `INSERT` with no `ON CONFLICT`, so a reused id aborts the
302    /// caller's transaction rather than silently losing a message. **Permanent** — the id is
303    /// already taken.
304    Duplicate {
305        /// The id the caller tried to reuse.
306        id: MessageId,
307    },
308
309    /// Any other `sqlx` failure, classified by SQLSTATE exactly as
310    /// [`PostgresOutboxError::Database`] (including `42P01`, which classifies permanent under
311    /// the `42*` rule).
312    Database {
313        /// The underlying `sqlx` error.
314        source: sqlx::Error,
315    },
316}
317
318impl<E: fmt::Display> fmt::Display for EnqueueError<E> {
319    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320        match self {
321            Self::Serialize { source } => {
322                write!(f, "failed to serialize the envelope body: {source}")
323            }
324            Self::Duplicate { id } => write!(f, "message id {id} already exists in the outbox"),
325            Self::Database { source } => write!(f, "database error: {source}"),
326        }
327    }
328}
329
330impl<E: std::error::Error + 'static> std::error::Error for EnqueueError<E> {
331    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
332        match self {
333            Self::Serialize { source } => Some(source),
334            Self::Database { source } => Some(source),
335            Self::Duplicate { .. } => None,
336        }
337    }
338}
339
340impl<E: std::error::Error + Send + Sync + 'static> Classify for EnqueueError<E> {
341    fn kind(&self) -> FailureKind {
342        match self {
343            Self::Serialize { .. } | Self::Duplicate { .. } => FailureKind::Permanent,
344            Self::Database { source } => classify_sqlstate(source),
345        }
346    }
347}
348
349/// Maps a `sqlx::Error` from an `enqueue` `INSERT` to a typed error, keying on the constraint
350/// **name** — never on message text — so `ix_outbox_message_id` maps to `Duplicate` (ADR 0044
351/// §1: `enqueue` never binds `id`, so the only conflict an `INSERT` can hit is a reused
352/// `message_id`) and every other failure (including `42P01`) stays `Database`, for
353/// [`classify_sqlstate`] to classify.
354pub(crate) fn map_enqueue_error<E>(id: MessageId, err: sqlx::Error) -> EnqueueError<E> {
355    if is_constraint_violation(&err, "ix_outbox_message_id") {
356        return EnqueueError::Duplicate { id };
357    }
358
359    EnqueueError::Database { source: err }
360}
361
362/// `true` when `err` is a unique/check-constraint violation on `constraint`. Keys on the
363/// **name**, never on message text — that naming discipline is what keeps this map stable
364/// across PostgreSQL versions.
365pub(crate) fn is_constraint_violation(err: &sqlx::Error, constraint: &str) -> bool {
366    match err {
367        sqlx::Error::Database(db) => db.constraint() == Some(constraint),
368        _ => false,
369    }
370}