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}