Skip to main content

puerto_sqlx/events/
deliveries.rs

1//! The `db` transport: no broker, one `event_deliveries` row per event and subscribed consumer.
2
3use std::collections::BTreeMap;
4use std::time::Duration;
5
6use async_trait::async_trait;
7use puerto_core::EventEnvelope;
8use puerto_core::events::delivery::Delivery;
9use puerto_core::events::transport::{Claimed, EventPublisher, EventSubscription, TransportError};
10use sqlx::PgPool;
11
12use super::{ENVELOPE_COLUMNS, envelope_from_pg, unavailable};
13
14/// How long a claimed delivery stays invisible to other claims. A worker that dies while
15/// handling one leaves it due again after this — at-least-once, never lost.
16pub const DEFAULT_CLAIM_LEASE: Duration = Duration::from_secs(60);
17
18/// The relay's publisher on the `db` transport: fans each event out to the consumers subscribed
19/// to its type and marks it published, in one transaction.
20pub struct PgDeliveries {
21    pool: PgPool,
22    /// event type → the consumers subscribed to it, in the order they subscribed.
23    routes: BTreeMap<String, Vec<String>>,
24}
25
26impl PgDeliveries {
27    pub fn new(pool: PgPool) -> Self {
28        Self {
29            pool,
30            routes: BTreeMap::new(),
31        }
32    }
33
34    /// Subscribes `consumer` to `event_types` — one `[[consumer]]` of puerto.toml.
35    pub fn route(mut self, consumer: &str, event_types: &[&str]) -> Self {
36        for event_type in event_types {
37            let consumers = self.routes.entry((*event_type).to_string()).or_default();
38            if !consumers.iter().any(|c| c == consumer) {
39                consumers.push(consumer.to_string());
40            }
41        }
42        self
43    }
44
45    /// Who receives an event of this type. None is fine: the event is marked published anyway.
46    pub fn consumers_for(&self, event_type: &str) -> &[String] {
47        self.routes.get(event_type).map_or(&[], Vec::as_slice)
48    }
49}
50
51#[async_trait]
52impl EventPublisher for PgDeliveries {
53    async fn publish(&self, event: &EventEnvelope) -> Result<(), TransportError> {
54        let context = "deliveries: publish";
55        let mut tx = self.pool.begin().await.map_err(unavailable(context))?;
56        sqlx::query(
57            "INSERT INTO event_deliveries (event_id, consumer) \
58             SELECT $1, consumer FROM unnest($2::text[]) AS consumer \
59             ON CONFLICT DO NOTHING",
60        )
61        .bind(event.id)
62        .bind(self.consumers_for(&event.event_type))
63        .execute(&mut *tx)
64        .await
65        .map_err(unavailable(context))?;
66        sqlx::query(
67            "UPDATE outbox SET published_at = now() WHERE id = $1 AND published_at IS NULL",
68        )
69        .bind(event.id)
70        .execute(&mut *tx)
71        .await
72        .map_err(unavailable(context))?;
73        tx.commit().await.map_err(unavailable(context))
74    }
75}
76
77/// One consumer's input on the `db` transport.
78pub struct PgSubscription {
79    pool: PgPool,
80    consumer: String,
81    lease: Duration,
82}
83
84impl PgSubscription {
85    pub fn new(pool: PgPool, consumer: impl Into<String>) -> Self {
86        Self {
87            pool,
88            consumer: consumer.into(),
89            lease: DEFAULT_CLAIM_LEASE,
90        }
91    }
92
93    /// How long a claim is kept from other workers; longer than the slowest handler.
94    pub fn with_lease(mut self, lease: Duration) -> Self {
95        self.lease = lease;
96        self
97    }
98}
99
100#[async_trait]
101impl EventSubscription for PgSubscription {
102    fn consumer(&self) -> &str {
103        &self.consumer
104    }
105
106    /// Claims the due deliveries, oldest event first. A claim counts as a delivery (`attempts`
107    /// goes up now, not when it is settled), so a worker that crashes mid-handle still uses one
108    /// of the event's deliveries, and the claim is hidden for the lease.
109    async fn next_batch(&mut self, max: u32) -> Result<Vec<Claimed>, TransportError> {
110        let context = "deliveries: next_batch";
111        let rows = sqlx::query(&format!(
112            "WITH due AS ( \
113                 SELECT d.event_id FROM event_deliveries d \
114                 JOIN outbox o ON o.id = d.event_id \
115                 WHERE d.consumer = $1 AND d.status = 'pending' AND d.next_attempt_at <= now() \
116                 ORDER BY o.seq LIMIT $2 \
117                 FOR UPDATE OF d SKIP LOCKED \
118             ), claimed AS ( \
119                 UPDATE event_deliveries d \
120                 SET attempts = d.attempts + 1, \
121                     next_attempt_at = now() + make_interval(secs => $3) \
122                 FROM due WHERE d.event_id = due.event_id AND d.consumer = $1 \
123                 RETURNING d.event_id, d.attempts \
124             ) \
125             SELECT {ENVELOPE_COLUMNS}, claimed.attempts FROM claimed \
126             JOIN outbox o ON o.id = claimed.event_id \
127             ORDER BY o.seq"
128        ))
129        .bind(&self.consumer)
130        .bind(i64::from(max))
131        .bind(self.lease.as_secs_f64())
132        .fetch_all(&self.pool)
133        .await
134        .map_err(unavailable(context))?;
135        rows.iter()
136            .map(|row| {
137                let envelope = envelope_from_pg(row, context)?;
138                let attempts: i32 =
139                    sqlx::Row::try_get(row, "attempts").map_err(|_| TransportError::InvalidData)?;
140                let attempt = u32::try_from(attempts).map_err(|_| TransportError::InvalidData)?;
141                Ok(Claimed::new(envelope, attempt))
142            })
143            .collect()
144    }
145
146    async fn settle(
147        &mut self,
148        claimed: &Claimed,
149        delivery: &Delivery,
150    ) -> Result<(), TransportError> {
151        let (id, consumer) = (claimed.envelope.id, &self.consumer);
152        let result = match delivery {
153            Delivery::Ack => {
154                sqlx::query(
155                    "UPDATE event_deliveries SET status = 'done', last_error = NULL \
156                     WHERE event_id = $1 AND consumer = $2",
157                )
158                .bind(id)
159                .bind(consumer)
160                .execute(&self.pool)
161                .await
162            }
163            Delivery::Retry { delay } => {
164                sqlx::query(
165                    "UPDATE event_deliveries \
166                     SET next_attempt_at = now() + make_interval(secs => $3) \
167                     WHERE event_id = $1 AND consumer = $2",
168                )
169                .bind(id)
170                .bind(consumer)
171                .bind(delay.as_secs_f64())
172                .execute(&self.pool)
173                .await
174            }
175            Delivery::DeadLetter { reason, detail } => {
176                sqlx::query(
177                    "UPDATE event_deliveries SET status = 'dead', last_error = $3 \
178                     WHERE event_id = $1 AND consumer = $2",
179                )
180                .bind(id)
181                .bind(consumer)
182                .bind(format!("{reason}: {detail}"))
183                .execute(&self.pool)
184                .await
185            }
186        };
187        result.map_err(unavailable("deliveries: settle"))?;
188        Ok(())
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use sqlx::postgres::PgPoolOptions;
196
197    fn lazy_pool() -> PgPool {
198        PgPoolOptions::new()
199            .connect_lazy("postgres://puerto@127.0.0.1:1/puerto")
200            .unwrap()
201    }
202
203    #[tokio::test]
204    async fn should_route_an_event_to_every_consumer_subscribed_to_its_type() {
205        let deliveries = PgDeliveries::new(lazy_pool())
206            .route(
207                "notify_new_product",
208                &["product_created", "product_published"],
209            )
210            .route("audit", &["product_created"]);
211
212        assert_eq!(
213            deliveries.consumers_for("product_created"),
214            ["notify_new_product", "audit"]
215        );
216        assert_eq!(
217            deliveries.consumers_for("product_published"),
218            ["notify_new_product"]
219        );
220    }
221
222    #[tokio::test]
223    async fn should_route_to_nobody_when_no_consumer_subscribed() {
224        let deliveries = PgDeliveries::new(lazy_pool()).route("audit", &["product_created"]);
225
226        assert!(deliveries.consumers_for("product_deleted").is_empty());
227    }
228
229    #[tokio::test]
230    async fn should_route_once_when_a_consumer_subscribes_twice() {
231        let deliveries = PgDeliveries::new(lazy_pool())
232            .route("audit", &["product_created", "product_created"])
233            .route("audit", &["product_created"]);
234
235        assert_eq!(deliveries.consumers_for("product_created"), ["audit"]);
236    }
237
238    #[tokio::test]
239    async fn should_name_its_consumer_and_default_lease_when_subscribed() {
240        let subscription = PgSubscription::new(lazy_pool(), "audit");
241
242        assert_eq!(subscription.consumer(), "audit");
243        assert_eq!(subscription.lease, DEFAULT_CLAIM_LEASE);
244        assert_eq!(
245            subscription.with_lease(Duration::from_secs(5)).lease,
246            Duration::from_secs(5)
247        );
248    }
249}