Skip to main content

reliar_store_postgres/
store.rs

1//! [`PostgresOutboxStore`]: construction, `enqueue`, and the `OutboxStore`/`OutboxDeadLetters`
2//! implementations (SRS §20, §21, §24, contract §4).
3
4use std::sync::Arc;
5
6use bytes::Bytes;
7use reliar_core::{ContentType, Message, MessageId, Serializer};
8use reliar_outbox::{
9    AcquiredBatch, CompletedMessage, DeadLetterPage, DeadQuery, FailedMessage, FailureOutcome,
10    MessageRef, OutboxDeadLetters, OutboxEnqueue, OutboxStats, OutboxStore, PoisonedRow,
11    PurgeReport, PurgeRequest, WorkerId,
12};
13use sqlx::{PgPool, Postgres, Transaction};
14use tracing::Instrument as _;
15
16use crate::error::{
17    EnqueueError, PostgresStoreError, is_undefined_table, map_enqueue_error, map_operational_error,
18};
19use crate::records::{RawRow, decode_row};
20use crate::settings::PostgresOutboxSettings;
21
22#[cfg(feature = "json")]
23use reliar_core::JsonSerializer;
24
25/// The largest `DeadQuery::limit` [`PostgresOutboxStore::list_dead`] honours — a caller-supplied
26/// value above this is silently capped, never sent to the database (contract §3.3: "provider-
27/// capped; default 100").
28const MAX_LIST_DEAD_LIMIT: u32 = 1000;
29
30/// Reliar's PostgreSQL outbox provider. Cheap to clone into an `AppState` — it wraps a
31/// [`PgPool`]; no outer `Arc` required. The connection pool stays the host's: Reliar never owns
32/// or reads a `DATABASE_URL`.
33///
34/// The default type parameter only exists behind the crate's default `json` feature (contract
35/// §4): under `--no-default-features` there is no default, so [`Self::connect`] is the only
36/// constructor and `cargo hack --feature-powerset` compiles every combination.
37#[non_exhaustive]
38pub struct PostgresOutboxStore<
39    #[cfg(feature = "json")] Ser = JsonSerializer,
40    #[cfg(not(feature = "json"))] Ser,
41> {
42    pool: PgPool,
43    settings: PostgresOutboxSettings,
44    serializer: Arc<Ser>,
45}
46
47/// **Manual impl, never derived**: a derived `Clone` would condition on `Ser: Clone`. The
48/// serializer is held as `Arc<Ser>` — stateless and cheap to share — so cloning the store never
49/// requires the serializer itself to be `Clone`.
50impl<Ser> Clone for PostgresOutboxStore<Ser> {
51    fn clone(&self) -> Self {
52        Self {
53            pool: self.pool.clone(),
54            settings: self.settings.clone(),
55            serializer: Arc::clone(&self.serializer),
56        }
57    }
58}
59
60impl<Ser> std::fmt::Debug for PostgresOutboxStore<Ser> {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("PostgresOutboxStore")
63            .field("settings", &self.settings)
64            .finish_non_exhaustive()
65    }
66}
67
68/// One row of the startup `search_path` verification query (ADR 0017).
69struct SchemaCheck {
70    resolved_schema: Option<String>,
71    configured_exists: bool,
72    search_path: String,
73}
74
75async fn verify_schema(pool: &PgPool, schema: &str) -> Result<SchemaCheck, PostgresStoreError> {
76    let qualified = format!("{schema}.outbox");
77    let row = sqlx::query!(
78        r#"SELECT
79             current_setting('search_path') AS "search_path!",
80             (SELECT n.nspname
81                FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
82               WHERE c.oid = to_regclass('outbox')) AS resolved_schema,
83             (to_regclass($1) IS NOT NULL) AS "configured_exists!""#,
84        qualified,
85    )
86    .fetch_one(pool)
87    .await
88    .map_err(|err| {
89        if is_undefined_table(&err) {
90            PostgresStoreError::NotMigrated {
91                schema: schema.to_owned(),
92            }
93        } else {
94            PostgresStoreError::from(err)
95        }
96    })?;
97
98    Ok(SchemaCheck {
99        resolved_schema: row.resolved_schema,
100        configured_exists: row.configured_exists,
101        search_path: row.search_path,
102    })
103}
104
105/// Rows with a `relname = 'outbox'` outside `schema`, for the same-named-table warning
106/// (ADR 0017). Empty when there is no such duplicate.
107async fn other_outbox_schemas(
108    pool: &PgPool,
109    schema: &str,
110) -> Result<Vec<String>, PostgresStoreError> {
111    let schemas = sqlx::query_scalar!(
112        r#"SELECT n.nspname
113             FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
114            WHERE c.relname = 'outbox' AND n.nspname <> $1"#,
115        schema,
116    )
117    .fetch_all(pool)
118    .await?;
119    Ok(schemas)
120}
121
122impl<Ser: Serializer + Send + Sync + 'static> PostgresOutboxStore<Ser> {
123    /// Wraps `pool` with `settings` and `serializer`. **Verifies once at construction** that
124    /// the unqualified name `outbox` resolves to `settings.schema`: fails fast with
125    /// [`PostgresStoreError::SchemaResolution`] (`search_path` problem) or
126    /// [`PostgresStoreError::NotMigrated`] (the relation is missing entirely) rather than
127    /// surprising the first `acquire`. Logs a `tracing::warn!` when a same-named table also
128    /// exists in another schema on the path.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`PostgresStoreError::NotMigrated`], [`PostgresStoreError::SchemaResolution`],
133    /// or [`PostgresStoreError::Database`] for a connection failure during verification.
134    pub async fn connect(
135        pool: PgPool,
136        settings: PostgresOutboxSettings,
137        serializer: Ser,
138    ) -> Result<Self, PostgresStoreError> {
139        if !crate::error::is_valid_schema_name(&settings.schema) {
140            return Err(PostgresStoreError::InvalidSchema {
141                schema: settings.schema,
142            });
143        }
144
145        let check = verify_schema(&pool, &settings.schema).await?;
146
147        let resolved_here = check.resolved_schema.as_deref() == Some(settings.schema.as_str());
148        if !resolved_here {
149            if !check.configured_exists {
150                return Err(PostgresStoreError::NotMigrated {
151                    schema: settings.schema,
152                });
153            }
154            return Err(PostgresStoreError::SchemaResolution {
155                configured: settings.schema,
156                observed: check.search_path,
157            });
158        }
159
160        let others = other_outbox_schemas(&pool, &settings.schema).await?;
161        if !others.is_empty() {
162            tracing::warn!(
163                configured_schema = %settings.schema,
164                other_schemas = ?others,
165                "a table named `outbox` also exists outside the configured schema; \
166                 an unqualified reference from another session could resolve to it"
167            );
168        }
169
170        Ok(Self {
171            pool,
172            settings,
173            serializer: Arc::new(serializer),
174        })
175    }
176
177    /// The `ContentType` this store writes to every row — `Serializer::content_type()`. The
178    /// only way a caller can predict the `content_type` of an envelope it will later acquire:
179    /// `enqueue` writes this value, ignoring whatever `envelope.metadata.delivery.content_type`
180    /// held (contract §4).
181    #[must_use]
182    pub fn content_type(&self) -> &ContentType {
183        self.serializer.content_type()
184    }
185
186    /// Maps a `sqlx::Error` from one of this store's own operations to a typed
187    /// [`PostgresStoreError`], catching SQLSTATE `42P01` on **every** call, not just startup
188    /// verification (contract §7 J2).
189    fn map_err(&self, err: sqlx::Error) -> PostgresStoreError {
190        map_operational_error(&self.settings.schema, err)
191    }
192
193    /// Issues `SET LOCAL statement_timeout` on an already-open transaction — the shared half of
194    /// every `Duration::ZERO`-vs-non-zero split below (contract §4).
195    async fn set_local_timeout(
196        &self,
197        tx: &mut Transaction<'_, Postgres>,
198    ) -> Result<(), PostgresStoreError> {
199        let timeout_ms = i64::try_from(self.settings.statement_timeout.as_millis())
200            .unwrap_or(i64::MAX)
201            .to_string();
202        sqlx::query_scalar!(
203            "SELECT set_config('statement_timeout', $1, true)",
204            timeout_ms
205        )
206        .fetch_one(&mut **tx)
207        .await
208        .map_err(|e| self.map_err(e))?;
209        Ok(())
210    }
211}
212
213/// Writes `payload` under `content_type`, running the schema's own `search_path` handling first
214/// (set it, when configured; restore only on success). A free function, not a method: it never
215/// touches `self.serializer` — the caller, [`OutboxEnqueue::enqueue_envelope`], has already
216/// serialized `envelope.body` into `payload` by the time it calls here.
217async fn insert_enqueued<T>(
218    tx: &mut Transaction<'_, Postgres>,
219    settings: &PostgresOutboxSettings,
220    envelope: &reliar_core::Envelope<T>,
221    payload: &Bytes,
222    content_type: &ContentType,
223) -> Result<(), sqlx::Error> {
224    let restore = if settings.enqueue_sets_search_path {
225        Some(set_search_path(tx, &settings.schema).await?)
226    } else {
227        None
228    };
229
230    let result = insert_row(tx, envelope, payload, content_type).await;
231
232    // Only restore on success: a failed INSERT already aborts the transaction (25P02), so
233    // issuing another statement on it would mask the real error behind "current transaction is
234    // aborted" instead. The transaction-local scope makes skipping the restore safe — the
235    // caller's own rollback/abandonment is what actually undoes it.
236    if result.is_ok()
237        && let Some(previous) = restore
238    {
239        restore_search_path(tx, &previous).await?;
240    }
241
242    result
243}
244
245/// Reads the caller's current `search_path`, sets it transaction-locally
246/// (`set_config(.., true)` — dies with the caller's `COMMIT`/`ROLLBACK`) to put `schema` first,
247/// and returns the previous value so it can be restored (contract §4). Returns the raw
248/// [`sqlx::Error`] — not [`EnqueueError`] — so `insert_enqueued`, its only caller, is the one
249/// place that maps it.
250async fn set_search_path(
251    tx: &mut Transaction<'_, Postgres>,
252    schema: &str,
253) -> Result<String, sqlx::Error> {
254    let previous: String = sqlx::query_scalar!("SELECT current_setting('search_path')")
255        .fetch_one(&mut **tx)
256        .await?
257        .unwrap_or_default();
258    let wanted = format!("{schema},public");
259    sqlx::query_scalar!("SELECT set_config('search_path', $1, true)", wanted)
260        .fetch_one(&mut **tx)
261        .await?;
262    Ok(previous)
263}
264
265async fn restore_search_path(
266    tx: &mut Transaction<'_, Postgres>,
267    previous: &str,
268) -> Result<(), sqlx::Error> {
269    sqlx::query_scalar!("SELECT set_config('search_path', $1, true)", previous)
270        .fetch_one(&mut **tx)
271        .await?;
272    Ok(())
273}
274
275/// Generic over the envelope body `T` — `T: Message` is never needed here, only
276/// `envelope.message_type` (the promoted `message_type`/`message_version` columns), which every
277/// `Envelope<T>` carries regardless of `T`.
278async fn insert_row<T>(
279    tx: &mut Transaction<'_, Postgres>,
280    envelope: &reliar_core::Envelope<T>,
281    payload: &Bytes,
282    content_type: &ContentType,
283) -> Result<(), sqlx::Error> {
284    let corr = &envelope.metadata.correlation;
285    let sent_at_ms = envelope
286        .metadata
287        .delivery
288        .sent_at
289        .map(crate::records::encode_epoch_millis);
290    let rest = crate::records::MetadataRest {
291        trace: crate::records::TraceRest {
292            traceparent: envelope.metadata.trace.traceparent.clone(),
293            tracestate: envelope.metadata.trace.tracestate.clone(),
294        },
295        routing: crate::records::RoutingRest {
296            source: envelope
297                .metadata
298                .routing
299                .source
300                .as_ref()
301                .map(|v| v.as_str().to_owned()),
302            destination: envelope
303                .metadata
304                .routing
305                .destination
306                .as_ref()
307                .map(|v| v.as_str().to_owned()),
308            reply_to: envelope
309                .metadata
310                .routing
311                .reply_to
312                .as_ref()
313                .map(|v| v.as_str().to_owned()),
314        },
315        delivery: crate::records::DeliveryRest {
316            sent_at_ms,
317            deduplication_id: envelope.metadata.delivery.deduplication_id.clone(),
318        },
319    };
320    // An empty remainder is written as SQL NULL, not '{}', so pending rows stay small (§24.2).
321    let metadata_json = if rest.trace.traceparent.is_none()
322        && rest.trace.tracestate.is_none()
323        && rest.routing.source.is_none()
324        && rest.routing.destination.is_none()
325        && rest.routing.reply_to.is_none()
326        && rest.delivery.sent_at_ms.is_none()
327        && rest.delivery.deduplication_id.is_none()
328    {
329        None
330    } else {
331        // `MetadataRest`'s fields are now all plain owned `String`/`i64`/`Option` values (no
332        // RFC3339 formatting, contract §7 J5) — `serde_json::to_value` is total over this
333        // shape. The fallback is unreachable in practice; kept non-panicking rather than
334        // `.expect()`'d away (§19.5 forbids a panic on the enqueue path). `.ok()` rather than a
335        // `Value::Null` fallback: on the unreachable error branch this writes SQL `NULL` — the
336        // same "no remainder" shape as the empty-check above — rather than a JSON `null` a reader
337        // would then have to treat as yet another poison case.
338        serde_json::to_value(&rest).ok()
339    };
340
341    let headers_json = envelope.headers().filter(|h| !h.is_empty()).map(|h| {
342        let map: serde_json::Map<String, serde_json::Value> = h
343            .iter()
344            .map(|(k, v)| (k.to_owned(), serde_json::Value::String(v.to_owned())))
345            .collect();
346        serde_json::Value::Object(map)
347    });
348
349    sqlx::query!(
350        r#"INSERT INTO outbox (
351             id, message_type, message_version,
352             correlation_id, conversation_id, causation_id, request_id,
353             content_type, payload, tenant_id, expires_at, ordering_key,
354             metadata, headers, available_at
355           ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, now())"#,
356        envelope.id.as_uuid(),
357        envelope.message_type.name(),
358        i32::from(envelope.message_type.version()),
359        corr.correlation_id
360            .as_ref()
361            .map(reliar_core::CorrelationId::as_str),
362        corr.conversation_id.as_uuid(),
363        corr.causation_id.map(|id| id.as_uuid()),
364        corr.request_id.map(|id| id.as_uuid()),
365        content_type.as_str(),
366        &payload[..],
367        envelope.metadata.tenant_id.as_deref(),
368        envelope.metadata.delivery.expires_at,
369        // No writer sets `ordering_key` in this release — `Ordering::PerKey` is a configuration
370        // error (§22.2) — but the column and its bind stay so a later `PerKey` writer needs no
371        // migration. The statement text is unchanged from when this bound `options.ordering_key`,
372        // so `.sqlx/` needs no regeneration.
373        None::<&str>,
374        metadata_json,
375        headers_json,
376    )
377    .execute(&mut **tx)
378    .await?;
379    Ok(())
380}
381
382/// [`PostgresOutboxStore`]'s [`OutboxEnqueue`] implementation: reuses `insert_enqueued`'s
383/// `search_path` handling and its `insert_row` helper. Implements only
384/// [`OutboxEnqueue::enqueue_envelope`] — the provided `enqueue` (bare `T: Message`) calls back
385/// into it, so the `reliar.outbox.enqueue` span fires exactly once per row for either spelling
386/// (decision #42, ADR 0037 amendment A).
387///
388/// **`Ser: 'static`** — needed because the method reaches `self.serializer` (held as `Arc<Ser>`)
389/// across the `.await` in `insert_enqueued`; without it the future fails to type-check (`E0310`).
390/// Every concrete serializer (`JsonSerializer` or any owned one) is `'static`, so no real host is
391/// excluded.
392///
393/// **A single lifetime, `'c`, quantified by the impl.** With `&mut Tx` in the trait's own method
394/// signature the reborrow lifetime is quantified by the method itself, so the higher-ranked
395/// "implementation is not general enough" trap an earlier `OutboxEnqueueIn<&'a mut
396/// Transaction<'c, _>>` shape had — where an explicit, implied-looking `where 'c: 'a` bound broke
397/// every `tokio::spawn`/Axum call site — cannot arise here; there is no second lifetime to
398/// accidentally bound. That regression guard lives as
399/// `outbox_enqueue::enqueue_is_send_through_tokio_spawn` in this crate's Postgres suite.
400///
401/// Renamed from `OutboxStaging`/`stage` in 0.4.0 (decision #34); folded the store's own typed
402/// `enqueue`/`enqueue_with` into this impl in decision #37 (no inherent method may share a name
403/// with a trait method) — a caller now needs `use reliar_outbox::OutboxEnqueue;` in scope to call
404/// `store.enqueue(..)`/`store.enqueue_envelope(..)`. The trait's serialized twin,
405/// `enqueue_serialized`, was cut before shipping (decision #38).
406impl<'c, Ser> OutboxEnqueue<Transaction<'c, Postgres>> for PostgresOutboxStore<Ser>
407where
408    Ser: Serializer + Send + Sync + 'static,
409{
410    type Error = EnqueueError<Ser::Error>;
411
412    /// Serializes `envelope.body` with this store's configured `Serializer` and writes the
413    /// serializer's own `content_type`. Plain `INSERT`, **no `ON CONFLICT`**: a reused
414    /// `MessageId` aborts the caller's transaction rather than silently losing a message.
415    ///
416    /// # Errors
417    ///
418    /// [`EnqueueError::Serialize`] if the configured `Serializer` rejects the body,
419    /// [`EnqueueError::Duplicate`] for a reused [`MessageId`] (`pk_outbox` violation), or
420    /// [`EnqueueError::Database`] for any other `sqlx` failure.
421    ///
422    /// [`EnqueueError::Duplicate`]/[`EnqueueError::Database`] leave `tx` aborted: the failed
423    /// `INSERT` puts the PostgreSQL transaction in the aborted state, so PostgreSQL rejects every
424    /// subsequent statement on it, and every earlier write in that transaction is rolled back at
425    /// commit. [`EnqueueError::Serialize`] is returned before any statement runs, so `tx` is
426    /// untouched and stays usable.
427    // Block form, not `async fn` (conventions §3(b)): the trait bounds neither `T: Send` nor
428    // `Tx: Send`, and an `async fn`'s parameters live in its own generator's *unstarted* state
429    // regardless of when the body consumes them, so it would need both. Serializing and dropping
430    // the typed body (`map_body(|_| ())`) happen synchronously, before any future is constructed,
431    // so the `async` block below is built only after `T` is gone — its captured state (`tx`,
432    // `envelope: Envelope<()>`, `payload`) is `Send` independently of `T`.
433    fn enqueue_envelope<T: Message + Sync>(
434        &self,
435        tx: &mut Transaction<'c, Postgres>,
436        typed_envelope: reliar_core::Envelope<T>,
437    ) -> impl Future<Output = Result<MessageId, Self::Error>> + Send {
438        let span = tracing::debug_span!(
439            "reliar.outbox.enqueue",
440            message.id = %typed_envelope.id,
441            message.type = %typed_envelope.message_type,
442        );
443        // Entered, not `.instrument()`ed: serialization is synchronous and must run before the
444        // async block below exists, but a serializer's own `tracing` events still belong inside
445        // this span. The guard is dropped before the block is built.
446        let payload = {
447            let _guard = span.enter();
448            self.serializer
449                .serialize(&typed_envelope.body)
450                .map_err(|source| EnqueueError::Serialize { source })
451        };
452        // `insert_enqueued` never reads `envelope.body` anyway.
453        let envelope = typed_envelope.map_body(|_| ());
454
455        async move {
456            let payload = payload?;
457            insert_enqueued(tx, &self.settings, &envelope, &payload, self.content_type())
458                .await
459                .map_err(|source| map_enqueue_error(envelope.id, source))?;
460            Ok(envelope.id)
461        }
462        .instrument(span)
463    }
464}
465
466#[cfg(feature = "json")]
467impl PostgresOutboxStore<JsonSerializer> {
468    /// Convenience over [`Self::connect`], behind the crate's default `json` feature.
469    ///
470    /// # Errors
471    ///
472    /// Same as [`Self::connect`].
473    pub async fn new(pool: PgPool) -> Result<Self, PostgresStoreError> {
474        Self::connect(pool, PostgresOutboxSettings::default(), JsonSerializer).await
475    }
476
477    /// Convenience over [`Self::connect`] with explicit settings, behind the crate's default
478    /// `json` feature.
479    ///
480    /// # Errors
481    ///
482    /// Same as [`Self::connect`].
483    pub async fn with_settings(
484        pool: PgPool,
485        settings: PostgresOutboxSettings,
486    ) -> Result<Self, PostgresStoreError> {
487        Self::connect(pool, settings, JsonSerializer).await
488    }
489}
490
491/// `acquire`'s poison sweep: moves every row `decode_row` couldn't reconstruct to dead with
492/// `DeadReason::Undecodable`, worker-guarded the same way every other outcome update is
493/// (`o.locked_by = $3`) so a row already reclaimed by a different worker after this one's lease
494/// lapsed is left alone.
495async fn poison_sweep_rows<'e>(
496    executor: impl sqlx::PgExecutor<'e>,
497    poisoned_ids: &[uuid::Uuid],
498    poisoned_errors: &[String],
499    worker: &str,
500    undecodable: &str,
501) -> Result<(), sqlx::Error> {
502    sqlx::query!(
503        r#"UPDATE outbox o
504              SET dead_at      = now(),
505                  dead_reason  = $4,
506                  last_error   = f.err,
507                  locked_by    = NULL,
508                  locked_until = NULL,
509                  updated_at   = now()
510             FROM UNNEST($1::uuid[], $2::text[]) AS f(id, err)
511            WHERE o.id = f.id AND o.locked_by = $3"#,
512        poisoned_ids,
513        poisoned_errors,
514        worker,
515        undecodable,
516    )
517    .execute(executor)
518    .await?;
519    Ok(())
520}
521
522/// `purge`'s published-row delete, bounded by `batch_size`. The outer `WHERE` repeats the
523/// subselect's own predicate **in full** — not just the `IS NOT NULL` half — so `EvalPlanQual`'s
524/// re-check (on a row a concurrent writer touched between the subselect's snapshot and this
525/// statement's lock acquisition) can actually exclude it, rather than deleting on stale
526/// information (review 3 B1, review 4 minor: the retention-age comparison needs repeating too,
527/// not only nullness, since a row could in principle be re-published with a fresher timestamp
528/// between the snapshot and the lock).
529async fn purge_published_rows<'e>(
530    executor: impl sqlx::PgExecutor<'e>,
531    retention_ms: i64,
532    batch_size: i64,
533) -> Result<u64, sqlx::Error> {
534    let result = sqlx::query!(
535        r#"DELETE FROM outbox WHERE id IN (
536               SELECT id FROM outbox
537                WHERE published_at IS NOT NULL
538                  AND published_at < now() - ($1::bigint * interval '1 millisecond')
539                LIMIT $2
540           )
541           AND published_at IS NOT NULL
542           AND published_at < now() - ($1::bigint * interval '1 millisecond')"#,
543        retention_ms,
544        batch_size,
545    )
546    .execute(executor)
547    .await?;
548    Ok(result.rows_affected())
549}
550
551/// `purge`'s dead-row delete, bounded by `batch_size`. Same full-predicate `EvalPlanQual` guard
552/// as [`purge_published_rows`] — without it, a row `retry_dead` resurrects (or re-deadens with a
553/// fresher `dead_at`) between the subselect's snapshot and this statement's lock acquisition
554/// could still be deleted (review 3 B1, the blocker this fixes; review 4 minor extended it to
555/// the retention-age comparison too).
556async fn purge_dead_retention_rows<'e>(
557    executor: impl sqlx::PgExecutor<'e>,
558    retention_ms: i64,
559    batch_size: i64,
560) -> Result<u64, sqlx::Error> {
561    let result = sqlx::query!(
562        r#"DELETE FROM outbox WHERE id IN (
563               SELECT id FROM outbox
564                WHERE dead_at IS NOT NULL
565                  AND dead_at < now() - ($1::bigint * interval '1 millisecond')
566                LIMIT $2
567           )
568           AND dead_at IS NOT NULL
569           AND dead_at < now() - ($1::bigint * interval '1 millisecond')"#,
570        retention_ms,
571        batch_size,
572    )
573    .execute(executor)
574    .await?;
575    Ok(result.rows_affected())
576}
577
578/// `purge`'s expired-pending-to-dead sweep, bounded by `batch_size`. The outer
579/// `published_at IS NULL AND dead_at IS NULL` plus the lease clause repeat the subselect's own
580/// mutable-state predicates so a lapsed-lease worker's concurrent `complete`/`fail` can't race
581/// this into a `ck_outbox_terminal` violation (review 3 M1); `expires_at` itself is immutable
582/// once written, so it doesn't need repeating.
583async fn purge_expired_sweep_rows<'e>(
584    executor: impl sqlx::PgExecutor<'e>,
585    batch_size: i64,
586    expired_reason: &str,
587) -> Result<u64, sqlx::Error> {
588    let result = sqlx::query!(
589        r#"UPDATE outbox
590              SET dead_at      = now(),
591                  dead_reason  = $2,
592                  last_error   = 'reliar: expired before publication',
593                  locked_by    = NULL,
594                  locked_until = NULL,
595                  updated_at   = now()
596            WHERE id IN (
597                SELECT id FROM outbox
598                 WHERE expires_at IS NOT NULL AND expires_at < now()
599                   AND published_at IS NULL AND dead_at IS NULL
600                   AND (locked_until IS NULL OR locked_until < now())
601                 LIMIT $1
602            )
603              AND published_at IS NULL AND dead_at IS NULL
604              AND (locked_until IS NULL OR locked_until < now())"#,
605        batch_size,
606        expired_reason,
607    )
608    .execute(executor)
609    .await?;
610    Ok(result.rows_affected())
611}
612
613/// Worker-guarded `complete`: clears the lease and sets `published_at`, only for rows this
614/// worker still holds (`locked_by = $2`) — a row already reclaimed by another worker
615/// contributes nothing (ADR 0008).
616async fn complete_rows<'e>(
617    executor: impl sqlx::PgExecutor<'e>,
618    ids: &[uuid::Uuid],
619    worker: &str,
620) -> Result<u64, sqlx::Error> {
621    let result = sqlx::query!(
622        r#"UPDATE outbox
623              SET published_at = now(),
624                  attempts     = attempts + 1,
625                  locked_by    = NULL,
626                  locked_until = NULL,
627                  updated_at   = now()
628            WHERE id = ANY($1) AND locked_by = $2"#,
629        ids,
630        worker,
631    )
632    .execute(executor)
633    .await?;
634    Ok(result.rows_affected())
635}
636
637async fn release_rows<'e>(
638    executor: impl sqlx::PgExecutor<'e>,
639    ids: &[uuid::Uuid],
640    worker: &str,
641) -> Result<u64, sqlx::Error> {
642    let result = sqlx::query!(
643        r#"UPDATE outbox
644              SET locked_by    = NULL,
645                  locked_until = NULL,
646                  updated_at   = now()
647            WHERE id = ANY($1) AND locked_by = $2"#,
648        ids,
649        worker,
650    )
651    .execute(executor)
652    .await?;
653    Ok(result.rows_affected())
654}
655
656async fn extend_lease_rows<'e>(
657    executor: impl sqlx::PgExecutor<'e>,
658    ids: &[uuid::Uuid],
659    lease_ms: i64,
660    worker: &str,
661) -> Result<u64, sqlx::Error> {
662    let result = sqlx::query!(
663        r#"UPDATE outbox
664              SET locked_until = now() + ($2::bigint * interval '1 millisecond'),
665                  updated_at   = now()
666            WHERE id = ANY($1) AND locked_by = $3"#,
667        ids,
668        lease_ms,
669        worker,
670    )
671    .execute(executor)
672    .await?;
673    Ok(result.rows_affected())
674}
675
676async fn fail_retry_rows<'e>(
677    executor: impl sqlx::PgExecutor<'e>,
678    ids: &[uuid::Uuid],
679    errors: &[String],
680    delays_ms: &[i64],
681    worker: &str,
682) -> Result<u64, sqlx::Error> {
683    let result = sqlx::query!(
684        r#"UPDATE outbox o
685              SET attempts     = o.attempts + 1,
686                  last_error   = f.err,
687                  locked_by    = NULL,
688                  locked_until = NULL,
689                  available_at = now() + (f.delay_ms * interval '1 millisecond'),
690                  updated_at   = now()
691             FROM UNNEST($1::uuid[], $2::text[], $3::bigint[]) AS f(id, err, delay_ms)
692            WHERE o.id = f.id AND o.locked_by = $4"#,
693        ids,
694        errors,
695        delays_ms,
696        worker,
697    )
698    .execute(executor)
699    .await?;
700    Ok(result.rows_affected())
701}
702
703async fn fail_dead_rows<'e>(
704    executor: impl sqlx::PgExecutor<'e>,
705    ids: &[uuid::Uuid],
706    errors: &[String],
707    reasons: &[&str],
708    worker: &str,
709) -> Result<u64, sqlx::Error> {
710    let result = sqlx::query!(
711        r#"UPDATE outbox o
712              SET attempts     = o.attempts + 1,
713                  last_error   = f.err,
714                  dead_at      = now(),
715                  dead_reason  = f.reason,
716                  locked_by    = NULL,
717                  locked_until = NULL,
718                  updated_at   = now()
719             FROM UNNEST($1::uuid[], $2::text[], $3::text[]) AS f(id, err, reason)
720            WHERE o.id = f.id AND o.locked_by = $4"#,
721        ids,
722        errors,
723        reasons as &[&str],
724        worker,
725    )
726    .execute(executor)
727    .await?;
728    Ok(result.rows_affected())
729}
730
731/// The canonical single-statement claim (SRS §24.1, ADR 0006): a CTE
732/// `SELECT … FOR UPDATE SKIP LOCKED` feeding an `UPDATE … RETURNING`, so the row lock is
733/// released before the call returns and no network I/O to a publisher can ever happen while it
734/// is held. Named against [`RawRow`] via `query_as!` (never `FromRow`) so both the plain-pool
735/// and `statement_timeout`-wrapped-transaction call sites in [`PostgresOutboxStore::acquire`]
736/// share one macro invocation instead of two structurally distinct anonymous row types (review
737/// 3 minor: this doc previously sat, misplaced, above `complete_rows` instead).
738async fn claim_rows<'e>(
739    executor: impl sqlx::PgExecutor<'e>,
740    batch_size: i64,
741    worker: &str,
742    lease_ms: i64,
743) -> Result<Vec<RawRow>, sqlx::Error> {
744    sqlx::query_as!(
745        RawRow,
746        r#"WITH claimed AS (
747               SELECT id FROM outbox
748                WHERE published_at IS NULL AND dead_at IS NULL
749                  AND available_at <= now()
750                  AND (locked_until IS NULL OR locked_until < now())
751                  AND (expires_at IS NULL OR expires_at > now())
752                ORDER BY available_at, sequence
753                LIMIT $1
754                FOR UPDATE SKIP LOCKED
755           )
756           UPDATE outbox o
757              SET locked_by    = $2,
758                  locked_until = now() + ($3::bigint * interval '1 millisecond'),
759                  updated_at   = now()
760             FROM claimed
761            WHERE o.id = claimed.id
762           RETURNING o.id, o.sequence, o.message_type, o.message_version,
763                     o.correlation_id, o.conversation_id, o.causation_id, o.request_id,
764                     o.content_type, o.payload, o.tenant_id, o.expires_at, o.ordering_key,
765                     o.metadata, o.headers, o.metadata_version,
766                     o.created_at, o.available_at,
767                     o.attempts, o.locked_by, o.locked_until,
768                     o.published_at, o.dead_at, o.dead_reason, o.last_error"#,
769        batch_size,
770        worker,
771        lease_ms,
772    )
773    .fetch_all(executor)
774    .await
775}
776
777/// `list_dead`'s query, shared by the plain-pool and `statement_timeout`-wrapped-transaction
778/// call sites. Named against [`RawRow`] via `query_as!`, same as [`claim_rows`] — the `SELECT`
779/// list matches its field order exactly.
780async fn list_dead_rows<'e>(
781    executor: impl sqlx::PgExecutor<'e>,
782    query: &DeadQuery,
783    limit: i64,
784) -> Result<Vec<RawRow>, sqlx::Error> {
785    sqlx::query_as!(
786        RawRow,
787        r#"SELECT id, sequence, message_type, message_version,
788                  correlation_id, conversation_id, causation_id, request_id,
789                  content_type, payload, tenant_id, expires_at, ordering_key,
790                  metadata, headers, metadata_version,
791                  created_at, available_at,
792                  attempts, locked_by, locked_until,
793                  published_at, dead_at, dead_reason, last_error
794             FROM outbox
795            WHERE dead_at IS NOT NULL
796              AND ($1::text IS NULL OR message_type = $1)
797              AND ($2::text IS NULL OR tenant_id = $2)
798              AND ($3::timestamptz IS NULL OR dead_at < $3)
799              AND ($4::bigint IS NULL OR sequence > $4)
800            ORDER BY sequence ASC
801            LIMIT $5"#,
802        query.message_type,
803        query.tenant_id,
804        query.dead_before,
805        query.after_sequence,
806        limit,
807    )
808    .fetch_all(executor)
809    .await
810}
811
812/// `retry_dead`'s query, shared by the plain-pool and `statement_timeout`-wrapped-transaction
813/// call sites. Not worker-guarded — a dead row holds no lease (contract §3.4).
814async fn retry_dead_rows<'e>(
815    executor: impl sqlx::PgExecutor<'e>,
816    ids: &[uuid::Uuid],
817) -> Result<u64, sqlx::Error> {
818    let result = sqlx::query!(
819        r#"UPDATE outbox
820              SET dead_at      = NULL,
821                  dead_reason  = NULL,
822                  available_at = now(),
823                  attempts     = 0,
824                  locked_by    = NULL,
825                  locked_until = NULL,
826                  updated_at   = now()
827            WHERE id = ANY($1) AND dead_at IS NOT NULL"#,
828        ids,
829    )
830    .execute(executor)
831    .await?;
832    Ok(result.rows_affected())
833}
834
835/// `purge_dead`'s query, shared by the plain-pool and `statement_timeout`-wrapped-transaction
836/// call sites.
837async fn purge_dead_rows<'e>(
838    executor: impl sqlx::PgExecutor<'e>,
839    ids: &[uuid::Uuid],
840) -> Result<u64, sqlx::Error> {
841    let result = sqlx::query!(
842        "DELETE FROM outbox WHERE id = ANY($1) AND dead_at IS NOT NULL",
843        ids,
844    )
845    .execute(executor)
846    .await?;
847    Ok(result.rows_affected())
848}
849
850impl<Ser: Serializer + Send + Sync + 'static> OutboxStore for PostgresOutboxStore<Ser> {
851    type Error = PostgresStoreError;
852
853    /// The canonical single-statement claim (SRS §24.1, ADR 0006): a CTE
854    /// `SELECT … FOR UPDATE SKIP LOCKED` feeding an `UPDATE … RETURNING`, so the row lock is
855    /// released before this future resolves and no network I/O to a publisher can ever happen
856    /// while it is held.
857    ///
858    /// A row this call cannot decode is **excluded from `records`**, reported in `poisoned`,
859    /// and **moved to dead** with `DeadReason::Undecodable` by a follow-up statement guarded by
860    /// `locked_by` — the batch continues (§19.5, ADR 0008).
861    async fn acquire(
862        &self,
863        request: reliar_outbox::AcquireRequest,
864    ) -> Result<AcquiredBatch, Self::Error> {
865        let batch_size = i64::from(request.batch_size);
866        let lease_ms = i64::try_from(request.lease.as_millis()).unwrap_or(i64::MAX);
867        let worker = request.worker.as_str();
868
869        // `Duration::ZERO` (the default) issues nothing and runs the claim as the single
870        // implicit-transaction statement ADR 0006 relies on; a non-zero `statement_timeout`
871        // costs a `BEGIN`/`SET LOCAL`/statement/`COMMIT` round trip instead, which is why it is
872        // opt-in (contract §4, review 1 major 4).
873        let rows = if self.settings.statement_timeout.is_zero() {
874            claim_rows(&self.pool, batch_size, worker, lease_ms)
875                .await
876                .map_err(|e| self.map_err(e))?
877        } else {
878            let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
879            let timeout_ms = i64::try_from(self.settings.statement_timeout.as_millis())
880                .unwrap_or(i64::MAX)
881                .to_string();
882            sqlx::query_scalar!(
883                "SELECT set_config('statement_timeout', $1, true)",
884                timeout_ms
885            )
886            .fetch_one(&mut *tx)
887            .await
888            .map_err(|e| self.map_err(e))?;
889            let rows = claim_rows(&mut *tx, batch_size, worker, lease_ms)
890                .await
891                .map_err(|e| self.map_err(e))?;
892            tx.commit().await.map_err(|e| self.map_err(e))?;
893            rows
894        };
895
896        let mut records = Vec::with_capacity(rows.len());
897        let mut poisoned = Vec::new();
898        let mut poisoned_ids = Vec::new();
899        let mut poisoned_errors = Vec::new();
900
901        for raw in rows {
902            match decode_row(raw) {
903                Ok(record) => records.push(record),
904                Err(err) => {
905                    poisoned_ids.push(err.id.as_uuid());
906                    poisoned_errors.push(crate::records::truncate_last_error(err.detail.clone()));
907                    poisoned.push(PoisonedRow::new(err.id, err.sequence, err.detail));
908                }
909            }
910        }
911
912        if !poisoned_ids.is_empty() {
913            // Not an observed publish attempt, so `attempts` is untouched (ADR 0009: `attempts`
914            // counts outcomes, never claims) — only the lease clears and the row goes dead. Runs
915            // under the same `statement_timeout` policy as the claim itself (review 3 minor):
916            // previously this always ran directly on the pool even when the claim above had
917            // just gone through the `SET LOCAL` wrap, so a slow poison sweep couldn't be bounded
918            // by a non-zero `statement_timeout`.
919            let undecodable =
920                crate::records::encode_dead_reason(reliar_outbox::DeadReason::Undecodable);
921            if self.settings.statement_timeout.is_zero() {
922                poison_sweep_rows(
923                    &self.pool,
924                    &poisoned_ids,
925                    &poisoned_errors,
926                    worker,
927                    undecodable,
928                )
929                .await
930                .map_err(|e| self.map_err(e))?;
931            } else {
932                let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
933                self.set_local_timeout(&mut tx).await?;
934                poison_sweep_rows(
935                    &mut *tx,
936                    &poisoned_ids,
937                    &poisoned_errors,
938                    worker,
939                    undecodable,
940                )
941                .await
942                .map_err(|e| self.map_err(e))?;
943                tx.commit().await.map_err(|e| self.map_err(e))?;
944            }
945        }
946
947        Ok(AcquiredBatch::new(records, poisoned))
948    }
949
950    /// Marks rows published, worker-guarded (`locked_by = $2`). A row already completed or
951    /// reclaimed by another worker contributes nothing to the count — a shortfall is logged at
952    /// `debug`, never an error (ADR 0008).
953    async fn complete(
954        &self,
955        worker: &WorkerId,
956        items: &[CompletedMessage],
957    ) -> Result<u64, Self::Error> {
958        if items.is_empty() {
959            return Ok(0);
960        }
961        let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.message.id.as_uuid()).collect();
962        let affected = if self.settings.statement_timeout.is_zero() {
963            complete_rows(&self.pool, &ids, worker.as_str())
964                .await
965                .map_err(|e| self.map_err(e))?
966        } else {
967            let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
968            self.set_local_timeout(&mut tx).await?;
969            let affected = complete_rows(&mut *tx, &ids, worker.as_str())
970                .await
971                .map_err(|e| self.map_err(e))?;
972            tx.commit().await.map_err(|e| self.map_err(e))?;
973            affected
974        };
975        log_shortfall("complete", items.len(), affected);
976        Ok(affected)
977    }
978
979    /// Applies each item's [`FailureOutcome`], worker-guarded. Retry rows get
980    /// `available_at = now() + delay` computed in SQL (ADR 0009); dead rows get `dead_at`/
981    /// `dead_reason` set together (`ck_outbox_dead_reason`). Both increment `attempts` — on
982    /// outcome, never on claim.
983    async fn fail(&self, worker: &WorkerId, items: &[FailedMessage]) -> Result<u64, Self::Error> {
984        if items.is_empty() {
985            return Ok(0);
986        }
987
988        let mut retry_ids = Vec::new();
989        let mut retry_errors = Vec::new();
990        let mut retry_delays = Vec::new();
991        let mut dead_ids = Vec::new();
992        let mut dead_errors = Vec::new();
993        let mut dead_reasons = Vec::new();
994
995        for item in items {
996            match item.outcome {
997                FailureOutcome::Retry { delay } => {
998                    retry_ids.push(item.message.id.as_uuid());
999                    retry_errors.push(item.error.clone());
1000                    retry_delays.push(i64::try_from(delay.as_millis()).unwrap_or(i64::MAX));
1001                }
1002                FailureOutcome::Dead { reason } => {
1003                    dead_ids.push(item.message.id.as_uuid());
1004                    dead_errors.push(item.error.clone());
1005                    dead_reasons.push(crate::records::encode_dead_reason(reason));
1006                }
1007                // `FailureOutcome` is `#[non_exhaustive]` from another crate; a variant this
1008                // build does not know how to apply is left untouched rather than guessed at —
1009                // it stays claimed until its lease expires and is republished, the same benign
1010                // outcome as any other unresolved row (ADR 0008).
1011                _ => tracing::error!(
1012                    id = %item.message.id,
1013                    "unrecognised FailureOutcome variant; row left as-is"
1014                ),
1015            }
1016        }
1017
1018        let affected = if self.settings.statement_timeout.is_zero() {
1019            let mut affected = 0u64;
1020            if !retry_ids.is_empty() {
1021                affected += fail_retry_rows(
1022                    &self.pool,
1023                    &retry_ids,
1024                    &retry_errors,
1025                    &retry_delays,
1026                    worker.as_str(),
1027                )
1028                .await
1029                .map_err(|e| self.map_err(e))?;
1030            }
1031            if !dead_ids.is_empty() {
1032                affected += fail_dead_rows(
1033                    &self.pool,
1034                    &dead_ids,
1035                    &dead_errors,
1036                    &dead_reasons,
1037                    worker.as_str(),
1038                )
1039                .await
1040                .map_err(|e| self.map_err(e))?;
1041            }
1042            affected
1043        } else {
1044            let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
1045            self.set_local_timeout(&mut tx).await?;
1046            let mut affected = 0u64;
1047            if !retry_ids.is_empty() {
1048                affected += fail_retry_rows(
1049                    &mut *tx,
1050                    &retry_ids,
1051                    &retry_errors,
1052                    &retry_delays,
1053                    worker.as_str(),
1054                )
1055                .await
1056                .map_err(|e| self.map_err(e))?;
1057            }
1058            if !dead_ids.is_empty() {
1059                affected += fail_dead_rows(
1060                    &mut *tx,
1061                    &dead_ids,
1062                    &dead_errors,
1063                    &dead_reasons,
1064                    worker.as_str(),
1065                )
1066                .await
1067                .map_err(|e| self.map_err(e))?;
1068            }
1069            tx.commit().await.map_err(|e| self.map_err(e))?;
1070            affected
1071        };
1072        log_shortfall("fail", items.len(), affected);
1073        Ok(affected)
1074    }
1075
1076    /// Clears the lease for rows this worker still owns. `available_at` and `attempts` are
1077    /// untouched — a release is not a failure (SRS §26.1).
1078    async fn release(&self, worker: &WorkerId, items: &[MessageRef]) -> Result<u64, Self::Error> {
1079        if items.is_empty() {
1080            return Ok(0);
1081        }
1082        let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.id.as_uuid()).collect();
1083        let affected = if self.settings.statement_timeout.is_zero() {
1084            release_rows(&self.pool, &ids, worker.as_str())
1085                .await
1086                .map_err(|e| self.map_err(e))?
1087        } else {
1088            let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
1089            self.set_local_timeout(&mut tx).await?;
1090            let affected = release_rows(&mut *tx, &ids, worker.as_str())
1091                .await
1092                .map_err(|e| self.map_err(e))?;
1093            tx.commit().await.map_err(|e| self.map_err(e))?;
1094            affected
1095        };
1096        log_shortfall("release", items.len(), affected);
1097        Ok(affected)
1098    }
1099
1100    /// Renews `locked_until = now() + lease` for rows this worker still owns. Best-effort: a
1101    /// shortfall means the lease already expired (§21.1).
1102    async fn extend_lease(
1103        &self,
1104        worker: &WorkerId,
1105        items: &[MessageRef],
1106        lease: std::time::Duration,
1107    ) -> Result<u64, Self::Error> {
1108        if items.is_empty() {
1109            return Ok(0);
1110        }
1111        let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.id.as_uuid()).collect();
1112        let lease_ms = i64::try_from(lease.as_millis()).unwrap_or(i64::MAX);
1113        let affected = if self.settings.statement_timeout.is_zero() {
1114            extend_lease_rows(&self.pool, &ids, lease_ms, worker.as_str())
1115                .await
1116                .map_err(|e| self.map_err(e))?
1117        } else {
1118            let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
1119            self.set_local_timeout(&mut tx).await?;
1120            let affected = extend_lease_rows(&mut *tx, &ids, lease_ms, worker.as_str())
1121                .await
1122                .map_err(|e| self.map_err(e))?;
1123            tx.commit().await.map_err(|e| self.map_err(e))?;
1124            affected
1125        };
1126        log_shortfall("extend_lease", items.len(), affected);
1127        Ok(affected)
1128    }
1129
1130    /// **One bounded pass, three statements, each capped at `request.batch_size`** (contract §7
1131    /// G1): published-row delete, dead-row delete, and the expired→dead sweep — none of the
1132    /// three is ever an unbounded `DELETE`/`UPDATE`. The sweep's predicate carries the claim's
1133    /// lease clause (`locked_until IS NULL OR locked_until < now()`), so it never transitions a
1134    /// row a live worker still owns (contract §7 G2) — that worker's own `complete`/`fail`
1135    /// wins, and the row becomes sweepable only once its lease lapses.
1136    async fn purge(&self, request: PurgeRequest) -> Result<PurgeReport, Self::Error> {
1137        let batch_size = i64::from(request.batch_size);
1138        let expired_reason = crate::records::encode_dead_reason(reliar_outbox::DeadReason::Expired);
1139
1140        let (published_deleted, dead_deleted, expired_to_dead) =
1141            if self.settings.statement_timeout.is_zero() {
1142                let published_deleted = if let Some(retention) = request.published_retention {
1143                    let retention_ms = i64::try_from(retention.as_millis()).unwrap_or(i64::MAX);
1144                    purge_published_rows(&self.pool, retention_ms, batch_size)
1145                        .await
1146                        .map_err(|e| self.map_err(e))?
1147                } else {
1148                    0
1149                };
1150
1151                let dead_deleted = if let Some(retention) = request.dead_retention {
1152                    let retention_ms = i64::try_from(retention.as_millis()).unwrap_or(i64::MAX);
1153                    purge_dead_retention_rows(&self.pool, retention_ms, batch_size)
1154                        .await
1155                        .map_err(|e| self.map_err(e))?
1156                } else {
1157                    0
1158                };
1159
1160                let expired_to_dead =
1161                    purge_expired_sweep_rows(&self.pool, batch_size, expired_reason)
1162                        .await
1163                        .map_err(|e| self.map_err(e))?;
1164
1165                (published_deleted, dead_deleted, expired_to_dead)
1166            } else {
1167                // One transaction, one `SET LOCAL statement_timeout`, all three statements —
1168                // each is individually bounded by it (contract §4/§7 ruling: `statement_timeout`
1169                // bounds every statement Reliar issues on its own pool, `purge` included), and
1170                // sharing one transaction costs one `BEGIN`/`SET LOCAL`/`COMMIT` round trip
1171                // instead of three.
1172                let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
1173                self.set_local_timeout(&mut tx).await?;
1174
1175                let published_deleted = if let Some(retention) = request.published_retention {
1176                    let retention_ms = i64::try_from(retention.as_millis()).unwrap_or(i64::MAX);
1177                    purge_published_rows(&mut *tx, retention_ms, batch_size)
1178                        .await
1179                        .map_err(|e| self.map_err(e))?
1180                } else {
1181                    0
1182                };
1183
1184                let dead_deleted = if let Some(retention) = request.dead_retention {
1185                    let retention_ms = i64::try_from(retention.as_millis()).unwrap_or(i64::MAX);
1186                    purge_dead_retention_rows(&mut *tx, retention_ms, batch_size)
1187                        .await
1188                        .map_err(|e| self.map_err(e))?
1189                } else {
1190                    0
1191                };
1192
1193                let expired_to_dead =
1194                    purge_expired_sweep_rows(&mut *tx, batch_size, expired_reason)
1195                        .await
1196                        .map_err(|e| self.map_err(e))?;
1197
1198                tx.commit().await.map_err(|e| self.map_err(e))?;
1199                (published_deleted, dead_deleted, expired_to_dead)
1200            };
1201
1202        Ok(PurgeReport::new(
1203            published_deleted,
1204            dead_deleted,
1205            expired_to_dead,
1206        ))
1207    }
1208    /// One statement, four `FILTER`-qualified aggregates over a single scan of `outbox`
1209    /// (contract §4, S8 EXPLAIN comparison, RELIAR-17 card Log). An earlier version issued four
1210    /// separate statements, one per `ix_outbox_pending`/`ix_outbox_dead_at`/`ix_outbox_expires` —
1211    /// but `pending`'s and the `min(available_at)` row's predicates aren't a strict subset of
1212    /// `ix_outbox_pending` (they also filter on `available_at`/`locked_until`/`expires_at`, none
1213    /// of which the partial index's `WHERE` clause covers), so the planner chose a `Seq Scan`
1214    /// for both anyway on a realistic seeded table (20k rows, a 25/25/25/25 pending/dead/
1215    /// published/expired-pending mix) — meaning the four-statement form paid for that same
1216    /// `Seq Scan` **twice** (once for `pending`, once for the `min`/`now()` row) plus three
1217    /// extra round trips, for a strictly worse total (`Execution Time` 4.65 ms combined,
1218    /// `Buffers: shared hit` 836) than one statement computing all four aggregates from one scan
1219    /// (`Execution Time` 2.77 ms, `Buffers: shared hit` 412) — see the card Log for both full
1220    /// `EXPLAIN (ANALYZE, BUFFERS)` plans.
1221    async fn stats(&self) -> Result<OutboxStats, Self::Error> {
1222        if self.settings.statement_timeout.is_zero() {
1223            let row = sqlx::query!(
1224                r#"SELECT
1225                       count(*) FILTER (
1226                           WHERE published_at IS NULL AND dead_at IS NULL
1227                             AND available_at <= now()
1228                             AND (locked_until IS NULL OR locked_until < now())
1229                             AND (expires_at IS NULL OR expires_at > now())
1230                       ) AS "pending!",
1231                       count(*) FILTER (WHERE dead_at IS NOT NULL) AS "dead!",
1232                       count(*) FILTER (
1233                           WHERE published_at IS NULL AND dead_at IS NULL
1234                             AND expires_at IS NOT NULL AND expires_at < now()
1235                       ) AS "expired_pending!",
1236                       min(available_at) FILTER (
1237                           WHERE published_at IS NULL AND dead_at IS NULL
1238                             AND available_at <= now()
1239                             AND (locked_until IS NULL OR locked_until < now())
1240                             AND (expires_at IS NULL OR expires_at > now())
1241                       ) AS oldest_pending_available_at,
1242                       now() AS "as_of!"
1243                     FROM outbox"#
1244            )
1245            .fetch_one(&self.pool)
1246            .await
1247            .map_err(|e| self.map_err(e))?;
1248
1249            return Ok(OutboxStats::new(
1250                u64::try_from(row.pending).unwrap_or(0),
1251                u64::try_from(row.dead).unwrap_or(0),
1252                u64::try_from(row.expired_pending).unwrap_or(0),
1253                row.oldest_pending_available_at,
1254                row.as_of,
1255            ));
1256        }
1257
1258        // Same one statement, wrapped in a `SET LOCAL statement_timeout` transaction.
1259        let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
1260        self.set_local_timeout(&mut tx).await?;
1261
1262        let row = sqlx::query!(
1263            r#"SELECT
1264                   count(*) FILTER (
1265                       WHERE published_at IS NULL AND dead_at IS NULL
1266                         AND available_at <= now()
1267                         AND (locked_until IS NULL OR locked_until < now())
1268                         AND (expires_at IS NULL OR expires_at > now())
1269                   ) AS "pending!",
1270                   count(*) FILTER (WHERE dead_at IS NOT NULL) AS "dead!",
1271                   count(*) FILTER (
1272                       WHERE published_at IS NULL AND dead_at IS NULL
1273                         AND expires_at IS NOT NULL AND expires_at < now()
1274                   ) AS "expired_pending!",
1275                   min(available_at) FILTER (
1276                       WHERE published_at IS NULL AND dead_at IS NULL
1277                         AND available_at <= now()
1278                         AND (locked_until IS NULL OR locked_until < now())
1279                         AND (expires_at IS NULL OR expires_at > now())
1280                   ) AS oldest_pending_available_at,
1281                   now() AS "as_of!"
1282                 FROM outbox"#
1283        )
1284        .fetch_one(&mut *tx)
1285        .await
1286        .map_err(|e| self.map_err(e))?;
1287
1288        tx.commit().await.map_err(|e| self.map_err(e))?;
1289
1290        Ok(OutboxStats::new(
1291            u64::try_from(row.pending).unwrap_or(0),
1292            u64::try_from(row.dead).unwrap_or(0),
1293            u64::try_from(row.expired_pending).unwrap_or(0),
1294            row.oldest_pending_available_at,
1295            row.as_of,
1296        ))
1297    }
1298}
1299
1300impl<Ser: Serializer + Send + Sync + 'static> OutboxDeadLetters for PostgresOutboxStore<Ser> {
1301    type Error = PostgresStoreError;
1302
1303    /// **`ORDER BY sequence ASC` is normative** (contract §3.4): `after_sequence` is a keyset
1304    /// cursor over `sequence`, the column `ix_outbox_dead` orders by; `message_type`/
1305    /// `tenant_id`/`dead_before` are filters only, expressed as `($n::type IS NULL OR ...)` so
1306    /// one static statement serves every combination. The cursor returned is the largest
1307    /// `sequence` **scanned**, poisoned rows included, so a poisoned tail cannot loop the
1308    /// caller forever.
1309    async fn list_dead(&self, query: DeadQuery) -> Result<DeadLetterPage, Self::Error> {
1310        // Provider-capped (contract §3.3 "provider-capped; default 100"): a caller-supplied
1311        // limit above this never reaches the database, regardless of what `DeadQuery` carries.
1312        let capped_limit = query.limit.min(MAX_LIST_DEAD_LIMIT);
1313        let limit = i64::from(capped_limit);
1314
1315        let rows = if self.settings.statement_timeout.is_zero() {
1316            list_dead_rows(&self.pool, &query, limit)
1317                .await
1318                .map_err(|e| self.map_err(e))?
1319        } else {
1320            let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
1321            self.set_local_timeout(&mut tx).await?;
1322            let rows = list_dead_rows(&mut *tx, &query, limit)
1323                .await
1324                .map_err(|e| self.map_err(e))?;
1325            tx.commit().await.map_err(|e| self.map_err(e))?;
1326            rows
1327        };
1328
1329        let scanned = rows.len();
1330        let mut records = Vec::with_capacity(scanned);
1331        let mut poisoned = Vec::new();
1332        let mut max_sequence: Option<i64> = None;
1333
1334        for raw in rows {
1335            max_sequence = Some(max_sequence.map_or(raw.sequence, |m| m.max(raw.sequence)));
1336            match decode_row(raw) {
1337                Ok(record) => records.push(record),
1338                Err(err) => poisoned.push(PoisonedRow::new(err.id, err.sequence, err.detail)),
1339            }
1340        }
1341
1342        // "Full" is scanned == limit, poisoned rows included — they occupy a row in the scan,
1343        // so counting only decoded records would stop pagination early on a poisoned tail.
1344        let next_after_sequence = if scanned == capped_limit as usize {
1345            max_sequence
1346        } else {
1347            None
1348        };
1349
1350        Ok(DeadLetterPage::new(records, poisoned, next_after_sequence))
1351    }
1352
1353    /// Returns dead rows to pending: clears the lease that already isn't there, resets
1354    /// `attempts` to 0 (the **only** operation that does), keeps `last_error` for audit. Not
1355    /// worker-guarded — a dead row holds no lease (contract §3.4).
1356    async fn retry_dead(&self, refs: &[MessageRef]) -> Result<u64, Self::Error> {
1357        if refs.is_empty() {
1358            return Ok(0);
1359        }
1360        let ids: Vec<uuid::Uuid> = refs.iter().map(|r| r.id.as_uuid()).collect();
1361        let affected = if self.settings.statement_timeout.is_zero() {
1362            retry_dead_rows(&self.pool, &ids)
1363                .await
1364                .map_err(|e| self.map_err(e))?
1365        } else {
1366            let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
1367            self.set_local_timeout(&mut tx).await?;
1368            let affected = retry_dead_rows(&mut *tx, &ids)
1369                .await
1370                .map_err(|e| self.map_err(e))?;
1371            tx.commit().await.map_err(|e| self.map_err(e))?;
1372            affected
1373        };
1374        Ok(affected)
1375    }
1376
1377    /// Deletes dead rows by reference, regardless of [`PurgeRequest::dead_retention`].
1378    async fn purge_dead(&self, refs: &[MessageRef]) -> Result<u64, Self::Error> {
1379        if refs.is_empty() {
1380            return Ok(0);
1381        }
1382        let ids: Vec<uuid::Uuid> = refs.iter().map(|r| r.id.as_uuid()).collect();
1383        let affected = if self.settings.statement_timeout.is_zero() {
1384            purge_dead_rows(&self.pool, &ids)
1385                .await
1386                .map_err(|e| self.map_err(e))?
1387        } else {
1388            let mut tx = self.pool.begin().await.map_err(|e| self.map_err(e))?;
1389            self.set_local_timeout(&mut tx).await?;
1390            let affected = purge_dead_rows(&mut *tx, &ids)
1391                .await
1392                .map_err(|e| self.map_err(e))?;
1393            tx.commit().await.map_err(|e| self.map_err(e))?;
1394            affected
1395        };
1396        Ok(affected)
1397    }
1398}
1399
1400/// Logs a claimed-vs-affected shortfall at `debug` — never an error (ADR 0008): it means the
1401/// lease was lost to another worker or the row was already retried/completed, both benign.
1402fn log_shortfall(operation: &'static str, claimed: usize, affected: u64) {
1403    let claimed = claimed as u64;
1404    if affected < claimed {
1405        tracing::debug!(
1406            operation,
1407            claimed,
1408            affected,
1409            "fewer rows affected than claimed"
1410        );
1411    }
1412}