reliar_store_postgres/inbox/error.rs
1//! Hand-rolled error enum for the inbox side of the PostgreSQL provider (ADR 0008, inbox contract
2//! §3).
3
4use core::fmt;
5
6use reliar_core::{Classify, FailureKind, MessageId};
7
8use crate::error::classify_sqlstate;
9
10/// A failure of a [`crate::PostgresInboxStore`] call (inbox contract §3). Deliberately smaller
11/// than [`crate::PostgresOutboxError`]: the inbox has no `enqueue`/`acquire` analog, so it needs
12/// no `Decode`/`UnknownMetadataVersion`/`DuplicateMessage` variant, and no `InvalidSchema` — every
13/// schema value this type ever sees already passed the same identifier validation at `migrate()`
14/// (the inbox shares the outbox's schema and its single `migrate()` entry point, inbox contract §1),
15/// and `claim`/`complete` bind it only as `set_config` *data*, never interpolate it into DDL.
16#[derive(Debug)]
17#[non_exhaustive]
18pub enum PostgresInboxError {
19 /// The connected server's `server_version_num` is below [`crate::MIN_SERVER_VERSION_NUM`]
20 /// (ADR 0041). Checked at [`crate::PostgresInboxStore::connect`], before the `search_path`
21 /// verification below — the same ordering
22 /// [`crate::PostgresOutboxError::UnsupportedServerVersion`] uses and for the same reason.
23 /// **Permanent.**
24 UnsupportedServerVersion {
25 /// [`crate::MIN_SERVER_VERSION_NUM`], restated on the value.
26 required: u32,
27 /// The `server_version_num` this connection reported.
28 detected: u32,
29 },
30
31 /// `inbox` resolved to the configured schema, but the relation itself is missing —
32 /// `migrate()` has not been run. **Permanent.** Checked before
33 /// [`Self::SchemaNotOnSearchPath`] below at [`crate::PostgresInboxStore::connect`], exactly as
34 /// [`crate::PostgresOutboxError::NotMigrated`] is checked before
35 /// `crate::PostgresOutboxError::SchemaNotOnSearchPath` — a missing relation reported as a
36 /// `search_path` problem would send an operator chasing the wrong fix.
37 NotMigrated {
38 /// The configured schema.
39 schema: String,
40 },
41
42 /// The unqualified name `inbox` does not resolve to the configured schema and the relation
43 /// exists somewhere reachable — `search_path` puts a different schema first. Carries the
44 /// configured schema and the observed `search_path`; the `ALTER ROLE` remedy is in the
45 /// `Display` text. **Permanent.**
46 SchemaNotOnSearchPath {
47 /// The schema [`crate::PostgresInboxSettings::schema`] named.
48 configured: String,
49 /// The `search_path` Postgres reported at construction.
50 observed: String,
51 },
52
53 /// [`crate::PostgresInboxStore`]'s `InboxStore::complete` matched zero rows — reachable only
54 /// by misuse (completing without a preceding `Claimed` claim, after the claiming
55 /// transaction aborted, or completing a row that has since gone dead — `complete`'s own
56 /// guard is `completed_at IS NULL AND dead_at IS NULL`, ADR 0042 A.2.4). **Permanent** —
57 /// retrying the same call changes nothing.
58 NotClaimed {
59 /// The scope the caller completed under.
60 scope: String,
61 /// The message id the caller tried to complete.
62 message_id: MessageId,
63 },
64
65 /// `PostgresInboxSettings::validate` rejected the settings — currently only
66 /// `max_attempts == 0` (ADR 0042 A.2.4). Checked at
67 /// [`crate::PostgresInboxStore::connect`], before any query runs. **Permanent.**
68 InvalidSettings {
69 /// A payload-free description of what was rejected.
70 message: String,
71 },
72
73 /// Any other `sqlx` failure, classified by SQLSTATE exactly as
74 /// [`crate::PostgresOutboxError::Database`].
75 Database {
76 /// The underlying `sqlx` error.
77 source: sqlx::Error,
78 },
79}
80
81impl fmt::Display for PostgresInboxError {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 Self::UnsupportedServerVersion { required, detected } => write!(
85 f,
86 "PostgreSQL 18 or newer is required (server_version_num >= {required}); \
87 detected {detected} — there is no supported way to run Reliar below the floor"
88 ),
89 Self::NotMigrated { schema } => write!(
90 f,
91 "relation \"{schema}.inbox\" does not exist; call \
92 reliar_store_postgres::migrate(&pool, ..) before constructing the store"
93 ),
94 Self::SchemaNotOnSearchPath {
95 configured,
96 observed,
97 } => write!(
98 f,
99 "inbox did not resolve to schema \"{configured}\" (observed search_path: \
100 \"{observed}\"); set search_path so \"{configured}\" comes first, e.g. \
101 ALTER ROLE <role> SET search_path = {configured}, public"
102 ),
103 Self::NotClaimed { scope, message_id } => write!(
104 f,
105 "no claimed inbox row for scope {scope:?}, message {message_id}; complete() may \
106 only follow a Claimed claim() in the same transaction, and never a dead one"
107 ),
108 Self::InvalidSettings { message } => {
109 write!(f, "invalid PostgresInboxSettings: {message}")
110 }
111 Self::Database { source } => write!(f, "database error: {source}"),
112 }
113 }
114}
115
116impl std::error::Error for PostgresInboxError {
117 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
118 match self {
119 Self::Database { source } => Some(source),
120 Self::UnsupportedServerVersion { .. }
121 | Self::NotMigrated { .. }
122 | Self::SchemaNotOnSearchPath { .. }
123 | Self::NotClaimed { .. }
124 | Self::InvalidSettings { .. } => None,
125 }
126 }
127}
128
129/// Per-variant classification table, exactly as [`crate::PostgresOutboxError`]'s: no blanket
130/// "everything else is transient".
131impl Classify for PostgresInboxError {
132 fn kind(&self) -> FailureKind {
133 match self {
134 Self::UnsupportedServerVersion { .. }
135 | Self::NotMigrated { .. }
136 | Self::SchemaNotOnSearchPath { .. }
137 | Self::NotClaimed { .. }
138 | Self::InvalidSettings { .. } => FailureKind::Permanent,
139 Self::Database { source } => classify_sqlstate(source),
140 }
141 }
142}
143
144impl From<sqlx::Error> for PostgresInboxError {
145 fn from(source: sqlx::Error) -> Self {
146 Self::Database { source }
147 }
148}