Skip to main content

reliar_store_postgres/store/
enqueue.rs

1//! [`PostgresOutboxStore`]'s [`OutboxEnqueue`] implementation.
2
3use bytes::Bytes;
4use reliar_core::{ContentType, Message, MessageId, Serializer};
5use reliar_outbox::OutboxEnqueue;
6use sqlx::{Postgres, Transaction};
7use tracing::Instrument as _;
8
9use crate::error::{EnqueueError, map_enqueue_error};
10use crate::settings::PostgresOutboxSettings;
11
12use super::PostgresOutboxStore;
13use super::schema::{restore_search_path, set_search_path};
14
15/// Writes `payload` under `content_type`, running the schema's own `search_path` handling first
16/// (set it, when configured; restore only on success). A free function, not a method: it never
17/// touches `self.serializer` — the caller, [`OutboxEnqueue::enqueue_envelope`], has already
18/// serialized `envelope.body` into `payload` by the time it calls here.
19async fn insert_enqueued<T>(
20    tx: &mut Transaction<'_, Postgres>,
21    settings: &PostgresOutboxSettings,
22    envelope: &reliar_core::Envelope<T>,
23    payload: &Bytes,
24    content_type: &ContentType,
25) -> Result<(), sqlx::Error> {
26    let restore = if settings.enqueue_sets_search_path {
27        Some(set_search_path(tx, &settings.schema).await?)
28    } else {
29        None
30    };
31
32    let result = insert_row(tx, envelope, payload, content_type).await;
33
34    // Only restore on success: a failed INSERT already aborts the transaction (25P02), so
35    // issuing another statement on it would mask the real error behind "current transaction is
36    // aborted" instead. The transaction-local scope makes skipping the restore safe — the
37    // caller's own rollback/abandonment is what actually undoes it.
38    if result.is_ok()
39        && let Some(previous) = restore
40    {
41        restore_search_path(tx, &previous).await?;
42    }
43
44    result
45}
46
47/// Generic over the envelope body `T` — `T: Message` is never needed here, only
48/// `envelope.message_type` (the promoted `message_type`/`message_version` columns), which every
49/// `Envelope<T>` carries regardless of `T`.
50async fn insert_row<T>(
51    tx: &mut Transaction<'_, Postgres>,
52    envelope: &reliar_core::Envelope<T>,
53    payload: &Bytes,
54    content_type: &ContentType,
55) -> Result<(), sqlx::Error> {
56    let corr = &envelope.metadata.correlation;
57    let sent_at_ms = envelope
58        .metadata
59        .delivery
60        .sent_at
61        .map(crate::records::encode_epoch_millis);
62    let rest = crate::records::MetadataRest {
63        trace: crate::records::TraceRest {
64            traceparent: envelope.metadata.trace.traceparent.clone(),
65            tracestate: envelope.metadata.trace.tracestate.clone(),
66        },
67        routing: crate::records::RoutingRest {
68            source: envelope
69                .metadata
70                .routing
71                .source
72                .as_ref()
73                .map(|v| v.as_str().to_owned()),
74            destination: envelope
75                .metadata
76                .routing
77                .destination
78                .as_ref()
79                .map(|v| v.as_str().to_owned()),
80            reply_to: envelope
81                .metadata
82                .routing
83                .reply_to
84                .as_ref()
85                .map(|v| v.as_str().to_owned()),
86        },
87        delivery: crate::records::DeliveryRest {
88            sent_at_ms,
89            deduplication_id: envelope.metadata.delivery.deduplication_id.clone(),
90        },
91    };
92    // An empty remainder is written as SQL NULL, not '{}', so pending rows stay small.
93    let metadata_json = if rest.trace.traceparent.is_none()
94        && rest.trace.tracestate.is_none()
95        && rest.routing.source.is_none()
96        && rest.routing.destination.is_none()
97        && rest.routing.reply_to.is_none()
98        && rest.delivery.sent_at_ms.is_none()
99        && rest.delivery.deduplication_id.is_none()
100    {
101        None
102    } else {
103        // `MetadataRest`'s fields are now all plain owned `String`/`i64`/`Option` values (no
104        // RFC3339 formatting) — `serde_json::to_value` is total over this
105        // shape. The fallback is unreachable in practice; kept non-panicking rather than
106        // `.expect()`'d away, since a panic on the enqueue path is never acceptable. `.ok()` rather than a
107        // `Value::Null` fallback: on the unreachable error branch this writes SQL `NULL` — the
108        // same "no remainder" shape as the empty-check above — rather than a JSON `null` a reader
109        // would then have to treat as yet another poison case.
110        serde_json::to_value(&rest).ok()
111    };
112
113    let headers_json = envelope.headers().filter(|h| !h.is_empty()).map(|h| {
114        let map: serde_json::Map<String, serde_json::Value> = h
115            .iter()
116            .map(|(k, v)| (k.to_owned(), serde_json::Value::String(v.to_owned())))
117            .collect();
118
119        serde_json::Value::Object(map)
120    });
121
122    sqlx::query!(
123        r#"INSERT INTO outbox (
124             id, message_type, message_version,
125             correlation_id, conversation_id, causation_id, request_id,
126             content_type, payload, tenant_id, expires_at, ordering_key,
127             metadata, headers, available_at
128           ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, now())"#,
129        envelope.id.as_uuid(),
130        envelope.message_type.name(),
131        i32::from(envelope.message_type.version()),
132        corr.correlation_id
133            .as_ref()
134            .map(reliar_core::CorrelationId::as_str),
135        corr.conversation_id.as_uuid(),
136        corr.causation_id.map(|id| id.as_uuid()),
137        corr.request_id.map(|id| id.as_uuid()),
138        content_type.as_str(),
139        &payload[..],
140        envelope.metadata.tenant_id.as_deref(),
141        envelope.metadata.delivery.expires_at,
142        // No writer sets `ordering_key` in this release — `Ordering::PerKey` is a configuration
143        // error — but the column and its bind stay so a later `PerKey` writer needs no
144        // migration. The statement text is unchanged from when this bound `options.ordering_key`,
145        // so `.sqlx/` needs no regeneration.
146        None::<&str>,
147        metadata_json,
148        headers_json,
149    )
150    .execute(&mut **tx)
151    .await?;
152
153    Ok(())
154}
155
156/// [`PostgresOutboxStore`]'s [`OutboxEnqueue`] implementation: reuses `insert_enqueued`'s
157/// `search_path` handling and its `insert_row` helper. Implements only
158/// [`OutboxEnqueue::enqueue_envelope`] — the provided `enqueue` (bare `T: Message`) calls back
159/// into it, so the `reliar.outbox.enqueue` span fires exactly once per row for either spelling
160/// (ADR 0037 amendment A).
161///
162/// **`Ser: 'static`** — needed because the method reaches `self.serializer` (held as `Arc<Ser>`)
163/// across the `.await` in `insert_enqueued`; without it the future fails to type-check (a
164/// borrowed type must outlive the generic parameters it references).
165/// Every concrete serializer (`JsonSerializer` or any owned one) is `'static`, so no real host is
166/// excluded.
167///
168/// **A single lifetime, `'c`, quantified by the impl.** With `&mut Tx` in the trait's own method
169/// signature the reborrow lifetime is quantified by the method itself, so the higher-ranked
170/// "implementation is not general enough" trap an earlier `OutboxEnqueueIn<&'a mut
171/// Transaction<'c, _>>` shape had — where an explicit, implied-looking `where 'c: 'a` bound broke
172/// every `tokio::spawn`/Axum call site — cannot arise here; there is no second lifetime to
173/// accidentally bound. That regression guard lives as
174/// `outbox_enqueue::enqueue_is_send_through_tokio_spawn` in this crate's Postgres suite.
175///
176/// Renamed from `OutboxStaging`/`stage` in 0.4.0; the store's own typed `enqueue`/`enqueue_with`
177/// were folded into this impl since no inherent method may share a name with a trait method — a
178/// caller now needs `use reliar_outbox::OutboxEnqueue;` in scope to call
179/// `store.enqueue(..)`/`store.enqueue_envelope(..)`. The trait's serialized twin,
180/// `enqueue_serialized`, was cut before shipping.
181impl<'c, Ser> OutboxEnqueue<Transaction<'c, Postgres>> for PostgresOutboxStore<Ser>
182where
183    Ser: Serializer + Send + Sync + 'static,
184{
185    type Error = EnqueueError<Ser::Error>;
186
187    /// Serializes `envelope.body` with this store's configured `Serializer` and writes the
188    /// serializer's own `content_type`. Plain `INSERT`, **no `ON CONFLICT`**: a reused
189    /// `MessageId` aborts the caller's transaction rather than silently losing a message.
190    ///
191    /// # Errors
192    ///
193    /// [`EnqueueError::Serialize`] if the configured `Serializer` rejects the body,
194    /// [`EnqueueError::Duplicate`] for a reused [`MessageId`] (`pk_outbox` violation), or
195    /// [`EnqueueError::Database`] for any other `sqlx` failure.
196    ///
197    /// [`EnqueueError::Duplicate`]/[`EnqueueError::Database`] leave `tx` aborted: the failed
198    /// `INSERT` puts the PostgreSQL transaction in the aborted state, so PostgreSQL rejects every
199    /// subsequent statement on it, and every earlier write in that transaction is rolled back at
200    /// commit. [`EnqueueError::Serialize`] is returned before any statement runs, so `tx` is
201    /// untouched and stays usable.
202    // Block form, not `async fn` (conventions §3(b)): the trait bounds neither `T: Send` nor
203    // `Tx: Send`, and an `async fn`'s parameters live in its own generator's *unstarted* state
204    // regardless of when the body consumes them, so it would need both. Serializing and dropping
205    // the typed body (`map_body(|_| ())`) happen synchronously, before any future is constructed,
206    // so the `async` block below is built only after `T` is gone — its captured state (`tx`,
207    // `envelope: Envelope<()>`, `payload`) is `Send` independently of `T`.
208    fn enqueue_envelope<T: Message + Sync>(
209        &self,
210        tx: &mut Transaction<'c, Postgres>,
211        typed_envelope: reliar_core::Envelope<T>,
212    ) -> impl Future<Output = Result<MessageId, Self::Error>> + Send {
213        let span = tracing::debug_span!(
214            "reliar.outbox.enqueue",
215            message.id = %typed_envelope.id,
216            message.type = %typed_envelope.message_type,
217        );
218        // Entered, not `.instrument()`ed: serialization is synchronous and must run before the
219        // async block below exists, but a serializer's own `tracing` events still belong inside
220        // this span. The guard is dropped before the block is built.
221        let payload = {
222            let _guard = span.enter();
223
224            self.serializer
225                .serialize(&typed_envelope.body)
226                .map_err(|source| EnqueueError::Serialize { source })
227        };
228        // `insert_enqueued` never reads `envelope.body` anyway.
229        let envelope = typed_envelope.map_body(|_| ());
230
231        async move {
232            let payload = payload?;
233
234            insert_enqueued(tx, &self.settings, &envelope, &payload, self.content_type())
235                .await
236                .map_err(|source| map_enqueue_error(envelope.id, source))?;
237
238            Ok(envelope.id)
239        }
240        .instrument(span)
241    }
242}