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