Skip to main content

cratestack_sqlx/
descriptor.rs

1use std::sync::Arc;
2use std::sync::atomic::AtomicBool;
3
4use crate::sqlx;
5
6use cratestack_core::{
7    CoolError, CoolEventBus, CoolEventEnvelope, CoolEventFuture, ModelEventKind, SubscriptionHandle,
8};
9
10use crate::error::cool_error_from_sqlx;
11
12#[derive(Debug, Clone)]
13pub struct SqlxRuntime {
14    pool: sqlx::PgPool,
15    events: CoolEventBus,
16    // Shared (not per-clone) so every handle onto the same logical
17    // runtime agrees on whether `cratestack_audit` has been
18    // bootstrapped. See `crate::audit::ensure_audit_table` — this is
19    // what lets it skip re-issuing `CREATE INDEX IF NOT EXISTS` after
20    // the first call, which is what self-deadlocked chained
21    // `run_in_tx` audit writes in a caller-managed transaction.
22    audit_table_ensured: Arc<AtomicBool>,
23}
24
25impl SqlxRuntime {
26    pub fn new(pool: sqlx::PgPool) -> Self {
27        Self {
28            pool,
29            events: CoolEventBus::default(),
30            audit_table_ensured: Arc::new(AtomicBool::new(false)),
31        }
32    }
33
34    pub fn pool(&self) -> &sqlx::PgPool {
35        &self.pool
36    }
37
38    pub(crate) fn audit_table_ensured(&self) -> &AtomicBool {
39        &self.audit_table_ensured
40    }
41
42    #[doc(hidden)]
43    pub fn subscribe<F>(
44        &self,
45        model: &'static str,
46        operation: ModelEventKind,
47        handler: F,
48    ) -> SubscriptionHandle
49    where
50        F: Fn(CoolEventEnvelope) -> CoolEventFuture + Send + Sync + 'static,
51    {
52        self.events.subscribe(model, operation, handler)
53    }
54
55    /// An owned, cheaply-cloneable handle onto the underlying
56    /// `CoolEventBus` — needed by callers (e.g. `@@subscribe` SSE
57    /// dispatch, cratestack#390) that outlive the `&SqlxRuntime` borrow
58    /// `subscribe`/`unsubscribe` would otherwise require.
59    #[doc(hidden)]
60    pub fn events_bus(&self) -> CoolEventBus {
61        self.events.clone()
62    }
63
64    #[doc(hidden)]
65    pub async fn drain_event_outbox(&self) -> Result<usize, CoolError> {
66        ensure_event_outbox_table(&self.pool).await?;
67
68        let rows = sqlx::query_as::<_, EventOutboxRow>(
69            "SELECT event_id, model, operation, occurred_at, payload, attempts, last_error \
70             FROM cratestack_event_outbox \
71             WHERE delivered_at IS NULL \
72             ORDER BY occurred_at ASC, event_id ASC",
73        )
74        .fetch_all(&self.pool)
75        .await
76        .map_err(cool_error_from_sqlx)?;
77
78        let mut delivered = 0usize;
79        for row in rows {
80            let event_id = row.event_id;
81            let envelope = row.try_into_envelope()?;
82            match self.events.emit(envelope).await {
83                Ok(()) => {
84                    sqlx::query(
85                        "UPDATE cratestack_event_outbox \
86                         SET delivered_at = NOW(), last_error = NULL, attempts = attempts + 1 \
87                         WHERE event_id = $1",
88                    )
89                    .bind(event_id)
90                    .execute(&self.pool)
91                    .await
92                    .map_err(cool_error_from_sqlx)?;
93                    delivered += 1;
94                }
95                Err(error) => {
96                    sqlx::query(
97                        "UPDATE cratestack_event_outbox \
98                         SET attempts = attempts + 1, last_error = $2 \
99                         WHERE event_id = $1",
100                    )
101                    .bind(event_id)
102                    .bind(error.to_string())
103                    .execute(&self.pool)
104                    .await
105                    .map_err(cool_error_from_sqlx)?;
106                }
107            }
108        }
109
110        Ok(delivered)
111    }
112}
113
114#[derive(Debug, Clone)]
115pub(crate) struct EventOutboxRow {
116    pub(crate) event_id: uuid::Uuid,
117    pub(crate) model: String,
118    pub(crate) operation: String,
119    pub(crate) occurred_at: chrono::DateTime<chrono::Utc>,
120    pub(crate) payload: serde_json::Value,
121    pub(crate) attempts: i64,
122    pub(crate) last_error: Option<String>,
123}
124
125// Hand-written `FromRow` impl. We can't use `#[derive(sqlx::FromRow)]` because
126// the derive macro hardcodes `::sqlx::*` paths that don't resolve through our
127// `crate::sqlx` shim (the shim is module-scoped, not crate-aliased).
128impl<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> for EventOutboxRow {
129    fn from_row(row: &'r sqlx::postgres::PgRow) -> Result<Self, sqlx::Error> {
130        use sqlx::Row;
131        Ok(Self {
132            event_id: row.try_get("event_id")?,
133            model: row.try_get("model")?,
134            operation: row.try_get("operation")?,
135            occurred_at: row.try_get("occurred_at")?,
136            payload: row.try_get("payload")?,
137            attempts: row.try_get("attempts")?,
138            last_error: row.try_get("last_error")?,
139        })
140    }
141}
142
143impl EventOutboxRow {
144    pub(crate) fn try_into_envelope(self) -> Result<CoolEventEnvelope, CoolError> {
145        let _ = self.attempts;
146        let _ = &self.last_error;
147        Ok(CoolEventEnvelope {
148            event_id: self.event_id,
149            model: self.model,
150            operation: ModelEventKind::parse(&self.operation)?,
151            occurred_at: self.occurred_at,
152            data: self.payload,
153        })
154    }
155}
156
157pub(crate) async fn ensure_event_outbox_table<'e, E>(executor: E) -> Result<(), CoolError>
158where
159    E: sqlx::Executor<'e, Database = sqlx::Postgres>,
160{
161    sqlx::query(
162        "CREATE TABLE IF NOT EXISTS cratestack_event_outbox (\
163            event_id UUID PRIMARY KEY, \
164            model TEXT NOT NULL, \
165            operation TEXT NOT NULL, \
166            occurred_at TIMESTAMPTZ NOT NULL, \
167            payload JSONB NOT NULL, \
168            delivered_at TIMESTAMPTZ, \
169            attempts BIGINT NOT NULL DEFAULT 0, \
170            last_error TEXT\
171        )",
172    )
173    .execute(executor)
174    .await
175    .map_err(cool_error_from_sqlx)?;
176
177    Ok(())
178}
179
180pub(crate) async fn enqueue_event_outbox<'e, E, T>(
181    executor: E,
182    model: &'static str,
183    operation: ModelEventKind,
184    data: &T,
185) -> Result<(), CoolError>
186where
187    E: sqlx::Executor<'e, Database = sqlx::Postgres>,
188    T: serde::Serialize,
189{
190    let payload = serde_json::to_value(data)
191        .map_err(|error| CoolError::Codec(format!("failed to encode event payload: {error}")))?;
192    sqlx::query(
193        "INSERT INTO cratestack_event_outbox (event_id, model, operation, occurred_at, payload) \
194         VALUES ($1, $2, $3, $4, $5)",
195    )
196    .bind(uuid::Uuid::new_v4())
197    .bind(model)
198    .bind(operation.as_str())
199    .bind(chrono::Utc::now())
200    .bind(payload)
201    .execute(executor)
202    .await
203    .map_err(cool_error_from_sqlx)?;
204
205    Ok(())
206}