Skip to main content

reliar_store_postgres/outbox/
outbox_store.rs

1//! The [`PostgresOutboxStore`] type itself: fields, construction (`connect`/`new`/
2//! `with_settings`), the small shared helpers every other concern module calls back into
3//! (`map_err`, `set_local_timeout`), and the `OutboxStore` trait impl — which delegates each
4//! method's body to its concern module (`claim`, `outcomes`, `purge`) so this file states *what*
5//! the public surface is without also carrying every query.
6
7use std::sync::Arc;
8
9use reliar_core::{ContentType, Serializer};
10use reliar_outbox::{
11    AcquireRequest, AcquiredBatch, CompletedRecord, FailedRecord, OutboxStats, OutboxStore,
12    PurgeReport, PurgeRequest, RecordRef, WorkerId,
13};
14use sqlx::{PgPool, Postgres, Transaction};
15
16use crate::connection::schema;
17use crate::settings::PostgresOutboxSettings;
18
19#[cfg(feature = "json")]
20use reliar_core::JsonSerializer;
21
22use super::error::{self, PostgresOutboxError};
23use super::{claim, outcomes, purge};
24
25/// Columns [`PostgresOutboxStore::connect`] requires on the resolved `outbox` relation to be
26/// **present and `NOT NULL`** (ADR 0044 Amendment A.4, corrected by Amendment A.5) — this is a
27/// **completion marker for migrations `0005`–`0010`**, not a column inventory: `message_id` is
28/// absent before `0005`; `id` exists from `0005` onward but stays nullable until `0010`'s `SET
29/// NOT NULL`, so checking `id`'s mere existence would pass in the `0005`–`0009` window where rows
30/// can still have a `NULL` `id` and every `acquire` fails decoding. Order is load-bearing — the
31/// first unsatisfied entry is what `connect` reports. Closed and known at compile time, hence
32/// `&'static str`.
33const REQUIRED_OUTBOX_COLUMNS: &[&str] = &["message_id", "id"];
34
35/// Reliar's PostgreSQL outbox provider. Cheap to clone into an `AppState` — it wraps a
36/// [`PgPool`]; no outer `Arc` required. The connection pool stays the host's: Reliar never owns
37/// or reads a `DATABASE_URL`.
38///
39/// The default type parameter only exists behind the crate's default `json` feature: under
40/// `--no-default-features` there is no default, so [`Self::connect`] is the only
41/// constructor and `cargo hack --feature-powerset` compiles every combination. This block's
42/// `PostgresOutboxStore::new` leans on that default, so it only compiles under `json`; without
43/// it this block still shows the shape but is not compiled.
44#[cfg_attr(not(feature = "json"), doc = "```ignore")]
45#[cfg_attr(feature = "json", doc = "```no_run")]
46/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
47/// use reliar_store_postgres::{PostgresOutboxStore, migrate};
48/// use sqlx::postgres::PgPoolOptions;
49///
50/// let pool = PgPoolOptions::new()
51///     .connect(&std::env::var("DATABASE_URL")?)
52///     .await?;
53/// migrate(&pool, Default::default()).await?;
54///
55/// let store = PostgresOutboxStore::new(pool).await?;
56/// // `store` now implements `OutboxEnqueue`, `OutboxStore` and `OutboxDeadLetters` —
57/// // hand it to an application's write path and to an `OutboxDispatcher`.
58/// # Ok(())
59/// # }
60/// ```
61#[non_exhaustive]
62pub struct PostgresOutboxStore<
63    #[cfg(feature = "json")] Ser = JsonSerializer,
64    #[cfg(not(feature = "json"))] Ser,
65> {
66    // `pub(super)`: every concern module under `outbox/` reads these directly rather than through
67    // an accessor — they are `outbox`-private, never part of this crate's public surface.
68    pub(super) pool: PgPool,
69
70    pub(super) settings: PostgresOutboxSettings,
71
72    pub(super) serializer: Arc<Ser>,
73}
74
75/// **Manual impl, never derived**: a derived `Clone` would condition on `Ser: Clone`. The
76/// serializer is held as `Arc<Ser>` — stateless and cheap to share — so cloning the store never
77/// requires the serializer itself to be `Clone`.
78impl<Ser> Clone for PostgresOutboxStore<Ser> {
79    fn clone(&self) -> Self {
80        Self {
81            pool: self.pool.clone(),
82            settings: self.settings.clone(),
83            serializer: Arc::clone(&self.serializer),
84        }
85    }
86}
87
88impl<Ser> std::fmt::Debug for PostgresOutboxStore<Ser> {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.debug_struct("PostgresOutboxStore")
91            .field("settings", &self.settings)
92            .finish_non_exhaustive()
93    }
94}
95
96impl<Ser: Serializer + Send + Sync + 'static> PostgresOutboxStore<Ser> {
97    /// Wraps `pool` with `settings` and `serializer`. **Verifies once at construction**, in
98    /// order: that the connected server's `server_version_num` meets
99    /// [`crate::MIN_SERVER_VERSION_NUM`] (ADR 0041 — a wrong server version
100    /// explains a missing relation, and the reverse is never true), then that the unqualified
101    /// name `outbox` resolves to `settings.schema`, then that the resolved relation has finished
102    /// the row-identity split — `message_id` and `id` both present and `NOT NULL` (ADR 0044 §1,
103    /// Amendment A.5 — a schema migrated only through `0004` is missing `message_id` entirely,
104    /// and one stopped anywhere in `0005`–`0009` has `id` but it is still nullable): fails fast
105    /// with
106    /// [`PostgresOutboxError::UnsupportedServerVersion`],
107    /// [`PostgresOutboxError::SchemaNotOnSearchPath`] (`search_path` problem),
108    /// [`PostgresOutboxError::NotMigrated`] (the relation is missing entirely), or
109    /// [`PostgresOutboxError::SchemaOutOfDate`] (the relation exists but is not yet on
110    /// `0.7.0`'s schema) rather than surprising the first `acquire`. Logs a `tracing::warn!` when
111    /// a same-named table also exists in another schema on the path.
112    ///
113    /// # Errors
114    ///
115    /// Returns [`PostgresOutboxError::UnsupportedServerVersion`],
116    /// [`PostgresOutboxError::NotMigrated`], [`PostgresOutboxError::SchemaNotOnSearchPath`],
117    /// [`PostgresOutboxError::SchemaOutOfDate`], or [`PostgresOutboxError::Database`] for a
118    /// connection failure during verification.
119    ///
120    /// ```no_run
121    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
122    /// use reliar_core::JsonSerializer;
123    /// use reliar_store_postgres::{PostgresOutboxSettings, PostgresOutboxStore};
124    /// use sqlx::postgres::PgPoolOptions;
125    ///
126    /// let pool = PgPoolOptions::new()
127    ///     .connect(&std::env::var("DATABASE_URL")?)
128    ///     .await?;
129    /// let store = PostgresOutboxStore::connect(
130    ///     pool,
131    ///     PostgresOutboxSettings::default(),
132    ///     JsonSerializer,
133    /// )
134    /// .await?;
135    /// # let _ = store;
136    /// # Ok(())
137    /// # }
138    /// ```
139    pub async fn connect(
140        pool: PgPool,
141        settings: PostgresOutboxSettings,
142        serializer: Ser,
143    ) -> Result<Self, PostgresOutboxError> {
144        if !schema::is_valid_schema_name(&settings.schema) {
145            return Err(PostgresOutboxError::InvalidSchema {
146                schema: settings.schema,
147            });
148        }
149
150        let detected = crate::connection::version::detected_server_version_num(&pool).await?;
151
152        if detected < crate::MIN_SERVER_VERSION_NUM {
153            return Err(PostgresOutboxError::UnsupportedServerVersion {
154                required: crate::MIN_SERVER_VERSION_NUM,
155                detected,
156            });
157        }
158
159        let check =
160            schema::verify_table_schema(&pool, &settings.schema, "outbox", REQUIRED_OUTBOX_COLUMNS)
161                .await
162                .map_err(|err| error::map_operational_error(&settings.schema, err))?;
163
164        let resolved_here = check.resolved_schema.as_deref() == Some(settings.schema.as_str());
165
166        if !resolved_here {
167            if !check.configured_exists {
168                return Err(PostgresOutboxError::NotMigrated {
169                    schema: settings.schema,
170                });
171            }
172
173            return Err(PostgresOutboxError::SchemaNotOnSearchPath {
174                configured: settings.schema,
175                observed: check.search_path,
176            });
177        }
178
179        // ADR 0044 Amendment A.4 (marker corrected by Amendment A.5): `outbox` exists and
180        // resolves correctly, but a pre-0.7.0 schema is missing — or has not yet finished
181        // migrating — a column a later migration completes; refuse `connect` here rather than
182        // surprising the first `enqueue`/`acquire` with a bare `42703` or a decode failure on a
183        // still-nullable `id`.
184        if let Some(&missing) = REQUIRED_OUTBOX_COLUMNS
185            .iter()
186            .find(|col| !check.satisfied_required_columns.iter().any(|c| c == *col))
187        {
188            return Err(PostgresOutboxError::SchemaOutOfDate {
189                schema: settings.schema,
190                missing,
191            });
192        }
193
194        let others = schema::other_table_schemas(&pool, &settings.schema, "outbox")
195            .await
196            .map_err(PostgresOutboxError::from)?;
197
198        if !others.is_empty() {
199            tracing::warn!(
200                configured_schema = %settings.schema,
201                other_schemas = ?others,
202                "a table named `outbox` also exists outside the configured schema; \
203                 an unqualified reference from another session could resolve to it"
204            );
205        }
206
207        Ok(Self {
208            pool,
209            settings,
210            serializer: Arc::new(serializer),
211        })
212    }
213
214    /// The `ContentType` this store writes to every row — `Serializer::content_type()`. The
215    /// only way a caller can predict the `content_type` of an envelope it will later acquire:
216    /// `enqueue` writes this value, ignoring whatever `envelope.metadata.delivery.content_type`
217    /// held. `PostgresOutboxStore::new` here leans on the default type parameter, gated on the
218    /// default `json` feature; without it this block still shows the shape but is not compiled.
219    #[cfg_attr(not(feature = "json"), doc = "```ignore")]
220    #[cfg_attr(feature = "json", doc = "```no_run")]
221    /// # async fn run(pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
222    /// use reliar_store_postgres::PostgresOutboxStore;
223    ///
224    /// let store = PostgresOutboxStore::new(pool).await?;
225    /// assert_eq!(store.content_type().as_str(), "application/json");
226    /// # Ok(())
227    /// # }
228    /// ```
229    #[must_use]
230    pub fn content_type(&self) -> &ContentType {
231        self.serializer.content_type()
232    }
233
234    /// Maps a `sqlx::Error` from one of this store's own operations to a typed
235    /// [`PostgresOutboxError`], catching SQLSTATE `42P01` on **every** call, not just startup
236    /// verification.
237    pub(super) fn map_err(&self, err: sqlx::Error) -> PostgresOutboxError {
238        error::map_operational_error(&self.settings.schema, err)
239    }
240
241    /// Issues `SET LOCAL statement_timeout` on an already-open transaction — the shared half of
242    /// every `Duration::ZERO`-vs-non-zero split in the concern modules below.
243    pub(super) async fn set_local_timeout(
244        &self,
245        tx: &mut Transaction<'_, Postgres>,
246    ) -> Result<(), PostgresOutboxError> {
247        self.set_local_timeout_raw(tx)
248            .await
249            .map_err(|e| self.map_err(e))
250    }
251
252    /// [`Self::set_local_timeout`] without the `PostgresOutboxError` mapping — for the one caller
253    /// (`claim::acquire`'s best-effort poison sweep, ADR 0039 §4) that folds this into a larger
254    /// `sqlx::Error`-returning block rather than propagating a typed error immediately.
255    pub(super) async fn set_local_timeout_raw(
256        &self,
257        tx: &mut Transaction<'_, Postgres>,
258    ) -> Result<(), sqlx::Error> {
259        let timeout_ms = i64::try_from(self.settings.statement_timeout.as_millis())
260            .unwrap_or(i64::MAX)
261            .to_string();
262
263        sqlx::query_scalar!(
264            "SELECT set_config('statement_timeout', $1, true)",
265            timeout_ms
266        )
267        .fetch_one(&mut **tx)
268        .await?;
269
270        Ok(())
271    }
272}
273
274#[cfg(feature = "json")]
275#[cfg_attr(docsrs, doc(cfg(feature = "json")))]
276impl PostgresOutboxStore<JsonSerializer> {
277    /// Convenience over [`Self::connect`], behind the crate's default `json` feature.
278    ///
279    /// # Errors
280    ///
281    /// Same as [`Self::connect`].
282    ///
283    /// ```no_run
284    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
285    /// use reliar_store_postgres::PostgresOutboxStore;
286    /// use sqlx::postgres::PgPoolOptions;
287    ///
288    /// let pool = PgPoolOptions::new()
289    ///     .connect(&std::env::var("DATABASE_URL")?)
290    ///     .await?;
291    /// let store = PostgresOutboxStore::new(pool).await?;
292    /// # let _ = store;
293    /// # Ok(())
294    /// # }
295    /// ```
296    pub async fn new(pool: PgPool) -> Result<Self, PostgresOutboxError> {
297        Self::connect(pool, PostgresOutboxSettings::default(), JsonSerializer).await
298    }
299
300    /// Convenience over [`Self::connect`] with explicit settings, behind the crate's default
301    /// `json` feature.
302    ///
303    /// # Errors
304    ///
305    /// Same as [`Self::connect`].
306    ///
307    /// ```no_run
308    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
309    /// use reliar_store_postgres::{PostgresOutboxSettings, PostgresOutboxStore};
310    /// use sqlx::postgres::PgPoolOptions;
311    ///
312    /// let pool = PgPoolOptions::new()
313    ///     .connect(&std::env::var("DATABASE_URL")?)
314    ///     .await?;
315    /// let store = PostgresOutboxStore::with_settings(
316    ///     pool,
317    ///     PostgresOutboxSettings::default().schema("orders"),
318    /// )
319    /// .await?;
320    /// # let _ = store;
321    /// # Ok(())
322    /// # }
323    /// ```
324    pub async fn with_settings(
325        pool: PgPool,
326        settings: PostgresOutboxSettings,
327    ) -> Result<Self, PostgresOutboxError> {
328        Self::connect(pool, settings, JsonSerializer).await
329    }
330}
331
332impl<Ser: Serializer + Send + Sync + 'static> OutboxStore for PostgresOutboxStore<Ser> {
333    type Error = PostgresOutboxError;
334
335    /// The canonical single-statement claim (ADR 0006): a CTE
336    /// `SELECT … FOR UPDATE SKIP LOCKED` feeding an `UPDATE … RETURNING`, so the row lock is
337    /// released before this future resolves and no network I/O to a publisher can ever happen
338    /// while it is held.
339    ///
340    /// A row this call cannot decode is **excluded from `records`**, reported in `poisoned`,
341    /// and **moved to dead** with `DeadReason::Undecodable` by a follow-up statement guarded by
342    /// `locked_by` — the batch continues rather than failing outright (ADR 0008).
343    async fn acquire(&self, request: AcquireRequest) -> Result<AcquiredBatch, Self::Error> {
344        claim::acquire(self, request).await
345    }
346
347    /// Marks rows published, worker-guarded (`locked_by = $2`). A row already completed or
348    /// reclaimed by another worker contributes nothing to the count — a shortfall is logged at
349    /// `debug`, never an error (ADR 0008).
350    async fn complete(
351        &self,
352        worker: &WorkerId,
353        items: &[CompletedRecord],
354    ) -> Result<u64, Self::Error> {
355        outcomes::complete(self, worker, items).await
356    }
357
358    /// Applies each item's [`FailureOutcome`](reliar_outbox::FailureOutcome), worker-guarded. Retry rows get
359    /// `available_at = now() + delay` computed in SQL (ADR 0009); dead rows get `dead_at`/
360    /// `dead_reason` set together (`ck_outbox_dead_reason`). Both increment `attempts` — on
361    /// outcome, never on claim.
362    async fn fail(&self, worker: &WorkerId, items: &[FailedRecord]) -> Result<u64, Self::Error> {
363        outcomes::fail(self, worker, items).await
364    }
365
366    /// Clears the lease for rows this worker still owns. `available_at` and `attempts` are
367    /// untouched — a release is not a failure.
368    async fn release(&self, worker: &WorkerId, items: &[RecordRef]) -> Result<u64, Self::Error> {
369        outcomes::release(self, worker, items).await
370    }
371
372    /// Renews `locked_until = now() + lease` for rows this worker still owns. Best-effort: a
373    /// shortfall means the lease already expired.
374    async fn extend_lease(
375        &self,
376        worker: &WorkerId,
377        items: &[RecordRef],
378        lease: std::time::Duration,
379    ) -> Result<u64, Self::Error> {
380        outcomes::extend_lease(self, worker, items, lease).await
381    }
382
383    /// **One bounded pass, three statements, each capped at `request.batch_size`**:
384    /// published-row delete, dead-row delete, and the expired→dead sweep — none of the
385    /// three is ever an unbounded `DELETE`/`UPDATE`. The sweep's predicate carries the claim's
386    /// lease clause (`locked_until IS NULL OR locked_until < now()`), so it never transitions a
387    /// row a live worker still owns — that worker's own `complete`/`fail`
388    /// wins, and the row becomes sweepable only once its lease lapses.
389    async fn purge(&self, request: PurgeRequest) -> Result<PurgeReport, Self::Error> {
390        purge::purge(self, request).await
391    }
392
393    /// One statement, **four independently planned scalar subqueries** (ADR 0040 §3; supersedes
394    /// the earlier single-scan `FILTER`-aggregate form, which was `O(table)`). Each subquery is
395    /// aimed at its own partial index — `pending` and `oldest_pending_available_at` at
396    /// `ix_outbox_claimable` (an index-only scan can evaluate a filter on its `INCLUDE`d
397    /// `locked_until`/`expires_at`), `dead` at `ix_outbox_dead_cursor`, `expired_pending` at
398    /// `ix_outbox_expires` — so the cost is `O(claimable backlog)`/`O(dead rows)`/`O(expired
399    /// rows)`, never `O(table)`, and `oldest_pending_available_at` is a single-row `LIMIT`. One
400    /// round trip, one transaction snapshot (`now()` evaluated once), so `as_of` and the four
401    /// values are consistent with each other even though each is planned separately. Measured at
402    /// 100k rows (mixed pending/leased/published/dead/expired) on a vacuumed table, every
403    /// subquery plans as an index-only scan with zero heap fetches.
404    async fn stats(&self) -> Result<OutboxStats, Self::Error> {
405        purge::stats(self).await
406    }
407}