reliar_store_postgres/inbox/inbox_store.rs
1//! The [`PostgresInboxStore`] type itself: fields, construction (`new`/`with_settings`), the
2//! whole [`reliar_inbox::InboxStore`] implementation (one file per public trait, mirroring
3//! `outbox::outbox_store`'s shape — `docs/architecture/store-postgres-layout.md` Part II §6), and the private helpers its method bodies
4//! call into or share: `claim_locked` (and its own `claim_row_params`/`claim_from_state`/
5//! `claimed_attempt`/`claimed_attempts_recorded`/`ADVISORY_LOCK_CLASS`) for `claim`,
6//! `format_error_chain` for `fail`, and `build_record` — shared with `InboxDeadLetters::list_dead`
7//! in `inbox_store_dead_letters.rs`, so it stays `pub(super)`.
8
9use reliar_core::{ConversationId, CorrelationId, MessageId, MessageType};
10use reliar_inbox::InboxStore;
11use reliar_inbox::{
12 InboxClaim, InboxFailure, InboxMessage, InboxPurgeReport, InboxPurgeRequest, InboxRecord,
13 InboxRecordId, InboxScope,
14};
15use sqlx::{PgConnection, Postgres, Transaction};
16use tracing::Instrument as _;
17
18use crate::connection::session::Session;
19use crate::records::truncate_last_error;
20use crate::settings::PostgresInboxSettings;
21
22use super::claim as claim_repo;
23use super::claim::ClaimStateRow;
24use super::error::PostgresInboxError;
25use super::outcomes as outcomes_repo;
26use super::purge as purge_repo;
27use super::rows::InboxRow;
28
29/// Reliar's PostgreSQL inbox provider (inbox contract §3). A **separate type** from
30/// [`crate::PostgresOutboxStore`]: the inbox stores no payload, so it needs no `Serializer` type
31/// parameter and none of the outbox's lease/ordering/retention settings. Same crate, same schema,
32/// same [`crate::migrate`]. Cheap to clone — wraps a [`sqlx::PgPool`]; no outer `Arc` required.
33#[derive(Clone, Debug)]
34#[non_exhaustive]
35pub struct PostgresInboxStore {
36 // `pub(super)`: `inbox_store_dead_letters.rs` reads this directly — its whole
37 // `InboxDeadLetters` impl (signature and body) lives there rather than delegating from this
38 // file. `claim`/`fail`/`find`/`purge` above read it as `&self.session` from the trait methods
39 // instead. `complete` never touches it: it runs inside the caller's own transaction, never
40 // through `Session::run`.
41 pub(super) session: Session,
42
43 settings: PostgresInboxSettings,
44}
45
46impl PostgresInboxStore {
47 /// Wraps `pool` with [`PostgresInboxSettings::default`]. Performs **no I/O**: it issues no
48 /// query, opens no connection and verifies nothing about the database. The pool stays the
49 /// host's.
50 ///
51 /// Call [`crate::migrate`] (or apply the published SQL through your own pipeline) **before**
52 /// the first store call, and make sure the connection's `search_path` resolves the
53 /// unqualified name `inbox` to the migrated schema — see the crate docs. An un-migrated or
54 /// unreachable table surfaces at the first statement as
55 /// [`PostgresInboxError::NotMigrated`], never here.
56 ///
57 /// ```no_run
58 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
59 /// use reliar_store_postgres::PostgresInboxStore;
60 /// use sqlx::postgres::PgPoolOptions;
61 ///
62 /// let pool = PgPoolOptions::new()
63 /// .connect(&std::env::var("DATABASE_URL")?)
64 /// .await?;
65 /// let store = PostgresInboxStore::new(pool);
66 /// # let _ = store;
67 /// # Ok(())
68 /// # }
69 /// ```
70 #[must_use]
71 pub fn new(pool: sqlx::PgPool) -> Self {
72 let settings = PostgresInboxSettings::default();
73 let session = Session::new(pool, settings.statement_timeout);
74
75 Self { session, settings }
76 }
77
78 /// As [`Self::new`], with explicit `settings`. Performs no I/O beyond the settings' own
79 /// validation, which never touches the database.
80 ///
81 /// # Errors
82 ///
83 /// [`PostgresInboxError::InvalidSettings`] when `settings.max_attempts == 0` — the one
84 /// rejection this crate can make without asking the database (ADR 0042 A.2.4).
85 ///
86 /// ```no_run
87 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
88 /// use reliar_store_postgres::{PostgresInboxSettings, PostgresInboxStore};
89 /// use sqlx::postgres::PgPoolOptions;
90 ///
91 /// let pool = PgPoolOptions::new()
92 /// .connect(&std::env::var("DATABASE_URL")?)
93 /// .await?;
94 /// let store = PostgresInboxStore::with_settings(
95 /// pool,
96 /// PostgresInboxSettings::default().max_attempts(5),
97 /// )?;
98 /// # let _ = store;
99 /// # Ok(())
100 /// # }
101 /// ```
102 pub fn with_settings(
103 pool: sqlx::PgPool,
104 settings: PostgresInboxSettings,
105 ) -> Result<Self, PostgresInboxError> {
106 settings.validate()?;
107
108 let session = Session::new(pool, settings.statement_timeout);
109
110 Ok(Self { session, settings })
111 }
112
113 /// [`PostgresInboxSettings::max_attempts`] — the one setting a runtime call reads outside
114 /// construction, read directly by `fail`'s body below rather than a hidden dependency on
115 /// `self` further down the call chain.
116 pub(super) fn max_attempts(&self) -> u32 {
117 self.settings.max_attempts
118 }
119}
120
121impl<'c> InboxStore<Transaction<'c, Postgres>> for PostgresInboxStore {
122 type Error = PostgresInboxError;
123
124 /// The three-statement claim (inbox contract §3.1): the in-flight advisory-lock guard, the
125 /// `INSERT … ON CONFLICT DO NOTHING` claim, and — only when nothing was inserted — the state
126 /// read that decides `AlreadyCompleted`/`Dead` vs. `Claimed`. The three statements themselves
127 /// live in `claim_locked`, below.
128 // Block form — reason (a): `inbox.scope`/`message.id`/`message.type` must be recorded on the
129 // `reliar.inbox.claim` span before the first statement runs (inbox contract §4), so the span
130 // itself has to exist before the async block that runs those statements does.
131 fn claim(
132 &self,
133 tx: &mut Transaction<'c, Postgres>,
134 scope: &InboxScope,
135 message: InboxMessage<'_>,
136 ) -> impl Future<Output = Result<InboxClaim, Self::Error>> + Send {
137 let span = tracing::debug_span!(
138 "reliar.inbox.claim",
139 inbox.scope = %scope,
140 message.id = %message.id,
141 // `r#type`, not `type` — `type` is a Rust keyword; tracing strips the `r#` prefix
142 // from the field name, so this still renders as `message.type` (inbox contract §4).
143 message.r#type = %message.message_type,
144 inbox.outcome = tracing::field::Empty,
145 inbox.attempt = tracing::field::Empty,
146 inbox.attempts = tracing::field::Empty,
147 inbox.record_id = tracing::field::Empty,
148 );
149 let recording_span = span.clone();
150
151 async move {
152 let result = claim_locked(tx, scope.as_str(), message).await;
153
154 if let Ok(claim) = &result {
155 record_claim_outcome(&recording_span, claim);
156 }
157
158 result
159 }
160 .instrument(span)
161 }
162
163 /// Marks the row completed in the caller's transaction. Zero rows affected ⇒ `NotClaimed`
164 /// (including a row that has since gone dead — the guard is `completed_at IS NULL AND
165 /// dead_at IS NULL`).
166 // Block form — reason (a): the span's `inbox.scope`/`message.id` fields (inbox contract §4)
167 // must be created before the statement they describe runs.
168 fn complete(
169 &self,
170 tx: &mut Transaction<'c, Postgres>,
171 scope: &InboxScope,
172 id: MessageId,
173 ) -> impl Future<Output = Result<(), Self::Error>> + Send {
174 let span = tracing::debug_span!(
175 "reliar.inbox.complete",
176 inbox.scope = %scope,
177 message.id = %id,
178 );
179
180 async move {
181 let scope_str = scope.as_str();
182 let message_id = id.as_uuid();
183 let affected = outcomes_repo::complete_row(
184 tx,
185 outcomes_repo::CompleteRowParams {
186 scope: scope_str,
187 message_id,
188 },
189 )
190 .await?;
191
192 if affected == 0 {
193 return Err(PostgresInboxError::NotClaimed {
194 scope: scope_str.to_owned(),
195 message_id: id,
196 });
197 }
198
199 Ok(())
200 }
201 .instrument(span)
202 }
203
204 /// Records a failed attempt on this store's own pool, guarded by `completed_at IS NULL`, and
205 /// bounds it at `settings.max_attempts` atomically with the increment (ADR 0042 A.2.4).
206 // Block form: `error: &(dyn Error + 'static)` is not `Send`, so its `Display` chain must be
207 // extracted into an owned `String` before the async block is built, never inside a plain
208 // `async fn` (conventions §3(b); the trait's own rustdoc calls this out) — also reason (a):
209 // the `reliar.inbox.fail` span's entry fields must be recorded before the statement runs.
210 fn fail(
211 &self,
212 scope: &InboxScope,
213 message: InboxMessage<'_>,
214 error: &(dyn std::error::Error + 'static),
215 ) -> impl Future<Output = Result<InboxFailure, Self::Error>> + Send {
216 let last_error = format_error_chain(error);
217 let span = tracing::debug_span!(
218 "reliar.inbox.fail",
219 inbox.scope = %scope,
220 message.id = %message.id,
221 // `r#type`, not `type` — `type` is a Rust keyword; tracing strips the `r#` prefix
222 // from the field name, so this still renders as `message.type` (inbox contract §4).
223 message.r#type = %message.message_type,
224 inbox.outcome = tracing::field::Empty,
225 inbox.attempts = tracing::field::Empty,
226 inbox.record_id = tracing::field::Empty,
227 );
228 let recording_span = span.clone();
229
230 async move {
231 let session = &self.session;
232 let scope_str = scope.as_str();
233 let last_error = truncate_last_error(last_error);
234 let max_attempts = i32::try_from(self.max_attempts()).unwrap_or(i32::MAX);
235 let id = InboxRecordId::new().as_uuid();
236 let message_id = message.id.as_uuid();
237 let message_type = message.message_type.name();
238 let message_version = i32::from(message.message_type.version());
239 let conversation_id = message.conversation_id.as_uuid();
240 let correlation_id = message.correlation_id.map(CorrelationId::as_str);
241 let causation_id = message.causation_id.map(|c| c.as_uuid());
242
243 let row = session
244 .run(async |conn: &mut PgConnection| {
245 outcomes_repo::fail_row(
246 &mut *conn,
247 outcomes_repo::FailRowParams {
248 id,
249 scope: scope_str,
250 message_id,
251 message_type,
252 message_version,
253 conversation_id,
254 correlation_id,
255 causation_id,
256 last_error: &last_error,
257 max_attempts,
258 },
259 )
260 .await
261 })
262 .await
263 .map_err(|e| session.map_err::<PostgresInboxError>(e))?;
264
265 let failure = match row {
266 None => InboxFailure::AlreadyCompleted,
267 Some(row) => {
268 let attempts = u32::try_from(row.attempts).unwrap_or(u32::MAX);
269
270 match row.dead_at {
271 Some(dead_at) => InboxFailure::Dead {
272 id: InboxRecordId::from_uuid(row.id),
273 attempts,
274 dead_at,
275 },
276 None => InboxFailure::Recorded { attempts },
277 }
278 }
279 };
280
281 record_fail_outcome(&recording_span, &failure);
282
283 Ok(failure)
284 }
285 .instrument(span)
286 }
287
288 /// Reads a row for diagnostics. No Reliar code path calls it. No span — the inbox contract's
289 /// observability table (§4) does not list `find`, since no Reliar code path calls it.
290 async fn find(
291 &self,
292 scope: &InboxScope,
293 id: MessageId,
294 ) -> Result<Option<InboxRecord>, PostgresInboxError> {
295 let session = &self.session;
296 let scope_str = scope.as_str();
297 let message_id = id.as_uuid();
298 let row = session
299 .run(async |conn: &mut PgConnection| {
300 purge_repo::find_row(
301 &mut *conn,
302 purge_repo::FindRowParams {
303 scope: scope_str,
304 message_id,
305 },
306 )
307 .await
308 })
309 .await
310 .map_err(|e| session.map_err::<PostgresInboxError>(e))?;
311
312 let Some(row) = row else {
313 return Ok(None);
314 };
315
316 Ok(Some(build_record(row)?))
317 }
318
319 /// One bounded pass, three statements, each capped at `request.batch_size`: the
320 /// completed-row, incomplete-row and dead-row deletes.
321 // Block form — reason (a): the `reliar.inbox.purge` span must exist before the three
322 // statements it will report on run.
323 fn purge(
324 &self,
325 request: InboxPurgeRequest,
326 ) -> impl Future<Output = Result<InboxPurgeReport, Self::Error>> + Send {
327 let span = tracing::debug_span!(
328 "reliar.inbox.purge",
329 inbox.completed_deleted = tracing::field::Empty,
330 inbox.incomplete_deleted = tracing::field::Empty,
331 inbox.dead_deleted = tracing::field::Empty,
332 );
333 let recording_span = span.clone();
334
335 async move {
336 let session = &self.session;
337 let batch_size = i64::from(request.batch_size);
338 let completed_ms = request.completed_retention.map(to_millis);
339 let incomplete_ms = request.incomplete_retention.map(to_millis);
340 let dead_ms = request.dead_retention.map(to_millis);
341
342 let (completed_deleted, incomplete_deleted, dead_deleted) = session
343 .run(async |conn: &mut PgConnection| {
344 let completed_deleted = match completed_ms {
345 Some(retention_ms) => {
346 purge_repo::purge_completed_rows(
347 &mut *conn,
348 purge_repo::PurgeCompletedRowsParams {
349 retention_ms,
350 batch_size,
351 },
352 )
353 .await?
354 }
355 None => 0,
356 };
357
358 let incomplete_deleted = match incomplete_ms {
359 Some(retention_ms) => {
360 purge_repo::purge_incomplete_rows(
361 &mut *conn,
362 purge_repo::PurgeIncompleteRowsParams {
363 retention_ms,
364 batch_size,
365 },
366 )
367 .await?
368 }
369 None => 0,
370 };
371
372 let dead_deleted = match dead_ms {
373 Some(retention_ms) => {
374 purge_repo::purge_dead_retention_rows(
375 &mut *conn,
376 purge_repo::PurgeDeadRetentionRowsParams {
377 retention_ms,
378 batch_size,
379 },
380 )
381 .await?
382 }
383 None => 0,
384 };
385
386 Ok((completed_deleted, incomplete_deleted, dead_deleted))
387 })
388 .await
389 .map_err(|e| session.map_err::<PostgresInboxError>(e))?;
390
391 let report = InboxPurgeReport::new(completed_deleted, incomplete_deleted, dead_deleted);
392
393 recording_span.record("inbox.completed_deleted", report.completed_deleted);
394 recording_span.record("inbox.incomplete_deleted", report.incomplete_deleted);
395 recording_span.record("inbox.dead_deleted", report.dead_deleted);
396
397 Ok(report)
398 }
399 .instrument(span)
400 }
401}
402
403/// The `reliar.inbox.claim` span's outcome fields (ADR 0042 Amendment C.5): `inbox.outcome`
404/// always, `inbox.attempt` on `Claimed`, `inbox.record_id` + `inbox.attempts` on `Dead`. Never
405/// recorded on an `Err` path — the caller's own log carries the failure.
406fn record_claim_outcome(span: &tracing::Span, claim: &InboxClaim) {
407 match claim {
408 InboxClaim::Claimed { attempt } => {
409 span.record("inbox.outcome", "claimed");
410 span.record("inbox.attempt", attempt);
411 }
412 InboxClaim::AlreadyCompleted { .. } => {
413 span.record("inbox.outcome", "already_completed");
414 }
415 InboxClaim::InProgress => {
416 span.record("inbox.outcome", "in_progress");
417 }
418 InboxClaim::Dead { id, attempts, .. } => {
419 span.record("inbox.outcome", "dead");
420 span.record("inbox.record_id", tracing::field::display(id));
421 span.record("inbox.attempts", attempts);
422 }
423 // `InboxClaim` is `#[non_exhaustive]`; every variant this crate's contract defines is
424 // matched above.
425 _ => {}
426 }
427}
428
429/// The `reliar.inbox.fail` span's outcome fields (inbox contract §4): `inbox.outcome` always,
430/// `inbox.attempts`/`inbox.record_id` where the variant carries them. Never recorded on an `Err`
431/// path.
432fn record_fail_outcome(span: &tracing::Span, failure: &InboxFailure) {
433 match failure {
434 InboxFailure::Recorded { attempts } => {
435 span.record("inbox.outcome", "recorded");
436 span.record("inbox.attempts", attempts);
437 }
438 InboxFailure::Dead {
439 id,
440 attempts,
441 dead_at: _,
442 } => {
443 span.record("inbox.outcome", "dead");
444 span.record("inbox.attempts", attempts);
445 span.record("inbox.record_id", tracing::field::display(id));
446 }
447 InboxFailure::AlreadyCompleted => {
448 span.record("inbox.outcome", "already_completed");
449 }
450 // `InboxFailure` is `#[non_exhaustive]`; every variant this crate's contract defines is
451 // matched above.
452 _ => {}
453 }
454}
455
456/// The fixed Reliar advisory-lock "class" for the inbox's in-flight guard (inbox contract §3.1):
457/// `i32::from_be_bytes(*b"RELI")`, the first argument to the two-argument
458/// `pg_try_advisory_xact_lock`, which PostgreSQL documents as a distinct key space from the
459/// one-argument `bigint` form — so this can never collide with a key any other Reliar or host
460/// code chooses in that space. Released by the caller's commit or rollback; nothing to clean up.
461const ADVISORY_LOCK_CLASS: i32 = i32::from_be_bytes(*b"RELI");
462
463/// [`InboxStore::claim`]'s body — see that trait method's rustdoc for the full contract.
464async fn claim_locked(
465 tx: &mut Transaction<'_, Postgres>,
466 scope: &str,
467 message: InboxMessage<'_>,
468) -> Result<InboxClaim, PostgresInboxError> {
469 let message_id = message.id.as_uuid();
470
471 // 1. the in-flight guard (inbox contract §3.1/ADR 0042 §3). `false` ⇒ `InProgress`, return
472 // now, no write, tx still usable. A collision between two *concurrently claimed* keys can
473 // report this spuriously — a redelivery, never a lost or doubled effect.
474 let acquired = claim_repo::try_advisory_lock(
475 &mut **tx,
476 claim_repo::TryAdvisoryLockParams {
477 class: ADVISORY_LOCK_CLASS,
478 scope,
479 message_id,
480 },
481 )
482 .await?;
483
484 if !acquired {
485 return Ok(InboxClaim::InProgress);
486 }
487
488 // 2. the claim. DO NOTHING, never DO UPDATE: the AlreadyCompleted path is the hot path of a
489 // redelivery storm and must not write, WAL or bloat a row it only reads. A returned row means
490 // we inserted ⇒ read its own `attempts` back rather than assume 0, so this stays correct even
491 // if a future migration ever gives the row a non-zero starting value.
492 let id = InboxRecordId::new();
493 let claim_params = claim_row_params(id, scope, &message);
494
495 if let Some(attempts) = claim_repo::insert_claim_row(&mut **tx, claim_params).await? {
496 return Ok(InboxClaim::Claimed {
497 attempt: claimed_attempt(attempts),
498 });
499 }
500
501 // 3. only when 2 returned nothing: the key was committed a moment ago, so decide from its
502 // state. A concurrent `purge` can delete the row between step 2's conflict and this read's
503 // own READ COMMITTED snapshot — the advisory lock held since step 1 serializes *claims*
504 // only, so a deleted-then-recreated key is a real possibility here, handled below.
505 let row = claim_repo::select_claim_state(
506 &mut **tx,
507 claim_repo::SelectClaimStateParams { scope, message_id },
508 )
509 .await?;
510
511 let Some(row) = row else {
512 // The advisory lock held since step 1 serializes *claims* only — `fail` inserts this
513 // same `(scope, message_id)` (ADR 0042 §4's `ON CONFLICT … DO UPDATE`) as a plain pool
514 // statement, without ever taking it, precisely for the case where the claiming
515 // transaction has rolled back. So a concurrent `purge` deleting the row this session's
516 // `SELECT` just missed, followed by a concurrent `fail` recreating it before this
517 // session's own re-insert runs, is a real race — not a corrupt-row scenario. Rather than
518 // a plain `INSERT … DO NOTHING` that could still return `None` here, the re-insert
519 // upserts and reads in one statement (ADR 0042 Amendment C.8): PostgreSQL guarantees an
520 // atomic insert-or-update outcome for `ON CONFLICT DO UPDATE` with no `WHERE` clause, so
521 // the statement can never return zero rows.
522 let upsert_params = claim_row_params(InboxRecordId::new(), scope, &message);
523 let row = claim_repo::upsert_claim_row(&mut **tx, upsert_params).await?;
524
525 return Ok(claim_from_state(&row));
526 };
527
528 Ok(claim_from_state(&row))
529}
530
531fn claim_row_params<'a>(
532 id: InboxRecordId,
533 scope: &'a str,
534 message: &InboxMessage<'a>,
535) -> claim_repo::ClaimRowParams<'a> {
536 claim_repo::ClaimRowParams {
537 id: id.as_uuid(),
538 scope,
539 message_id: message.id.as_uuid(),
540 message_type: message.message_type.name(),
541 message_version: i32::from(message.message_type.version()),
542 conversation_id: message.conversation_id.as_uuid(),
543 correlation_id: message.correlation_id.map(CorrelationId::as_str),
544 causation_id: message.causation_id.map(|c| c.as_uuid()),
545 }
546}
547
548/// Decides `AlreadyCompleted` → `Dead` → `Claimed` from a [`ClaimStateRow`] — the one place this
549/// precedence is written, shared by step 3's `SELECT` path and its upsert fallback (layout Part II
550/// §8.1; previously duplicated in both).
551fn claim_from_state(row: &ClaimStateRow) -> InboxClaim {
552 if let Some(completed_at) = row.completed_at {
553 return InboxClaim::AlreadyCompleted { completed_at };
554 }
555
556 if let Some(dead_at) = row.dead_at {
557 return InboxClaim::Dead {
558 id: InboxRecordId::from_uuid(row.id),
559 attempts: claimed_attempts_recorded(row.attempts),
560 dead_at,
561 };
562 }
563
564 InboxClaim::Claimed {
565 attempt: claimed_attempt(row.attempts),
566 }
567}
568
569/// `u32::try_from`/`saturating_add`, never `as` (inbox contract §3.1): an out-of-range
570/// `attempts` is a corrupt row, not a panic — it saturates instead.
571fn claimed_attempt(attempts: i32) -> u32 {
572 u32::try_from(attempts)
573 .unwrap_or(u32::MAX)
574 .saturating_add(1)
575}
576
577/// Same conversion as [`claimed_attempt`], without the `+ 1`: [`InboxClaim::Dead`] reports the
578/// recorded count as-is, not the next attempt ordinal.
579fn claimed_attempts_recorded(attempts: i32) -> u32 {
580 u32::try_from(attempts).unwrap_or(u32::MAX)
581}
582
583/// Joins `error`'s `Display` with every `source()` in its chain, `": "`-separated — the inbox
584/// contract's "last failure's error chain". Never touches payload bytes or header values: it
585/// only ever sees what the caller's own error type chose to put in its `Display`.
586///
587/// **Must run before `fail`'s future is constructed.** `&(dyn Error + 'static)` is not `Send`
588/// (`&T: Send` requires `T: Sync`, and `dyn Error` is not `Sync`), so a plain `async fn fail`
589/// holding it across the generator's state — even before any `.await` — fails
590/// [`InboxStore::fail`]'s `+ Send` bound. This is the synchronous extraction the trait's own
591/// rustdoc points implementors to.
592fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
593 let mut out = error.to_string();
594 let mut source = error.source();
595
596 while let Some(err) = source {
597 out.push_str(": ");
598 out.push_str(&err.to_string());
599 source = err.source();
600 }
601
602 out
603}
604
605/// Rehydrates an [`InboxRecord`] from a raw [`InboxRow`] — shared by `find` above and
606/// `inbox_store_dead_letters::list_dead`, so it stays `pub(super)`. Rehydration is store-layer
607/// policy, unlike the row shape itself, which belongs to the concern layer (`rows::InboxRow`).
608///
609/// # Errors
610///
611/// [`PostgresInboxError::Database`] wrapping a decode failure if `row.scope`/`row.correlation_id`
612/// no longer satisfy the type's own validation — only reachable if the schema and the type's
613/// invariant have drifted apart, never in ordinary operation.
614pub(super) fn build_record(row: InboxRow) -> Result<InboxRecord, PostgresInboxError> {
615 // `InboxScope::new` re-validates a value this row's own `ck_inbox_scope_len` constraint
616 // already guarantees is 1..=128 bytes, so this can only fail if the schema and the type's
617 // invariant have drifted apart — treated as a corrupt row (`Database`), never a panic.
618 let scope = InboxScope::new(row.scope).map_err(|err| PostgresInboxError::Database {
619 source: sqlx::Error::Decode(err.into()),
620 })?;
621
622 let correlation_id = row
623 .correlation_id
624 .map(CorrelationId::parse)
625 .transpose()
626 .map_err(|err| PostgresInboxError::Database {
627 source: sqlx::Error::Decode(err.into()),
628 })?;
629
630 let message_type = MessageType::from_parts(
631 row.message_type,
632 u16::try_from(row.message_version).unwrap_or(u16::MAX),
633 );
634 let message_id = MessageId::from_uuid(row.message_id);
635
636 let mut message = InboxMessage::new(message_id, &message_type)
637 .conversation(ConversationId::from_uuid(row.conversation_id));
638
639 if let Some(correlation_id) = correlation_id.as_ref() {
640 message = message.correlation(correlation_id);
641 }
642
643 if let Some(causation_id) = row.causation_id {
644 message = message.causation(MessageId::from_uuid(causation_id));
645 }
646
647 Ok(InboxRecord::builder(
648 InboxRecordId::from_uuid(row.id),
649 scope,
650 message,
651 row.received_at,
652 )
653 .updated_at(row.updated_at)
654 .completed_at(row.completed_at)
655 .dead_at(row.dead_at)
656 .attempts(u32::try_from(row.attempts).unwrap_or(u32::MAX))
657 .last_error(row.last_error)
658 .build())
659}
660
661fn to_millis(duration: std::time::Duration) -> i64 {
662 i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
663}