Skip to main content

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}