Skip to main content

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}