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}