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}