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}