Skip to main content

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