ai_crew_sync/store/backend.rs
1//! The messaging backend boundary, and the outbox that exercises it.
2//!
3//! Phase 2's path is synchronous: body, recipients and receipts commit in one
4//! Postgres transaction, so `stored` is true because that transaction
5//! committed and there is nothing to reconcile. That remains the default and
6//! is not slowed down by anything here.
7//!
8//! What that path cannot exercise is the shape every external store
9//! introduces: acceptance and persistence are two events, and the second can
10//! fail, time out, or succeed without the caller learning it did. This module
11//! adds that shape with **Postgres as the only implementation**, so the
12//! failure handling can be built and tested before there is a broker to
13//! blame for it.
14//!
15//! What the outbox deliberately does *not* do:
16//!
17//! * it never reports `stored` on acceptance. A message that is accepted and
18//! not yet confirmed says `pending_publication`, which is a different
19//! thing, and a reader sees the gap rather than a lie;
20//! * it never skips a pending earlier message when reporting a thread's
21//! state, so a cursor cannot walk past something still in flight;
22//! * it never replays a side effect. A retry presents the same `publish_key`
23//! and the adapter is expected to recognise it; a duplicate physical write
24//! resolves to one canonical locator rather than two messages.
25
26use std::time::Duration;
27
28use sqlx::PgPool;
29use uuid::Uuid;
30
31use crate::error::{BusError, BusResult};
32
33/// Where a confirmed body lives. Opaque to everything but the adapter that
34/// produced it; Postgres uses the message id itself.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct Locator(pub String);
37
38/// What an adapter reports after trying to publish.
39#[derive(Clone, Debug)]
40pub enum Published {
41 /// The backend confirmed it, and this is where it is.
42 Confirmed(Locator),
43 /// The attempt failed and may succeed later: a timeout, a refused
44 /// connection, a full queue. The slot stays and is retried.
45 Retryable(String),
46 /// It will never succeed: a payload the backend cannot accept, an
47 /// authorization failure. The slot is marked failed and stays visible.
48 Fatal(String),
49}
50
51/// One publication attempt's input.
52#[derive(Clone, Debug)]
53pub struct Envelope {
54 pub message_id: Uuid,
55 pub conversation_id: Uuid,
56 pub team_id: Uuid,
57 pub body: String,
58 /// Presented to the backend on every attempt, so an uncertain completion
59 /// can be recognised instead of duplicated.
60 pub publish_key: Uuid,
61}
62
63/// The boundary. One implementation today; a broker adapter slots in behind
64/// the same four operations without touching the callers.
65///
66/// Network-like work happens **here**, outside any database transaction: the
67/// outbox opens short transactions to lease and to settle, and never holds
68/// one across a publish.
69pub trait MessagingBackend: Send + Sync {
70 /// Name recorded on the conversation and the outbox row.
71 fn name(&self) -> &'static str;
72
73 /// Persist a body. Must be idempotent on `publish_key`: presented the
74 /// same key twice, it returns the same locator rather than storing twice.
75 fn publish(&self, envelope: Envelope) -> impl std::future::Future<Output = Published> + Send;
76
77 /// Read a body back by locator, for history.
78 ///
79 /// `message_id` is what the caller believes that locator names, and the
80 /// implementation must check it. A locator is opaque and proves
81 /// nothing: without this, one copied or guessed from another
82 /// conversation of the same team resolves to whatever body happens to
83 /// sit at that position.
84 fn fetch(
85 &self,
86 locator: &Locator,
87 message_id: Uuid,
88 ) -> impl std::future::Future<Output = BusResult<Option<String>>> + Send;
89
90 /// Drop a body the retention policy no longer keeps. Returns how many
91 /// were removed.
92 fn retain(
93 &self,
94 before: chrono::DateTime<chrono::Utc>,
95 ) -> impl std::future::Future<Output = BusResult<u64>> + Send;
96
97 /// Settle an attempt that ended without an answer.
98 ///
99 /// Takes the whole envelope, not just the key, because the honest
100 /// answer for a broker is "present this again under the same
101 /// idempotency key and read what comes back". Inside its deduplication
102 /// window that returns the original sequence; outside it, the body
103 /// lands now. Either way there is one logical message with one
104 /// canonical locator.
105 ///
106 /// What an implementation must **not** do is probe with a throwaway
107 /// message under the real key. A probe that can answer at all is a
108 /// probe that was stored, and it takes the key the body needed — the
109 /// locator then names an empty message and the body never lands.
110 ///
111 /// `None` means the backend holds nothing and nothing was written, so
112 /// the ordinary retry path is safe.
113 fn reconcile(
114 &self,
115 envelope: &Envelope,
116 ) -> impl std::future::Future<Output = BusResult<Option<Locator>>> + Send;
117}
118
119/// The Postgres implementation: the body is already in
120/// `conversation_messages`, so publishing is confirming what a row holds and
121/// the locator is the message id. Trivial on purpose — the point of this
122/// phase is the *handling*, not the storage.
123#[derive(Clone)]
124pub struct PostgresBackend {
125 pool: PgPool,
126 /// The team this handle may read for, when it was built for one. A
127 /// locator is opaque and a caller could hold one from anywhere; the
128 /// JetStream adapter checks the team on the envelope it reads back, and
129 /// this is the same check on this side of the boundary.
130 team_id: Option<Uuid>,
131 /// Fault injection for the tests. Production constructs `new`, which
132 /// leaves every fault off.
133 faults: Faults,
134 /// Retryable failures still owed, counted down as they are served. In
135 /// an `Arc` because the backend is cloned and the count is one budget,
136 /// not one per clone.
137 retryable_left: std::sync::Arc<std::sync::atomic::AtomicUsize>,
138}
139
140/// What to make go wrong, and how often. Off in production by construction.
141#[derive(Clone, Debug, Default)]
142pub struct Faults {
143 /// Fail the next N publishes with a retryable error, and then stop. A
144 /// fault that never runs out is a different test — it models a backend
145 /// that is down, not a transient failure — and the two must not be the
146 /// same knob.
147 pub retryable: usize,
148 /// Fail the next publish fatally.
149 pub fatal: bool,
150 /// Write the body, then report a failure — the uncertain completion a
151 /// reconcile has to resolve.
152 pub lose_confirmation: bool,
153 /// Pause inside publish, to widen the window a lease can expire in.
154 pub delay: Option<Duration>,
155 /// Fail every reconcile with an error, as a backend that cannot be
156 /// asked at all.
157 pub fail_reconcile: bool,
158}
159
160impl PostgresBackend {
161 pub fn new(pool: PgPool) -> Self {
162 Self {
163 pool,
164 team_id: None,
165 faults: Faults::default(),
166 retryable_left: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
167 }
168 }
169
170 /// A handle that may only read one team's bodies.
171 pub fn with_team(mut self, team_id: Uuid) -> Self {
172 self.team_id = Some(team_id);
173 self
174 }
175
176 /// Only the tests construct this.
177 pub fn with_faults(pool: PgPool, faults: Faults) -> Self {
178 let left = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(faults.retryable));
179 Self {
180 pool,
181 team_id: None,
182 faults,
183 retryable_left: left,
184 }
185 }
186
187 pub const NAME: &'static str = "postgres";
188}
189
190impl MessagingBackend for PostgresBackend {
191 fn name(&self) -> &'static str {
192 Self::NAME
193 }
194
195 async fn publish(&self, envelope: Envelope) -> Published {
196 if let Some(delay) = self.faults.delay {
197 tokio::time::sleep(delay).await;
198 }
199 if self.faults.fatal {
200 return Published::Fatal("the backend refused this payload".into());
201 }
202 if self
203 .retryable_left
204 .fetch_update(
205 std::sync::atomic::Ordering::SeqCst,
206 std::sync::atomic::Ordering::SeqCst,
207 |left| (left > 0).then(|| left - 1),
208 )
209 .is_ok()
210 {
211 return Published::Retryable("the backend was unreachable".into());
212 }
213 // Idempotent by construction: the row already exists, and confirming
214 // it twice is the same write.
215 let done = sqlx::query(
216 "UPDATE conversation_messages
217 SET canonical_locator = $2
218 WHERE id = $1 AND (canonical_locator IS NULL OR canonical_locator = $2)",
219 )
220 .bind(envelope.message_id)
221 .bind(envelope.message_id.to_string())
222 .execute(&self.pool)
223 .await;
224 match done {
225 Err(e) => {
226 tracing::warn!(error = %e, "publish failed");
227 Published::Retryable("the backend write failed".into())
228 }
229 Ok(_) if self.faults.lose_confirmation => {
230 // Written, and the caller is told it was not. This is the
231 // case reconcile exists for.
232 Published::Retryable("the confirmation was lost".into())
233 }
234 Ok(_) => Published::Confirmed(Locator(envelope.message_id.to_string())),
235 }
236 }
237
238 async fn fetch(&self, locator: &Locator, message_id: Uuid) -> BusResult<Option<String>> {
239 let id: Uuid = locator
240 .0
241 .parse()
242 .map_err(|_| BusError::invalid("not a locator this backend issued"))?;
243 if id != message_id {
244 return Err(BusError::Forbidden(
245 "that locator names another message".to_owned(),
246 ));
247 }
248 // Scoped to this handle's team when it has one. A locator is opaque
249 // and proves nothing about who may read it.
250 let row: Option<(String,)> = sqlx::query_as(
251 "SELECT m.body FROM conversation_messages m
252 JOIN conversations c ON c.id = m.conversation_id
253 WHERE m.id = $1 AND ($2::uuid IS NULL OR c.team_id = $2)",
254 )
255 .bind(id)
256 .bind(self.team_id)
257 .fetch_optional(&self.pool)
258 .await?;
259 Ok(row.map(|r| r.0))
260 }
261
262 async fn retain(&self, _before: chrono::DateTime<chrono::Utc>) -> BusResult<u64> {
263 // Postgres holds the bodies in the history table itself, which the
264 // existing `team prune` retention already covers. Nothing separate
265 // to drop, and pretending otherwise would delete history.
266 Ok(0)
267 }
268
269 async fn reconcile(&self, envelope: &Envelope) -> BusResult<Option<Locator>> {
270 if self.faults.fail_reconcile {
271 return Err(BusError::invalid("the backend could not be asked"));
272 }
273 // No write: the row is already here, so "did it land" is a lookup.
274 let row: Option<(Uuid,)> = sqlx::query_as(
275 "SELECT m.id FROM conversation_messages m
276 JOIN conversation_outbox o ON o.message_id = m.id
277 WHERE o.publish_key = $1 AND m.canonical_locator IS NOT NULL",
278 )
279 .bind(envelope.publish_key)
280 .fetch_optional(&self.pool)
281 .await?;
282 Ok(row.map(|r| Locator(r.0.to_string())))
283 }
284}