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}