Skip to main content

reliar_store_postgres/outbox/
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 super::error::{EnqueueError, map_enqueue_error};
10use crate::settings::PostgresOutboxSettings;
11
12use super::PostgresOutboxStore;
13use crate::connection::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    // Omits `id`: the surrogate row identity fires from `DEFAULT uuidv7()` (ADR 0044 §1) — no
123    // caller needs it before the row is read back, so nothing here mints or binds one.
124    sqlx::query!(
125        r#"INSERT INTO outbox (
126             message_id, message_type, message_version,
127             correlation_id, conversation_id, causation_id, request_id,
128             content_type, payload, tenant_id, expires_at, ordering_key,
129             metadata, headers, available_at
130           ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, now())"#,
131        envelope.id.as_uuid(),
132        envelope.message_type.name(),
133        i32::from(envelope.message_type.version()),
134        corr.correlation_id
135            .as_ref()
136            .map(reliar_core::CorrelationId::as_str),
137        corr.conversation_id.as_uuid(),
138        corr.causation_id.map(|id| id.as_uuid()),
139        corr.request_id.map(|id| id.as_uuid()),
140        content_type.as_str(),
141        &payload[..],
142        envelope.metadata.tenant_id.as_deref(),
143        envelope.metadata.delivery.expires_at,
144        // No writer sets `ordering_key` in this release — `Ordering::PerKey` is a configuration
145        // error — but the column and its bind stay so a later `PerKey` writer needs no
146        // migration. The statement text is unchanged from when this bound `options.ordering_key`,
147        // so `.sqlx/` needs no regeneration.
148        None::<&str>,
149        metadata_json,
150        headers_json,
151    )
152    .execute(&mut **tx)
153    .await?;
154
155    Ok(())
156}
157
158/// [`PostgresOutboxStore`]'s [`OutboxEnqueue`] implementation: reuses `insert_enqueued`'s
159/// `search_path` handling and its `insert_row` helper. Implements only
160/// [`OutboxEnqueue::enqueue_envelope`] — the provided `enqueue` (bare `T: Message`) calls back
161/// into it, so the `reliar.outbox.enqueue` span fires exactly once per row for either spelling
162/// (ADR 0037 amendment A).
163///
164/// **`Ser: 'static`** — needed because the method reaches `self.serializer` (held as `Arc<Ser>`)
165/// across the `.await` in `insert_enqueued`; without it the future fails to type-check (a
166/// borrowed type must outlive the generic parameters it references).
167/// Every concrete serializer (`JsonSerializer` or any owned one) is `'static`, so no real host is
168/// excluded.
169///
170/// **A single lifetime, `'c`, quantified by the impl.** With `&mut Tx` in the trait's own method
171/// signature the reborrow lifetime is quantified by the method itself, so the higher-ranked
172/// "implementation is not general enough" trap an earlier `OutboxEnqueueIn<&'a mut
173/// Transaction<'c, _>>` shape had — where an explicit, implied-looking `where 'c: 'a` bound broke
174/// every `tokio::spawn`/Axum call site — cannot arise here; there is no second lifetime to
175/// accidentally bound. That regression guard lives as
176/// `outbox_enqueue::enqueue_is_send_through_tokio_spawn` in this crate's Postgres suite.
177///
178/// Renamed from `OutboxStaging`/`stage` in 0.4.0; the store's own typed `enqueue`/`enqueue_with`
179/// were folded into this impl since no inherent method may share a name with a trait method — a
180/// caller now needs `use reliar_outbox::OutboxEnqueue;` in scope to call
181/// `store.enqueue(..)`/`store.enqueue_envelope(..)`. The trait's serialized twin,
182/// `enqueue_serialized`, was cut before shipping.
183impl<'c, Ser> OutboxEnqueue<Transaction<'c, Postgres>> for PostgresOutboxStore<Ser>
184where
185    Ser: Serializer + Send + Sync + 'static,
186{
187    type Error = EnqueueError<Ser::Error>;
188
189    /// Serializes `envelope.body` with this store's configured `Serializer` and writes the
190    /// serializer's own `content_type`. Plain `INSERT`, **no `ON CONFLICT`**: a reused
191    /// `MessageId` aborts the caller's transaction rather than silently losing a message.
192    ///
193    /// # Errors
194    ///
195    /// [`EnqueueError::Serialize`] if the configured `Serializer` rejects the body,
196    /// [`EnqueueError::Duplicate`] for a reused [`MessageId`] (`ix_outbox_message_id` violation,
197    /// ADR 0044 §1), or [`EnqueueError::Database`] for any other `sqlx` failure.
198    ///
199    /// [`EnqueueError::Duplicate`]/[`EnqueueError::Database`] leave `tx` aborted: the failed
200    /// `INSERT` puts the PostgreSQL transaction in the aborted state, so PostgreSQL rejects every
201    /// subsequent statement on it, and every earlier write in that transaction is rolled back at
202    /// commit. [`EnqueueError::Serialize`] is returned before any statement runs, so `tx` is
203    /// untouched and stays usable.
204    // Block form, not `async fn` (conventions §3(b)): the trait bounds neither `T: Send` nor
205    // `Tx: Send`, and an `async fn`'s parameters live in its own generator's *unstarted* state
206    // regardless of when the body consumes them, so it would need both. Serializing and dropping
207    // the typed body (`map_body(|_| ())`) happen synchronously, before any future is constructed,
208    // so the `async` block below is built only after `T` is gone — its captured state (`tx`,
209    // `envelope: Envelope<()>`, `payload`) is `Send` independently of `T`.
210    fn enqueue_envelope<T: Message + Sync>(
211        &self,
212        tx: &mut Transaction<'c, Postgres>,
213        typed_envelope: reliar_core::Envelope<T>,
214    ) -> impl Future<Output = Result<MessageId, Self::Error>> + Send {
215        let span = tracing::debug_span!(
216            "reliar.outbox.enqueue",
217            message.id = %typed_envelope.id,
218            message.type = %typed_envelope.message_type,
219        );
220        // Entered, not `.instrument()`ed: serialization is synchronous and must run before the
221        // async block below exists, but a serializer's own `tracing` events still belong inside
222        // this span. The guard is dropped before the block is built.
223        let payload = {
224            let _guard = span.enter();
225
226            self.serializer
227                .serialize(&typed_envelope.body)
228                .map_err(|source| EnqueueError::Serialize { source })
229        };
230        // `insert_enqueued` never reads `envelope.body` anyway.
231        let envelope = typed_envelope.map_body(|_| ());
232
233        async move {
234            let payload = payload?;
235
236            insert_enqueued(tx, &self.settings, &envelope, &payload, self.content_type())
237                .await
238                .map_err(|source| map_enqueue_error(envelope.id, source))?;
239
240            Ok(envelope.id)
241        }
242        .instrument(span)
243    }
244}