backbone_outbox/relay.rs
1//! The relay: drain unpublished outbox rows onto the bus, at-least-once.
2
3use chrono::{DateTime, Utc};
4use sqlx::PgPool;
5use uuid::Uuid;
6
7use crate::error::{validate_schema, OutboxError, Result};
8use crate::record::OutboxRecord;
9
10#[derive(sqlx::FromRow)]
11struct OutboxRow {
12 id: Uuid,
13 event_type: String,
14 aggregate_type: String,
15 aggregate_id: String,
16 company_id: Uuid,
17 payload: serde_json::Value,
18 occurred_at: DateTime<Utc>,
19 correlation_id: Option<String>,
20 causation_id: Option<String>,
21 version: i32,
22 org_unit_id: Option<Uuid>,
23}
24impl OutboxRow {
25 fn into_record(self) -> OutboxRecord {
26 OutboxRecord {
27 id: self.id,
28 event_type: self.event_type,
29 aggregate_type: self.aggregate_type,
30 aggregate_id: self.aggregate_id,
31 company_id: self.company_id,
32 payload: self.payload,
33 occurred_at: self.occurred_at,
34 correlation_id: self.correlation_id,
35 causation_id: self.causation_id,
36 version: self.version,
37 org_unit_id: self.org_unit_id,
38 }
39 }
40}
41
42/// Drain up to `batch` un-published events from `schema.outbox_events`, hand each to `publish`, and mark
43/// the successfully-published ones. Returns the number published this pass.
44///
45/// **The relay never holds a transaction across `publish`.** The batch is read with a short-lived
46/// connection that is returned before any `publish` runs; each record is then published while holding
47/// *no* relay connection, and marked with an idempotent per-row UPDATE (`… WHERE id=$1 AND published_at
48/// IS NULL`). This is deliberate: the shipped consumers reborrow the *same* pool inside `publish` (their
49/// own tx), so a relay that held its connection + the outbox row locks across `publish` would need two
50/// pool connections per in-flight event and self-deadlock on a bounded pool (maturity council
51/// 2026-07-07). By not spanning `publish`, each DB step borrows and returns a connection independently —
52/// safe even at `max_connections = 1`.
53///
54/// **At-least-once**: `published_at` is set only after `publish` returns `Ok`; a crash between publish
55/// and mark redelivers the row (the consumer's inbox dedups it). Concurrent relay workers are not row-
56/// locked, so they may double-*deliver* a row — harmless (the inbox makes the *effect* exactly-once);
57/// a lease-based claim to trim duplicate deliveries is a future optimization. A `publish` error leaves
58/// the row for the next pass.
59pub async fn drain_once<F, Fut>(pool: &PgPool, schema: &str, batch: i64, publish: F) -> Result<usize>
60where
61 F: Fn(OutboxRecord) -> Fut,
62 Fut: std::future::Future<Output = std::result::Result<(), OutboxError>>,
63{
64 validate_schema(schema)?;
65 // Short-lived read: borrows a connection only for the SELECT, then returns it to the pool.
66 let rows: Vec<OutboxRow> = sqlx::query_as(&format!(
67 r#"SELECT id, event_type, aggregate_type, aggregate_id, company_id, payload, occurred_at,
68 correlation_id, causation_id, version, org_unit_id
69 FROM {schema}.outbox_events
70 WHERE published_at IS NULL AND failed_at IS NULL
71 ORDER BY occurred_at, id
72 LIMIT $1"#
73 ))
74 .bind(batch)
75 .fetch_all(pool)
76 .await?;
77
78 let mut published = 0usize;
79 // Publish failures leave their rows for the next pass. Count them, and say so once per pass:
80 // a transport that refuses everything must be visible, without a line per record per second.
81 let mut refused = 0usize;
82 let mut first_refusal: Option<String> = None;
83 for row in rows {
84 let rec = row.into_record();
85 let id = rec.id;
86 match publish(rec).await {
87 // No relay connection is held here — the consumer may freely reborrow the pool.
88 Ok(()) => {
89 sqlx::query(&format!(
90 "UPDATE {schema}.outbox_events SET published_at=now() WHERE id=$1 AND published_at IS NULL"
91 ))
92 .bind(id)
93 .execute(pool)
94 .await?;
95 published += 1;
96 }
97 // Leave the row un-published; the next pass retries it.
98 Err(OutboxError::Publish(reason)) => {
99 refused += 1;
100 first_refusal.get_or_insert(reason);
101 }
102 // The transport gave up on this record. Marking it published would report a delivery
103 // that never happened and erase the only trace that work was dropped; leaving it pending
104 // would re-hand the same doomed record on every pass forever. Mark it dead instead:
105 // visible in the table, excluded from the drain, and re-emittable once the cause is fixed.
106 Err(OutboxError::Exhausted(reason)) => {
107 sqlx::query(&format!(
108 "UPDATE {schema}.outbox_events SET failed_at=now(), failure_reason=$2
109 WHERE id=$1 AND published_at IS NULL"
110 ))
111 .bind(id)
112 .bind(&reason)
113 .execute(pool)
114 .await?;
115 }
116 Err(e) => return Err(e),
117 }
118 }
119 if refused > 0 {
120 tracing::warn!(
121 schema,
122 refused,
123 reason = first_refusal.as_deref().unwrap_or_default(),
124 "outbox relay: the transport refused records this pass; they stay pending and retry"
125 );
126 }
127 Ok(published)
128}
129
130/// A record the transport gave up on, as an operator sees it.
131#[derive(Debug, Clone, sqlx::FromRow)]
132pub struct DeadRecord {
133 /// The outbox row id — pass it to [`reemit`].
134 pub id: Uuid,
135 /// The event that was never delivered.
136 pub event_type: String,
137 /// What it was about.
138 pub aggregate_type: String,
139 /// Which one.
140 pub aggregate_id: String,
141 /// When the transport gave up.
142 pub failed_at: DateTime<Utc>,
143 /// Why, as the transport reported it.
144 pub failure_reason: Option<String>,
145}
146
147/// List the records the transport gave up on, oldest first.
148///
149/// These are cross-module effects that did not happen. Nothing retries them on its own — that is the
150/// point of a dead letter — so an operator reads this, fixes the cause, and calls [`reemit`].
151pub async fn list_dead(pool: &PgPool, schema: &str, limit: i64) -> Result<Vec<DeadRecord>> {
152 validate_schema(schema)?;
153 let rows = sqlx::query_as::<_, DeadRecord>(&format!(
154 r#"SELECT id, event_type, aggregate_type, aggregate_id, failed_at, failure_reason
155 FROM {schema}.outbox_events
156 WHERE failed_at IS NOT NULL
157 ORDER BY failed_at, id
158 LIMIT $1"#
159 ))
160 .bind(limit)
161 .fetch_all(pool)
162 .await?;
163 Ok(rows)
164}
165
166/// Hand a dead record back to the relay. Returns whether a row was revived.
167///
168/// Clears the dead mark so the next drain offers the record to the transport again. Deliberately
169/// manual: an event is dead because delivery kept failing, and re-emitting before the cause is fixed
170/// just re-kills it. A row that was published in the meantime is left alone.
171pub async fn reemit(pool: &PgPool, schema: &str, id: Uuid) -> Result<bool> {
172 validate_schema(schema)?;
173 let done = sqlx::query(&format!(
174 "UPDATE {schema}.outbox_events SET failed_at=NULL, failure_reason=NULL
175 WHERE id=$1 AND failed_at IS NOT NULL AND published_at IS NULL"
176 ))
177 .bind(id)
178 .execute(pool)
179 .await?;
180 Ok(done.rows_affected() == 1)
181}
182
183/// Hand every dead record back to the relay. Returns how many were revived.
184///
185/// For the case where one shared cause killed a batch — the consumer was misconfigured, a downstream
186/// service was down — and the fix clears all of them at once.
187pub async fn reemit_all(pool: &PgPool, schema: &str) -> Result<u64> {
188 validate_schema(schema)?;
189 let done = sqlx::query(&format!(
190 "UPDATE {schema}.outbox_events SET failed_at=NULL, failure_reason=NULL
191 WHERE failed_at IS NOT NULL AND published_at IS NULL"
192 ))
193 .execute(pool)
194 .await?;
195 Ok(done.rows_affected())
196}
197
198/// Drain repeatedly until the outbox is empty (bounded by `max_passes`). Convenience for tests and
199/// one-shot flushes; a production relay loops `drain_once` on a `backbone-jobs` schedule instead.
200pub async fn drain_all<F, Fut>(
201 pool: &PgPool,
202 schema: &str,
203 batch: i64,
204 max_passes: usize,
205 publish: F,
206) -> Result<usize>
207where
208 F: Fn(OutboxRecord) -> Fut,
209 Fut: std::future::Future<Output = std::result::Result<(), OutboxError>>,
210{
211 let mut total = 0;
212 for _ in 0..max_passes {
213 let n = drain_once(pool, schema, batch, &publish).await?;
214 total += n;
215 if n == 0 {
216 break;
217 }
218 }
219 Ok(total)
220}