Skip to main content

reliar_store_postgres/outbox/
outbox_store.rs

1//! The [`PostgresOutboxStore`] type itself: fields, construction (`new`/`with_settings`/
2//! `with_serializer`), the small shared helper every other store module calls back into
3//! (`content_type`), and the whole `OutboxStore` trait impl (one file per public trait,
4//! `docs/architecture/store-postgres-layout.md` Part II §6): each method's body calls straight into its concern-layer module
5//! (`claim`, `outcomes`, `purge`). The private helpers below the impl block (`fenced_ids`,
6//! `FailBatches`/`classify_failures`/`apply_fail_batches`, `to_millis`) are shared across more
7//! than one of those method bodies, so they stay out of the impl block itself.
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use reliar_core::{ContentType, Serializer};
13use reliar_outbox::{
14    AcquireRequest, AcquiredBatch, FailedRecord, FailureOutcome, OutboxStats, OutboxStore,
15    PoisonedRow, PurgeReport, PurgeRequest, RecordRef, WorkerId,
16};
17use sqlx::{PgConnection, PgPool};
18
19use crate::connection::session::Session;
20use crate::records::{RawRow, decode_row};
21use crate::settings::PostgresOutboxSettings;
22
23#[cfg(feature = "json")]
24use reliar_core::JsonSerializer;
25
26use super::claim as claim_repo;
27use super::error::PostgresOutboxError;
28use super::outcomes as outcomes_repo;
29use super::purge as purge_repo;
30
31/// Reliar's PostgreSQL outbox provider. Cheap to clone into an `AppState` — it wraps a
32/// [`PgPool`]; no outer `Arc` required. The connection pool stays the host's: Reliar never owns
33/// or reads a `DATABASE_URL`.
34///
35/// The default type parameter only exists behind the crate's default `json` feature: under
36/// `--no-default-features` there is no default, so [`Self::with_serializer`] is the only
37/// constructor and `cargo hack --feature-powerset` compiles every combination. This block's
38/// `PostgresOutboxStore::new` leans on that default, so it only compiles under `json`; without
39/// it this block still shows the shape but is not compiled.
40#[cfg_attr(not(feature = "json"), doc = "```ignore")]
41#[cfg_attr(feature = "json", doc = "```no_run")]
42/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
43/// use reliar_store_postgres::{PostgresOutboxStore, migrate};
44/// use sqlx::postgres::PgPoolOptions;
45///
46/// let pool = PgPoolOptions::new()
47///     .connect(&std::env::var("DATABASE_URL")?)
48///     .await?;
49/// migrate(&pool, Default::default()).await?;
50///
51/// let store = PostgresOutboxStore::new(pool);
52/// // `store` now implements `OutboxEnqueue`, `OutboxStore` and `OutboxDeadLetters` —
53/// // hand it to an application's write path and to an `OutboxDispatcher`.
54/// # Ok(())
55/// # }
56/// ```
57#[non_exhaustive]
58pub struct PostgresOutboxStore<
59    #[cfg(feature = "json")] Ser = JsonSerializer,
60    #[cfg(not(feature = "json"))] Ser,
61> {
62    // `pub(super)`: `outbox_store_dead_letters.rs` reads this directly — its whole
63    // `OutboxDeadLetters` impl (signature and body) lives there rather than delegating from this
64    // file, unlike `claim`/`outcomes`/`purge`, which are read as `&self.session` from the trait
65    // methods below. `outbox_store_enqueue.rs` never touches it: `OutboxEnqueue` runs inside the
66    // caller's own transaction, not `Session::run`.
67    pub(super) session: Session,
68
69    // `pub(super)`: `outbox_store_enqueue.rs` reads this directly — its whole `OutboxEnqueue` impl
70    // lives there, the same reason `outbox_store_dead_letters.rs` reads `session` above.
71    pub(super) serializer: Arc<Ser>,
72}
73
74/// **Manual impl, never derived**: a derived `Clone` would condition on `Ser: Clone`. The
75/// serializer is held as `Arc<Ser>` — stateless and cheap to share — so cloning the store never
76/// requires the serializer itself to be `Clone`.
77impl<Ser> Clone for PostgresOutboxStore<Ser> {
78    fn clone(&self) -> Self {
79        Self {
80            session: self.session.clone(),
81            serializer: Arc::clone(&self.serializer),
82        }
83    }
84}
85
86impl<Ser> std::fmt::Debug for PostgresOutboxStore<Ser> {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.debug_struct("PostgresOutboxStore")
89            .field("session", &self.session)
90            .finish_non_exhaustive()
91    }
92}
93
94impl<Ser: Serializer + Send + Sync + 'static> PostgresOutboxStore<Ser> {
95    /// Wraps `pool` with `settings` and `serializer`. Performs **no I/O**: it issues no query,
96    /// opens no connection and verifies nothing about the database. The pool stays the host's.
97    ///
98    /// Call [`crate::migrate`] (or apply the published SQL through your own pipeline) **before**
99    /// the first store call, and make sure the connection's `search_path` resolves the
100    /// unqualified name `outbox` to the migrated schema — see the crate docs. An un-migrated or
101    /// unreachable table surfaces at the first statement as
102    /// [`PostgresOutboxError::NotMigrated`], never here.
103    ///
104    /// This example uses [`reliar_core::JsonSerializer`], gated on the default `json` feature;
105    /// without it this block still shows the shape but is not compiled.
106    #[cfg_attr(not(feature = "json"), doc = "```ignore")]
107    #[cfg_attr(feature = "json", doc = "```no_run")]
108    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
109    /// use reliar_core::JsonSerializer;
110    /// use reliar_store_postgres::{PostgresOutboxSettings, PostgresOutboxStore};
111    /// use sqlx::postgres::PgPoolOptions;
112    ///
113    /// let pool = PgPoolOptions::new()
114    ///     .connect(&std::env::var("DATABASE_URL")?)
115    ///     .await?;
116    /// let store =
117    ///     PostgresOutboxStore::with_serializer(pool, PostgresOutboxSettings::default(), JsonSerializer);
118    /// # let _ = store;
119    /// # Ok(())
120    /// # }
121    /// ```
122    #[must_use]
123    #[allow(
124        clippy::needless_pass_by_value,
125        reason = "the public signature takes settings by value (ADR 0047 §1); only \
126                  statement_timeout is read today, but PostgresOutboxSettings is #[non_exhaustive] \
127                  and may grow a field this constructor needs to own or move out of later"
128    )]
129    pub fn with_serializer(
130        pool: PgPool,
131        settings: PostgresOutboxSettings,
132        serializer: Ser,
133    ) -> Self {
134        let session = Session::new(pool, settings.statement_timeout);
135
136        Self {
137            session,
138            serializer: Arc::new(serializer),
139        }
140    }
141
142    /// The `ContentType` this store writes to every row — `Serializer::content_type()`. The
143    /// only way a caller can predict the `content_type` of an envelope it will later acquire:
144    /// `enqueue` writes this value, ignoring whatever `envelope.metadata.delivery.content_type`
145    /// held. `PostgresOutboxStore::new` here leans on the default type parameter, gated on the
146    /// default `json` feature; without it this block still shows the shape but is not compiled.
147    #[cfg_attr(not(feature = "json"), doc = "```ignore")]
148    #[cfg_attr(feature = "json", doc = "```no_run")]
149    /// # async fn run(pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
150    /// use reliar_store_postgres::PostgresOutboxStore;
151    ///
152    /// let store = PostgresOutboxStore::new(pool);
153    /// assert_eq!(store.content_type().as_str(), "application/json");
154    /// # Ok(())
155    /// # }
156    /// ```
157    #[must_use]
158    pub fn content_type(&self) -> &ContentType {
159        self.serializer.content_type()
160    }
161}
162
163#[cfg(feature = "json")]
164#[cfg_attr(docsrs, doc(cfg(feature = "json")))]
165impl PostgresOutboxStore<JsonSerializer> {
166    /// Convenience over [`Self::with_serializer`] with [`reliar_core::JsonSerializer`] and
167    /// [`PostgresOutboxSettings::default`], behind the crate's default `json` feature. Performs
168    /// **no I/O** — see [`Self::with_serializer`].
169    ///
170    /// ```no_run
171    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
172    /// use reliar_store_postgres::PostgresOutboxStore;
173    /// use sqlx::postgres::PgPoolOptions;
174    ///
175    /// let pool = PgPoolOptions::new()
176    ///     .connect(&std::env::var("DATABASE_URL")?)
177    ///     .await?;
178    /// let store = PostgresOutboxStore::new(pool);
179    /// # let _ = store;
180    /// # Ok(())
181    /// # }
182    /// ```
183    #[must_use]
184    pub fn new(pool: PgPool) -> Self {
185        Self::with_serializer(pool, PostgresOutboxSettings::default(), JsonSerializer)
186    }
187
188    /// Convenience over [`Self::with_serializer`] with [`reliar_core::JsonSerializer`] and
189    /// explicit `settings`, behind the crate's default `json` feature. Performs **no I/O** — see
190    /// [`Self::with_serializer`].
191    ///
192    /// ```no_run
193    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
194    /// use reliar_store_postgres::{PostgresOutboxSettings, PostgresOutboxStore};
195    /// use sqlx::postgres::PgPoolOptions;
196    /// use std::time::Duration;
197    ///
198    /// let pool = PgPoolOptions::new()
199    ///     .connect(&std::env::var("DATABASE_URL")?)
200    ///     .await?;
201    /// let store = PostgresOutboxStore::with_settings(
202    ///     pool,
203    ///     PostgresOutboxSettings::default().statement_timeout(Duration::from_secs(2)),
204    /// );
205    /// # let _ = store;
206    /// # Ok(())
207    /// # }
208    /// ```
209    #[must_use]
210    pub fn with_settings(pool: PgPool, settings: PostgresOutboxSettings) -> Self {
211        Self::with_serializer(pool, settings, JsonSerializer)
212    }
213}
214
215impl<Ser: Serializer + Send + Sync + 'static> OutboxStore for PostgresOutboxStore<Ser> {
216    type Error = PostgresOutboxError;
217
218    /// The canonical single-statement claim (ADR 0006): a CTE
219    /// `SELECT … FOR UPDATE SKIP LOCKED` feeding an `UPDATE … RETURNING`, so the row lock is
220    /// released before this future resolves and no network I/O to a publisher can ever happen
221    /// while it is held.
222    ///
223    /// A row this call cannot decode is **excluded from `records`**, reported in `poisoned`,
224    /// and **moved to dead** with `DeadReason::Undecodable` by a follow-up statement fenced by the
225    /// claim token this claim just stamped (ADR 0046 Amendment A) — the batch continues rather
226    /// than failing outright (ADR 0008).
227    async fn acquire(&self, request: AcquireRequest) -> Result<AcquiredBatch, Self::Error> {
228        let session = &self.session;
229        let batch_size = i64::from(request.batch_size);
230        let lease_ms = i64::try_from(request.lease.as_millis()).unwrap_or(i64::MAX);
231        let worker = request.worker.as_str();
232
233        let rows: Vec<RawRow> = session
234            .run(async |conn: &mut PgConnection| {
235                claim_repo::claim_rows(
236                    &mut *conn,
237                    claim_repo::ClaimRowsParams {
238                        batch_size,
239                        worker,
240                        lease_ms,
241                    },
242                )
243                .await
244            })
245            .await
246            .map_err(|e| session.map_err::<PostgresOutboxError>(e))?;
247
248        let mut records = Vec::with_capacity(rows.len());
249        let mut poisoned = Vec::new();
250        let mut poisoned_ids = Vec::new();
251        let mut poisoned_tokens = Vec::new();
252        let mut poisoned_errors = Vec::new();
253
254        for raw in rows {
255            // Captured before `decode_row` consumes `raw` by value — the poison sweep below must
256            // fence on the token this very claim just stamped, and `RowError` (what a decode
257            // failure returns) carries no such column (ADR 0046 Amendment A.5 item 9).
258            let claim_token = raw.claim_token;
259
260            match decode_row(raw) {
261                Ok(record) => records.push(record),
262                Err(err) => {
263                    poisoned_ids.push(err.id.as_uuid());
264                    poisoned_tokens.push(claim_token);
265                    poisoned_errors.push(crate::records::truncate_last_error(err.detail.clone()));
266
267                    poisoned.push(PoisonedRow::new(err.id, err.message_id, err.detail));
268                }
269            }
270        }
271
272        if !poisoned_ids.is_empty() {
273            // Not an observed publish attempt, so `attempts` is untouched (ADR 0009: `attempts`
274            // counts outcomes, never claims) — only the lease clears and the row goes dead. Runs
275            // under the same `Session::run` policy as the claim itself, so a slow poison sweep
276            // stays bounded by a non-zero `statement_timeout` too.
277            //
278            // **Best-effort (ADR 0039 §4): a sweep failure never turns a committed claim into an
279            // `Err`.** The claim above has already committed and its rows are already leased to
280            // this caller; failing the whole batch here would strand the N healthy rows for a
281            // full lease over a problem with the poisoned ones. On failure this only logs — the
282            // poisoned rows keep their lease and are re-attempted (sweep or publish) once it
283            // lapses, so `poisoned` means "could not decode and an attempt was made to deaden",
284            // not "is dead".
285            let undecodable =
286                crate::records::encode_dead_reason(reliar_outbox::DeadReason::Undecodable);
287            let sweep_result = session
288                .run(async |conn: &mut PgConnection| {
289                    claim_repo::poison_sweep_rows(
290                        &mut *conn,
291                        claim_repo::PoisonSweepRowsParams {
292                            ids: &poisoned_ids,
293                            tokens: &poisoned_tokens,
294                            errors: &poisoned_errors,
295                            dead_reason: undecodable,
296                        },
297                    )
298                    .await
299                })
300                .await;
301
302            if let Err(err) = sweep_result {
303                // Plain snake_case fields, not the usual dotted `worker.id`/`poisoned.count`
304                // convention: `tracing`'s event macro hits a `macro_rules!` parsing
305                // ambiguity ("multiple parsing options: built-in NTs tt ('field') or 1 other
306                // option") when an explicit `target:` is followed by a dotted field path — a
307                // `tracing` macro limitation, not a style choice.
308                tracing::warn!(
309                    target: "reliar.outbox.acquire",
310                    worker_id = %worker,
311                    poisoned_count = poisoned_ids.len(),
312                    error = %session.map_err::<PostgresOutboxError>(err),
313                    "poison sweep failed; the claimed batch is returned and the undecodable rows \
314                     stay leased until their lease lapses"
315                );
316            }
317        }
318
319        Ok(AcquiredBatch::new(records, poisoned))
320    }
321
322    /// Marks rows published, fenced by each item's claim token (ADR 0046 Amendment A). A row
323    /// already completed or reclaimed under a fresh token — by any worker, including this one —
324    /// contributes nothing to the count; a shortfall is logged at `warn`, naming the fenced ids,
325    /// never an error (ADR 0008, ADR 0046 §5).
326    async fn complete(&self, worker: &WorkerId, items: &[RecordRef]) -> Result<u64, Self::Error> {
327        let session = &self.session;
328
329        if items.is_empty() {
330            return Ok(0);
331        }
332
333        let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.id.as_uuid()).collect();
334        let tokens: Vec<Option<uuid::Uuid>> = items
335            .iter()
336            .map(|i| i.claim_token.map(|t| t.as_uuid()))
337            .collect();
338        let applied = session
339            .run(async |conn: &mut PgConnection| {
340                outcomes_repo::complete_rows(
341                    &mut *conn,
342                    outcomes_repo::CompleteRowsParams {
343                        ids: &ids,
344                        tokens: &tokens,
345                    },
346                )
347                .await
348            })
349            .await
350            .map_err(|e| session.map_err::<PostgresOutboxError>(e))?;
351
352        if applied.len() < ids.len() {
353            tracing::warn!(
354                target: "reliar.outbox.complete",
355                requested = ids.len(),
356                worker.id = %worker,
357                applied = applied.len(),
358                fenced_ids = ?fenced_ids(&ids, &applied),
359                "fewer rows completed than requested — the fenced rows belong to a superseded claim"
360            );
361        }
362
363        Ok(applied.len() as u64)
364    }
365
366    /// Applies each item's [`FailureOutcome`](reliar_outbox::FailureOutcome), fenced by each
367    /// item's claim token. Retry rows get `available_at = now() + delay` computed in SQL
368    /// (ADR 0009); dead rows get `dead_at`/`dead_reason` set together (`ck_outbox_dead_reason`).
369    /// Both increment `attempts` — on outcome, never on claim.
370    async fn fail(&self, worker: &WorkerId, items: &[FailedRecord]) -> Result<u64, Self::Error> {
371        let session = &self.session;
372
373        if items.is_empty() {
374            return Ok(0);
375        }
376
377        let batches = classify_failures(items);
378        let requested = batches.retry_ids.len() + batches.dead_ids.len();
379        let (applied_retry, applied_dead) = apply_fail_batches(session, &batches).await?;
380        let applied = applied_retry.len() + applied_dead.len();
381
382        if applied < requested {
383            let ids: Vec<uuid::Uuid> = batches
384                .retry_ids
385                .iter()
386                .chain(batches.dead_ids.iter())
387                .copied()
388                .collect();
389            let applied_ids: Vec<uuid::Uuid> = applied_retry
390                .iter()
391                .chain(applied_dead.iter())
392                .copied()
393                .collect();
394
395            tracing::warn!(
396                target: "reliar.outbox.fail",
397                requested,
398                worker.id = %worker,
399                applied,
400                fenced_ids = ?fenced_ids(&ids, &applied_ids),
401                "fewer rows failed than requested — the fenced rows belong to a superseded claim"
402            );
403        }
404
405        Ok(applied as u64)
406    }
407
408    /// Clears the lease for rows whose claim token still matches. `available_at` and `attempts`
409    /// are untouched — a release is not a failure.
410    async fn release(&self, worker: &WorkerId, items: &[RecordRef]) -> Result<u64, Self::Error> {
411        let session = &self.session;
412
413        if items.is_empty() {
414            return Ok(0);
415        }
416
417        let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.id.as_uuid()).collect();
418        let tokens: Vec<Option<uuid::Uuid>> = items
419            .iter()
420            .map(|i| i.claim_token.map(|t| t.as_uuid()))
421            .collect();
422        let applied = session
423            .run(async |conn: &mut PgConnection| {
424                outcomes_repo::release_rows(
425                    &mut *conn,
426                    outcomes_repo::ReleaseRowsParams {
427                        ids: &ids,
428                        tokens: &tokens,
429                    },
430                )
431                .await
432            })
433            .await
434            .map_err(|e| session.map_err::<PostgresOutboxError>(e))?;
435
436        if applied.len() < ids.len() {
437            tracing::warn!(
438                target: "reliar.outbox.release",
439                requested = ids.len(),
440                worker.id = %worker,
441                applied = applied.len(),
442                fenced_ids = ?fenced_ids(&ids, &applied),
443                "fewer rows released than requested — the fenced rows belong to a superseded claim"
444            );
445        }
446
447        Ok(applied.len() as u64)
448    }
449
450    /// Renews the lease by moving `available_at` to `now() + lease` for rows whose claim token
451    /// still matches, without rotating it (ADR 0046 Amendment A). `available_at` is the lease
452    /// clock, and the only one (ADR 0050 §1). Best-effort: a shortfall means the claim was
453    /// superseded.
454    async fn extend_lease(
455        &self,
456        worker: &WorkerId,
457        items: &[RecordRef],
458        lease: Duration,
459    ) -> Result<u64, Self::Error> {
460        let session = &self.session;
461
462        if items.is_empty() {
463            return Ok(0);
464        }
465
466        let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.id.as_uuid()).collect();
467        let tokens: Vec<Option<uuid::Uuid>> = items
468            .iter()
469            .map(|i| i.claim_token.map(|t| t.as_uuid()))
470            .collect();
471        let lease_ms = i64::try_from(lease.as_millis()).unwrap_or(i64::MAX);
472        let applied = session
473            .run(async |conn: &mut PgConnection| {
474                outcomes_repo::extend_lease_rows(
475                    &mut *conn,
476                    outcomes_repo::ExtendLeaseRowsParams {
477                        ids: &ids,
478                        tokens: &tokens,
479                        lease_ms,
480                    },
481                )
482                .await
483            })
484            .await
485            .map_err(|e| session.map_err::<PostgresOutboxError>(e))?;
486
487        if applied.len() < ids.len() {
488            tracing::warn!(
489                target: "reliar.outbox.extend_lease",
490                requested = ids.len(),
491                worker.id = %worker,
492                applied = applied.len(),
493                fenced_ids = ?fenced_ids(&ids, &applied),
494                "fewer leases renewed than requested — the fenced rows belong to a superseded claim"
495            );
496        }
497
498        Ok(applied.len() as u64)
499    }
500
501    /// **One bounded pass, three statements, each capped at `request.batch_size`**:
502    /// published-row delete, dead-row delete, and the expired→dead sweep — none of the
503    /// three is ever an unbounded `DELETE`/`UPDATE`. The sweep's predicate carries the
504    /// not-currently-leased guard (`locked_by IS NULL OR available_at <= now()`, ADR 0050 §2.3),
505    /// so it never transitions a row a live worker still owns — that worker's own
506    /// `complete`/`fail` wins, and the row becomes sweepable only once its lease lapses.
507    async fn purge(&self, request: PurgeRequest) -> Result<PurgeReport, Self::Error> {
508        let session = &self.session;
509        let batch_size = i64::from(request.batch_size);
510        let expired_reason = crate::records::encode_dead_reason(reliar_outbox::DeadReason::Expired);
511        let published_ms = request.published_retention.map(to_millis);
512        let dead_ms = request.dead_retention.map(to_millis);
513
514        let (published_deleted, dead_deleted, expired_to_dead) = session
515            .run(async |conn: &mut PgConnection| {
516                let published_deleted = match published_ms {
517                    Some(retention_ms) => {
518                        purge_repo::purge_published_rows(
519                            &mut *conn,
520                            purge_repo::PurgePublishedRowsParams {
521                                retention_ms,
522                                batch_size,
523                            },
524                        )
525                        .await?
526                    }
527                    None => 0,
528                };
529
530                let dead_deleted = match dead_ms {
531                    Some(retention_ms) => {
532                        purge_repo::purge_dead_retention_rows(
533                            &mut *conn,
534                            purge_repo::PurgeDeadRetentionRowsParams {
535                                retention_ms,
536                                batch_size,
537                            },
538                        )
539                        .await?
540                    }
541                    None => 0,
542                };
543
544                let expired_to_dead = purge_repo::purge_expired_sweep_rows(
545                    &mut *conn,
546                    purge_repo::PurgeExpiredSweepRowsParams {
547                        batch_size,
548                        dead_reason: expired_reason,
549                    },
550                )
551                .await?;
552
553                Ok((published_deleted, dead_deleted, expired_to_dead))
554            })
555            .await
556            .map_err(|e| session.map_err::<PostgresOutboxError>(e))?;
557
558        Ok(PurgeReport::new(
559            published_deleted,
560            dead_deleted,
561            expired_to_dead,
562        ))
563    }
564
565    /// One statement, **four independently planned scalar subqueries** (ADR 0040 §3; supersedes
566    /// the earlier single-scan `FILTER`-aggregate form, which was `O(table)`). Each subquery is
567    /// aimed at its own partial index — `pending` and `oldest_pending_available_at` at
568    /// `ix_outbox_claimable` (an index-only scan can evaluate a filter on its `INCLUDE`d
569    /// `expires_at`), `dead` at `ix_outbox_dead_cursor`, `expired_pending` at
570    /// `ix_outbox_expires` — so the cost is `O(claimable backlog)`/`O(dead rows)`/`O(expired
571    /// rows)`, never `O(table)`, and `oldest_pending_available_at` is a single-row `LIMIT`. One
572    /// round trip, one transaction snapshot (`now()` evaluated once), so `as_of` and the four
573    /// values are consistent with each other even though each is planned separately. Measured at
574    /// 100k rows (mixed pending/leased/published/dead/expired) on a vacuumed table, every
575    /// subquery plans as an index-only scan with zero heap fetches.
576    async fn stats(&self) -> Result<OutboxStats, Self::Error> {
577        let session = &self.session;
578        let row = session
579            .run(async |conn: &mut PgConnection| purge_repo::stats_row(&mut *conn).await)
580            .await
581            .map_err(|e| session.map_err::<PostgresOutboxError>(e))?;
582
583        Ok(OutboxStats::new(
584            u64::try_from(row.pending).unwrap_or(0),
585            u64::try_from(row.dead).unwrap_or(0),
586            u64::try_from(row.expired_pending).unwrap_or(0),
587            row.oldest_pending_available_at,
588            row.as_of,
589        ))
590    }
591}
592
593/// The ids in `requested` that a guarded write did not touch — a fresher claim now owns them, or
594/// the reference never carried a live claim's token at all. Allocates only when there is a
595/// shortfall to report. Shared by `complete`/`fail`/`release`/`extend_lease` above.
596///
597/// A caller that passes the same `(id, token)` pair twice in one batch is a caller bug, not a
598/// guard failure: `UNNEST`'s join matches the row once per array element, so `RETURNING o.id`
599/// (and therefore `applied.len()`) can legitimately exceed the row's own single-update count,
600/// which would misreport as a spurious shortfall here rather than a clean over-count.
601fn fenced_ids(requested: &[uuid::Uuid], applied: &[uuid::Uuid]) -> Vec<uuid::Uuid> {
602    let applied: std::collections::HashSet<&uuid::Uuid> = applied.iter().collect();
603
604    requested
605        .iter()
606        .filter(|id| !applied.contains(id))
607        .copied()
608        .collect()
609}
610
611/// `fail`'s items, split by [`FailureOutcome`] into the two statement shapes `fail_retry_rows`/
612/// `fail_dead_rows` take — grouping this way, rather than one item at a time, keeps `fail` a
613/// single pair of statements regardless of how many items of each outcome it was handed.
614struct FailBatches {
615    retry_ids: Vec<uuid::Uuid>,
616
617    retry_tokens: Vec<Option<uuid::Uuid>>,
618
619    retry_errors: Vec<String>,
620
621    retry_delays: Vec<i64>,
622
623    dead_ids: Vec<uuid::Uuid>,
624
625    dead_tokens: Vec<Option<uuid::Uuid>>,
626
627    dead_errors: Vec<String>,
628
629    dead_reasons: Vec<&'static str>,
630}
631
632fn classify_failures(items: &[FailedRecord]) -> FailBatches {
633    let mut batches = FailBatches {
634        retry_ids: Vec::new(),
635        retry_tokens: Vec::new(),
636        retry_errors: Vec::new(),
637        retry_delays: Vec::new(),
638        dead_ids: Vec::new(),
639        dead_tokens: Vec::new(),
640        dead_errors: Vec::new(),
641        dead_reasons: Vec::new(),
642    };
643
644    for item in items {
645        match item.outcome {
646            FailureOutcome::Retry { delay } => {
647                batches.retry_ids.push(item.record.id.as_uuid());
648                batches
649                    .retry_tokens
650                    .push(item.record.claim_token.map(|t| t.as_uuid()));
651                batches.retry_errors.push(item.error.clone());
652
653                batches
654                    .retry_delays
655                    .push(i64::try_from(delay.as_millis()).unwrap_or(i64::MAX));
656            }
657            FailureOutcome::Dead { reason } => {
658                batches.dead_ids.push(item.record.id.as_uuid());
659                batches
660                    .dead_tokens
661                    .push(item.record.claim_token.map(|t| t.as_uuid()));
662                batches.dead_errors.push(item.error.clone());
663
664                batches
665                    .dead_reasons
666                    .push(crate::records::encode_dead_reason(reason));
667            }
668            // `FailureOutcome` is `#[non_exhaustive]` from another crate; a variant this
669            // build does not know how to apply is left untouched rather than guessed at —
670            // it stays claimed until its lease expires and is republished, the same benign
671            // outcome as any other unresolved row (ADR 0008).
672            _ => tracing::error!(
673                id = %item.record.id,
674                "unrecognised FailureOutcome variant; row left as-is"
675            ),
676        }
677    }
678
679    batches
680}
681
682/// Runs `fail_retry_rows`/`fail_dead_rows` for `batches` in one [`Session::run`]. Shared by
683/// `fail`'s single batch pass above.
684async fn apply_fail_batches(
685    session: &Session,
686    batches: &FailBatches,
687) -> Result<(Vec<uuid::Uuid>, Vec<uuid::Uuid>), PostgresOutboxError> {
688    session
689        .run(async |conn: &mut PgConnection| {
690            let applied_retry = if batches.retry_ids.is_empty() {
691                Vec::new()
692            } else {
693                outcomes_repo::fail_retry_rows(
694                    &mut *conn,
695                    outcomes_repo::FailRetryRowsParams {
696                        ids: &batches.retry_ids,
697                        tokens: &batches.retry_tokens,
698                        errors: &batches.retry_errors,
699                        delays_ms: &batches.retry_delays,
700                    },
701                )
702                .await?
703            };
704            let applied_dead = if batches.dead_ids.is_empty() {
705                Vec::new()
706            } else {
707                outcomes_repo::fail_dead_rows(
708                    &mut *conn,
709                    outcomes_repo::FailDeadRowsParams {
710                        ids: &batches.dead_ids,
711                        tokens: &batches.dead_tokens,
712                        errors: &batches.dead_errors,
713                        reasons: &batches.dead_reasons,
714                    },
715                )
716                .await?
717            };
718
719            Ok((applied_retry, applied_dead))
720        })
721        .await
722        .map_err(|e| session.map_err::<PostgresOutboxError>(e))
723}
724
725/// Shared by `purge`'s three retention windows above.
726fn to_millis(duration: Duration) -> i64 {
727    i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
728}