puerto_sqlx/events/
deliveries.rs1use 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
14pub const DEFAULT_CLAIM_LEASE: Duration = Duration::from_secs(60);
17
18pub struct PgDeliveries {
21 pool: PgPool,
22 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 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 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
77pub 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 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 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}