Skip to main content

reliar_store_postgres/inbox/
inbox_store.rs

1//! The [`PostgresInboxStore`] type itself: fields, construction (`connect`), and the
2//! [`reliar_inbox::InboxStore`] implementation, which delegates each method's body to its concern
3//! module (`claim`, `outcomes`, `purge`) — mirroring `outbox::outbox_store`'s shape.
4
5use reliar_core::MessageId;
6use reliar_inbox::InboxStore;
7use reliar_inbox::{
8    InboxClaim, InboxFailure, InboxMessage, InboxPurgeReport, InboxPurgeRequest, InboxRecord,
9    InboxScope,
10};
11use sqlx::{PgPool, Postgres, Transaction};
12use tracing::Instrument as _;
13
14use crate::connection::schema;
15use crate::settings::PostgresInboxSettings;
16
17use super::error::PostgresInboxError;
18use super::{claim, outcomes, purge};
19
20/// Reliar's PostgreSQL inbox provider (inbox contract §3). A **separate type** from
21/// [`crate::PostgresOutboxStore`]: the inbox stores no payload, so it needs no `Serializer` type
22/// parameter and none of the outbox's lease/ordering/retention settings. Same crate, same schema,
23/// same [`crate::migrate`]. Cheap to clone — wraps a [`PgPool`]; no outer `Arc` required.
24#[derive(Clone, Debug)]
25#[non_exhaustive]
26pub struct PostgresInboxStore {
27    pub(super) pool: PgPool,
28
29    pub(super) settings: PostgresInboxSettings,
30}
31
32impl PostgresInboxStore {
33    /// Verifies the server version (ADR 0041) and then the `search_path` (ADR 0018/§20.1), in
34    /// that order — exactly as [`crate::PostgresOutboxStore::connect`] does: a wrong server
35    /// version explains a missing relation, and the reverse is never true. Logs a
36    /// `tracing::warn!` when a same-named table also exists in another schema on the path.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`PostgresInboxError::UnsupportedServerVersion`],
41    /// [`PostgresInboxError::NotMigrated`] (the relation itself is missing),
42    /// [`PostgresInboxError::SchemaNotOnSearchPath`] (it exists, but not on the configured
43    /// schema), or [`PostgresInboxError::Database`] for a connection failure during verification.
44    ///
45    /// ```no_run
46    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
47    /// use reliar_store_postgres::{PostgresInboxSettings, PostgresInboxStore};
48    /// use sqlx::postgres::PgPoolOptions;
49    ///
50    /// let pool = PgPoolOptions::new()
51    ///     .connect(&std::env::var("DATABASE_URL")?)
52    ///     .await?;
53    /// let store = PostgresInboxStore::connect(pool, PostgresInboxSettings::default()).await?;
54    /// # let _ = store;
55    /// # Ok(())
56    /// # }
57    /// ```
58    pub async fn connect(
59        pool: PgPool,
60        settings: PostgresInboxSettings,
61    ) -> Result<Self, PostgresInboxError> {
62        settings.validate()?;
63
64        let detected = crate::connection::version::detected_server_version_num(&pool).await?;
65
66        if detected < crate::MIN_SERVER_VERSION_NUM {
67            return Err(PostgresInboxError::UnsupportedServerVersion {
68                required: crate::MIN_SERVER_VERSION_NUM,
69                detected,
70            });
71        }
72
73        let check = schema::verify_table_schema(&pool, &settings.schema, "inbox", &[]).await?;
74        let resolved_here = check.resolved_schema.as_deref() == Some(settings.schema.as_str());
75
76        if !resolved_here {
77            // Mirrors `PostgresOutboxStore::connect`'s own ordering: a missing relation is a
78            // sharper, more actionable answer than "wrong search_path" when it is in fact the
79            // cause — `migrate()` never ran, rather than merely resolving somewhere else.
80            if !check.configured_exists {
81                return Err(PostgresInboxError::NotMigrated {
82                    schema: settings.schema,
83                });
84            }
85
86            return Err(PostgresInboxError::SchemaNotOnSearchPath {
87                configured: settings.schema,
88                observed: check.search_path,
89            });
90        }
91
92        let others = schema::other_table_schemas(&pool, &settings.schema, "inbox").await?;
93
94        if !others.is_empty() {
95            tracing::warn!(
96                configured_schema = %settings.schema,
97                other_schemas = ?others,
98                "a table named `inbox` also exists outside the configured schema; an \
99                 unqualified reference from another session could resolve to it"
100            );
101        }
102
103        Ok(Self { pool, settings })
104    }
105
106    /// Issues `SET LOCAL statement_timeout` on an already-open transaction — the shared half of
107    /// every `Duration::ZERO`-vs-non-zero split in `outcomes::fail` and `purge`'s pool-side
108    /// statements, mirroring [`crate::PostgresOutboxStore::set_local_timeout`].
109    pub(super) async fn set_local_timeout(
110        &self,
111        tx: &mut Transaction<'_, Postgres>,
112    ) -> Result<(), PostgresInboxError> {
113        let timeout_ms = i64::try_from(self.settings.statement_timeout.as_millis())
114            .unwrap_or(i64::MAX)
115            .to_string();
116
117        sqlx::query_scalar!(
118            "SELECT set_config('statement_timeout', $1, true)",
119            timeout_ms
120        )
121        .fetch_one(&mut **tx)
122        .await?;
123
124        Ok(())
125    }
126}
127
128impl<'c> InboxStore<Transaction<'c, Postgres>> for PostgresInboxStore {
129    type Error = PostgresInboxError;
130
131    /// The three-statement claim (inbox contract §3.1): the in-flight advisory-lock guard, the
132    /// `INSERT … ON CONFLICT DO NOTHING` claim, and — only when nothing was inserted — the state
133    /// read that decides `AlreadyCompleted`/`Dead` vs. `Claimed`.
134    // Block form — reason (a): `inbox.scope`/`message.id`/`message.type` must be recorded on the
135    // `reliar.inbox.claim` span before the first statement runs (inbox contract §4), so the span
136    // itself has to exist before the async block that runs those statements does.
137    fn claim(
138        &self,
139        tx: &mut Transaction<'c, Postgres>,
140        scope: &InboxScope,
141        message: InboxMessage<'_>,
142    ) -> impl Future<Output = Result<InboxClaim, Self::Error>> + Send {
143        let span = tracing::debug_span!(
144            "reliar.inbox.claim",
145            inbox.scope = %scope,
146            message.id = %message.id,
147            // `r#type`, not `type` — `type` is a Rust keyword; tracing strips the `r#` prefix
148            // from the field name, so this still renders as `message.type` (inbox contract §4).
149            message.r#type = %message.message_type,
150            inbox.outcome = tracing::field::Empty,
151            inbox.attempt = tracing::field::Empty,
152            inbox.attempts = tracing::field::Empty,
153            inbox.record_id = tracing::field::Empty,
154        );
155        let recording_span = span.clone();
156
157        async move {
158            let result = claim::claim(self, tx, scope, message).await;
159
160            if let Ok(claim) = &result {
161                record_claim_outcome(&recording_span, claim);
162            }
163
164            result
165        }
166        .instrument(span)
167    }
168
169    /// Marks the row completed in the caller's transaction. Zero rows affected ⇒ `NotClaimed`
170    /// (including a row that has since gone dead — the guard is `completed_at IS NULL AND
171    /// dead_at IS NULL`).
172    // Block form — reason (a): the span's `inbox.scope`/`message.id` fields (inbox contract §4)
173    // must be created before the statement they describe runs.
174    fn complete(
175        &self,
176        tx: &mut Transaction<'c, Postgres>,
177        scope: &InboxScope,
178        id: MessageId,
179    ) -> impl Future<Output = Result<(), Self::Error>> + Send {
180        let span = tracing::debug_span!(
181            "reliar.inbox.complete",
182            inbox.scope = %scope,
183            message.id = %id,
184        );
185
186        async move { outcomes::complete(self, tx, scope, id).await }.instrument(span)
187    }
188
189    /// Records a failed attempt on this store's own pool, guarded by `completed_at IS NULL`, and
190    /// bounds it at `settings.max_attempts` atomically with the increment (ADR 0042 A.2.4).
191    // Block form: `error: &(dyn Error + 'static)` is not `Send`, so its `Display` chain must be
192    // extracted into an owned `String` before the async block is built, never inside a plain
193    // `async fn` (conventions §3(b); the trait's own rustdoc calls this out) — also reason (a):
194    // the `reliar.inbox.fail` span's entry fields must be recorded before the statement runs.
195    fn fail(
196        &self,
197        scope: &InboxScope,
198        message: InboxMessage<'_>,
199        error: &(dyn std::error::Error + 'static),
200    ) -> impl Future<Output = Result<InboxFailure, Self::Error>> + Send {
201        let last_error = outcomes::format_error_chain(error);
202        let span = tracing::debug_span!(
203            "reliar.inbox.fail",
204            inbox.scope = %scope,
205            message.id = %message.id,
206            // `r#type`, not `type` — `type` is a Rust keyword; tracing strips the `r#` prefix
207            // from the field name, so this still renders as `message.type` (inbox contract §4).
208            message.r#type = %message.message_type,
209            inbox.outcome = tracing::field::Empty,
210            inbox.attempts = tracing::field::Empty,
211            inbox.record_id = tracing::field::Empty,
212        );
213        let recording_span = span.clone();
214
215        async move {
216            let result = outcomes::fail(self, scope, message, last_error).await;
217
218            if let Ok(failure) = &result {
219                record_fail_outcome(&recording_span, failure);
220            }
221
222            result
223        }
224        .instrument(span)
225    }
226
227    /// Reads a row for diagnostics. No Reliar code path calls it. No span — the inbox contract's
228    /// observability table (§4) does not list `find`, since no Reliar code path calls it.
229    async fn find(
230        &self,
231        scope: &InboxScope,
232        id: MessageId,
233    ) -> Result<Option<InboxRecord>, Self::Error> {
234        purge::find(self, scope, id).await
235    }
236
237    /// One bounded pass, three statements, each capped at `request.batch_size`: the
238    /// completed-row, incomplete-row and dead-row deletes.
239    // Block form — reason (a): the `reliar.inbox.purge` span must exist before the three
240    // statements it will report on run.
241    fn purge(
242        &self,
243        request: InboxPurgeRequest,
244    ) -> impl Future<Output = Result<InboxPurgeReport, Self::Error>> + Send {
245        let span = tracing::debug_span!(
246            "reliar.inbox.purge",
247            inbox.completed_deleted = tracing::field::Empty,
248            inbox.incomplete_deleted = tracing::field::Empty,
249            inbox.dead_deleted = tracing::field::Empty,
250        );
251        let recording_span = span.clone();
252
253        async move {
254            let result = purge::purge(self, request).await;
255
256            if let Ok(report) = &result {
257                recording_span.record("inbox.completed_deleted", report.completed_deleted);
258                recording_span.record("inbox.incomplete_deleted", report.incomplete_deleted);
259                recording_span.record("inbox.dead_deleted", report.dead_deleted);
260            }
261
262            result
263        }
264        .instrument(span)
265    }
266}
267
268/// The `reliar.inbox.claim` span's outcome fields (ADR 0042 Amendment C.5): `inbox.outcome`
269/// always, `inbox.attempt` on `Claimed`, `inbox.record_id` + `inbox.attempts` on `Dead`. Never
270/// recorded on an `Err` path — the caller's own log carries the failure.
271fn record_claim_outcome(span: &tracing::Span, claim: &InboxClaim) {
272    match claim {
273        InboxClaim::Claimed { attempt } => {
274            span.record("inbox.outcome", "claimed");
275            span.record("inbox.attempt", attempt);
276        }
277        InboxClaim::AlreadyCompleted { .. } => {
278            span.record("inbox.outcome", "already_completed");
279        }
280        InboxClaim::InProgress => {
281            span.record("inbox.outcome", "in_progress");
282        }
283        InboxClaim::Dead { id, attempts, .. } => {
284            span.record("inbox.outcome", "dead");
285            span.record("inbox.record_id", tracing::field::display(id));
286            span.record("inbox.attempts", attempts);
287        }
288        // `InboxClaim` is `#[non_exhaustive]`; every variant this crate's contract defines is
289        // matched above.
290        _ => {}
291    }
292}
293
294/// The `reliar.inbox.fail` span's outcome fields (inbox contract §4): `inbox.outcome` always,
295/// `inbox.attempts`/`inbox.record_id` where the variant carries them. Never recorded on an `Err`
296/// path.
297fn record_fail_outcome(span: &tracing::Span, failure: &InboxFailure) {
298    match failure {
299        InboxFailure::Recorded { attempts } => {
300            span.record("inbox.outcome", "recorded");
301            span.record("inbox.attempts", attempts);
302        }
303        InboxFailure::Dead {
304            id,
305            attempts,
306            dead_at: _,
307        } => {
308            span.record("inbox.outcome", "dead");
309            span.record("inbox.attempts", attempts);
310            span.record("inbox.record_id", tracing::field::display(id));
311        }
312        InboxFailure::AlreadyCompleted => {
313            span.record("inbox.outcome", "already_completed");
314        }
315        // `InboxFailure` is `#[non_exhaustive]`; every variant this crate's contract defines is
316        // matched above.
317        _ => {}
318    }
319}