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, CompletedRecord, FailedRecord, FailureOutcome, OutboxStats,
15    OutboxStore, 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    /// ```no_run
105    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
106    /// use reliar_core::JsonSerializer;
107    /// use reliar_store_postgres::{PostgresOutboxSettings, PostgresOutboxStore};
108    /// use sqlx::postgres::PgPoolOptions;
109    ///
110    /// let pool = PgPoolOptions::new()
111    ///     .connect(&std::env::var("DATABASE_URL")?)
112    ///     .await?;
113    /// let store =
114    ///     PostgresOutboxStore::with_serializer(pool, PostgresOutboxSettings::default(), JsonSerializer);
115    /// # let _ = store;
116    /// # Ok(())
117    /// # }
118    /// ```
119    #[must_use]
120    #[allow(
121        clippy::needless_pass_by_value,
122        reason = "the public signature takes settings by value (ADR 0047 §1); only \
123                  statement_timeout is read today, but PostgresOutboxSettings is #[non_exhaustive] \
124                  and may grow a field this constructor needs to own or move out of later"
125    )]
126    pub fn with_serializer(
127        pool: PgPool,
128        settings: PostgresOutboxSettings,
129        serializer: Ser,
130    ) -> Self {
131        let session = Session::new(pool, settings.statement_timeout);
132
133        Self {
134            session,
135            serializer: Arc::new(serializer),
136        }
137    }
138
139    /// The `ContentType` this store writes to every row — `Serializer::content_type()`. The
140    /// only way a caller can predict the `content_type` of an envelope it will later acquire:
141    /// `enqueue` writes this value, ignoring whatever `envelope.metadata.delivery.content_type`
142    /// held. `PostgresOutboxStore::new` here leans on the default type parameter, gated on the
143    /// default `json` feature; without it this block still shows the shape but is not compiled.
144    #[cfg_attr(not(feature = "json"), doc = "```ignore")]
145    #[cfg_attr(feature = "json", doc = "```no_run")]
146    /// # async fn run(pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
147    /// use reliar_store_postgres::PostgresOutboxStore;
148    ///
149    /// let store = PostgresOutboxStore::new(pool);
150    /// assert_eq!(store.content_type().as_str(), "application/json");
151    /// # Ok(())
152    /// # }
153    /// ```
154    #[must_use]
155    pub fn content_type(&self) -> &ContentType {
156        self.serializer.content_type()
157    }
158}
159
160#[cfg(feature = "json")]
161#[cfg_attr(docsrs, doc(cfg(feature = "json")))]
162impl PostgresOutboxStore<JsonSerializer> {
163    /// Convenience over [`Self::with_serializer`] with [`reliar_core::JsonSerializer`] and
164    /// [`PostgresOutboxSettings::default`], behind the crate's default `json` feature. Performs
165    /// **no I/O** — see [`Self::with_serializer`].
166    ///
167    /// ```no_run
168    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
169    /// use reliar_store_postgres::PostgresOutboxStore;
170    /// use sqlx::postgres::PgPoolOptions;
171    ///
172    /// let pool = PgPoolOptions::new()
173    ///     .connect(&std::env::var("DATABASE_URL")?)
174    ///     .await?;
175    /// let store = PostgresOutboxStore::new(pool);
176    /// # let _ = store;
177    /// # Ok(())
178    /// # }
179    /// ```
180    #[must_use]
181    pub fn new(pool: PgPool) -> Self {
182        Self::with_serializer(pool, PostgresOutboxSettings::default(), JsonSerializer)
183    }
184
185    /// Convenience over [`Self::with_serializer`] with [`reliar_core::JsonSerializer`] and
186    /// explicit `settings`, behind the crate's default `json` feature. Performs **no I/O** — see
187    /// [`Self::with_serializer`].
188    ///
189    /// ```no_run
190    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
191    /// use reliar_store_postgres::{PostgresOutboxSettings, PostgresOutboxStore};
192    /// use sqlx::postgres::PgPoolOptions;
193    /// use std::time::Duration;
194    ///
195    /// let pool = PgPoolOptions::new()
196    ///     .connect(&std::env::var("DATABASE_URL")?)
197    ///     .await?;
198    /// let store = PostgresOutboxStore::with_settings(
199    ///     pool,
200    ///     PostgresOutboxSettings::default().statement_timeout(Duration::from_secs(2)),
201    /// );
202    /// # let _ = store;
203    /// # Ok(())
204    /// # }
205    /// ```
206    #[must_use]
207    pub fn with_settings(pool: PgPool, settings: PostgresOutboxSettings) -> Self {
208        Self::with_serializer(pool, settings, JsonSerializer)
209    }
210}
211
212impl<Ser: Serializer + Send + Sync + 'static> OutboxStore for PostgresOutboxStore<Ser> {
213    type Error = PostgresOutboxError;
214
215    /// The canonical single-statement claim (ADR 0006): a CTE
216    /// `SELECT … FOR UPDATE SKIP LOCKED` feeding an `UPDATE … RETURNING`, so the row lock is
217    /// released before this future resolves and no network I/O to a publisher can ever happen
218    /// while it is held.
219    ///
220    /// A row this call cannot decode is **excluded from `records`**, reported in `poisoned`,
221    /// and **moved to dead** with `DeadReason::Undecodable` by a follow-up statement fenced by the
222    /// claim token this claim just stamped (ADR 0046 Amendment A) — the batch continues rather
223    /// than failing outright (ADR 0008).
224    async fn acquire(&self, request: AcquireRequest) -> Result<AcquiredBatch, Self::Error> {
225        let session = &self.session;
226        let batch_size = i64::from(request.batch_size);
227        let lease_ms = i64::try_from(request.lease.as_millis()).unwrap_or(i64::MAX);
228        let worker = request.worker.as_str();
229
230        let rows: Vec<RawRow> = session
231            .run(async |conn: &mut PgConnection| {
232                claim_repo::claim_rows(
233                    &mut *conn,
234                    claim_repo::ClaimRowsParams {
235                        batch_size,
236                        worker,
237                        lease_ms,
238                    },
239                )
240                .await
241            })
242            .await
243            .map_err(|e| session.map_err::<PostgresOutboxError>(e))?;
244
245        let mut records = Vec::with_capacity(rows.len());
246        let mut poisoned = Vec::new();
247        let mut poisoned_ids = Vec::new();
248        let mut poisoned_tokens = Vec::new();
249        let mut poisoned_errors = Vec::new();
250
251        for raw in rows {
252            // Captured before `decode_row` consumes `raw` by value — the poison sweep below must
253            // fence on the token this very claim just stamped, and `RowError` (what a decode
254            // failure returns) carries no such column (ADR 0046 Amendment A.5 item 9).
255            let claim_token = raw.claim_token;
256
257            match decode_row(raw) {
258                Ok(record) => records.push(record),
259                Err(err) => {
260                    poisoned_ids.push(err.id.as_uuid());
261                    poisoned_tokens.push(claim_token);
262                    poisoned_errors.push(crate::records::truncate_last_error(err.detail.clone()));
263
264                    poisoned.push(PoisonedRow::new(err.id, err.message_id, err.detail));
265                }
266            }
267        }
268
269        if !poisoned_ids.is_empty() {
270            // Not an observed publish attempt, so `attempts` is untouched (ADR 0009: `attempts`
271            // counts outcomes, never claims) — only the lease clears and the row goes dead. Runs
272            // under the same `Session::run` policy as the claim itself, so a slow poison sweep
273            // stays bounded by a non-zero `statement_timeout` too.
274            //
275            // **Best-effort (ADR 0039 §4): a sweep failure never turns a committed claim into an
276            // `Err`.** The claim above has already committed and its rows are already leased to
277            // this caller; failing the whole batch here would strand the N healthy rows for a
278            // full lease over a problem with the poisoned ones. On failure this only logs — the
279            // poisoned rows keep their lease and are re-attempted (sweep or publish) once it
280            // lapses, so `poisoned` means "could not decode and an attempt was made to deaden",
281            // not "is dead".
282            let undecodable =
283                crate::records::encode_dead_reason(reliar_outbox::DeadReason::Undecodable);
284            let sweep_result = session
285                .run(async |conn: &mut PgConnection| {
286                    claim_repo::poison_sweep_rows(
287                        &mut *conn,
288                        claim_repo::PoisonSweepRowsParams {
289                            ids: &poisoned_ids,
290                            tokens: &poisoned_tokens,
291                            errors: &poisoned_errors,
292                            dead_reason: undecodable,
293                        },
294                    )
295                    .await
296                })
297                .await;
298
299            if let Err(err) = sweep_result {
300                // Plain snake_case fields, not the usual dotted `worker.id`/`poisoned.count`
301                // convention: `tracing`'s event macro hits a `macro_rules!` parsing
302                // ambiguity ("multiple parsing options: built-in NTs tt ('field') or 1 other
303                // option") when an explicit `target:` is followed by a dotted field path — a
304                // `tracing` macro limitation, not a style choice.
305                tracing::warn!(
306                    target: "reliar.outbox.acquire",
307                    worker_id = %worker,
308                    poisoned_count = poisoned_ids.len(),
309                    error = %session.map_err::<PostgresOutboxError>(err),
310                    "poison sweep failed; the claimed batch is returned and the undecodable rows \
311                     stay leased until their lease lapses"
312                );
313            }
314        }
315
316        Ok(AcquiredBatch::new(records, poisoned))
317    }
318
319    /// Marks rows published, fenced by each item's claim token (ADR 0046 Amendment A). A row
320    /// already completed or reclaimed under a fresh token — by any worker, including this one —
321    /// contributes nothing to the count; a shortfall is logged at `warn`, naming the fenced ids,
322    /// never an error (ADR 0008, ADR 0046 §5).
323    async fn complete(
324        &self,
325        worker: &WorkerId,
326        items: &[CompletedRecord],
327    ) -> Result<u64, Self::Error> {
328        let session = &self.session;
329
330        if items.is_empty() {
331            return Ok(0);
332        }
333
334        let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.record.id.as_uuid()).collect();
335        let tokens: Vec<Option<uuid::Uuid>> = items
336            .iter()
337            .map(|i| i.record.claim_token.map(|t| t.as_uuid()))
338            .collect();
339        let applied = session
340            .run(async |conn: &mut PgConnection| {
341                outcomes_repo::complete_rows(
342                    &mut *conn,
343                    outcomes_repo::CompleteRowsParams {
344                        ids: &ids,
345                        tokens: &tokens,
346                    },
347                )
348                .await
349            })
350            .await
351            .map_err(|e| session.map_err::<PostgresOutboxError>(e))?;
352
353        if applied.len() < ids.len() {
354            tracing::warn!(
355                target: "reliar.outbox.complete",
356                requested = ids.len(),
357                worker.id = %worker,
358                applied = applied.len(),
359                fenced_ids = ?fenced_ids(&ids, &applied),
360                "fewer rows completed than requested — the fenced rows belong to a superseded claim"
361            );
362        }
363
364        Ok(applied.len() as u64)
365    }
366
367    /// Applies each item's [`FailureOutcome`](reliar_outbox::FailureOutcome), fenced by each
368    /// item's claim token. Retry rows get `available_at = now() + delay` computed in SQL
369    /// (ADR 0009); dead rows get `dead_at`/`dead_reason` set together (`ck_outbox_dead_reason`).
370    /// Both increment `attempts` — on outcome, never on claim.
371    async fn fail(&self, worker: &WorkerId, items: &[FailedRecord]) -> Result<u64, Self::Error> {
372        let session = &self.session;
373
374        if items.is_empty() {
375            return Ok(0);
376        }
377
378        let batches = classify_failures(items);
379        let requested = batches.retry_ids.len() + batches.dead_ids.len();
380        let (applied_retry, applied_dead) = apply_fail_batches(session, &batches).await?;
381        let applied = applied_retry.len() + applied_dead.len();
382
383        if applied < requested {
384            let ids: Vec<uuid::Uuid> = batches
385                .retry_ids
386                .iter()
387                .chain(batches.dead_ids.iter())
388                .copied()
389                .collect();
390            let applied_ids: Vec<uuid::Uuid> = applied_retry
391                .iter()
392                .chain(applied_dead.iter())
393                .copied()
394                .collect();
395
396            tracing::warn!(
397                target: "reliar.outbox.fail",
398                requested,
399                worker.id = %worker,
400                applied,
401                fenced_ids = ?fenced_ids(&ids, &applied_ids),
402                "fewer rows failed than requested — the fenced rows belong to a superseded claim"
403            );
404        }
405
406        Ok(applied as u64)
407    }
408
409    /// Clears the lease for rows whose claim token still matches. `available_at` and `attempts`
410    /// are untouched — a release is not a failure.
411    async fn release(&self, worker: &WorkerId, items: &[RecordRef]) -> Result<u64, Self::Error> {
412        let session = &self.session;
413
414        if items.is_empty() {
415            return Ok(0);
416        }
417
418        let ids: Vec<uuid::Uuid> = items.iter().map(|i| i.id.as_uuid()).collect();
419        let tokens: Vec<Option<uuid::Uuid>> = items
420            .iter()
421            .map(|i| i.claim_token.map(|t| t.as_uuid()))
422            .collect();
423        let applied = session
424            .run(async |conn: &mut PgConnection| {
425                outcomes_repo::release_rows(
426                    &mut *conn,
427                    outcomes_repo::ReleaseRowsParams {
428                        ids: &ids,
429                        tokens: &tokens,
430                    },
431                )
432                .await
433            })
434            .await
435            .map_err(|e| session.map_err::<PostgresOutboxError>(e))?;
436
437        if applied.len() < ids.len() {
438            tracing::warn!(
439                target: "reliar.outbox.release",
440                requested = ids.len(),
441                worker.id = %worker,
442                applied = applied.len(),
443                fenced_ids = ?fenced_ids(&ids, &applied),
444                "fewer rows released than requested — the fenced rows belong to a superseded claim"
445            );
446        }
447
448        Ok(applied.len() as u64)
449    }
450
451    /// Renews `locked_until = now() + lease` for rows whose claim token still matches, without
452    /// rotating it (ADR 0046 Amendment A). 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 claim's
504    /// lease clause (`locked_until IS NULL OR locked_until < now()`), so it never transitions a
505    /// row a live worker still owns — that worker's own `complete`/`fail`
506    /// 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    /// `locked_until`/`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}