Skip to main content

puerto_core/events/
transport.rs

1//! The ports the worker drives (ADR-0018 § 4). A transport (`db`, `kafka`) is an adapter crate
2//! implementing them; the relay and the consume loop in `puerto-events` only see these traits.
3//!
4//! ```text
5//! Outbox ──fetch──▶ relay ──publish──▶ EventPublisher      (relay: one process holds the lead)
6//! EventSubscription ──next_batch──▶ consumer ──handle──▶ EventHandler
7//!                   ◀──settle(decide(..))──  └─dead letter─▶ DeadLetterSink (before settling)
8//! ```
9
10use async_trait::async_trait;
11use thiserror::Error;
12use uuid::Uuid;
13
14use super::EventEnvelope;
15use super::delivery::Delivery;
16
17/// How much of a payload a dead letter keeps, in characters.
18pub const DEAD_LETTER_PAYLOAD_LIMIT: usize = 4000;
19
20/// What a transport port fails with. The adapter logs the driver error with its context; the
21/// loop that called it only needs to know to try again later.
22#[non_exhaustive]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
24pub enum TransportError {
25    /// The store or the broker failed or could not be reached.
26    #[error("transport.unavailable")]
27    Unavailable,
28    /// What was stored cannot be read back as an envelope.
29    #[error("transport.invalid_data")]
30    InvalidData,
31}
32
33/// The relay's input: the events written by the API's units of work, not yet published.
34#[async_trait]
35pub trait Outbox: Send + Sync {
36    /// Whether this process may relay now. Only one relay runs at a time, so the outbox order
37    /// is kept with several worker replicas (a Postgres advisory lock). It is asked before
38    /// every batch; once held, it keeps answering `true`.
39    async fn try_lead(&self) -> Result<bool, TransportError>;
40    /// Up to `limit` unpublished events, oldest first (the order they were written).
41    async fn fetch_unpublished(&self, limit: u32) -> Result<Vec<EventEnvelope>, TransportError>;
42    /// Marks these events published. Idempotent: an id already marked is left as it is.
43    async fn mark_published(&self, ids: &[Uuid]) -> Result<(), TransportError>;
44}
45
46/// The relay's output: hands one event to the transport. `Ok` means the transport has it
47/// durably — the relay marks it published only then, and stops at the first `Err` so the next
48/// batch starts from that event again (order kept).
49#[async_trait]
50pub trait EventPublisher: Send + Sync {
51    async fn publish(&self, event: &EventEnvelope) -> Result<(), TransportError>;
52}
53
54/// One event handed to one consumer.
55#[non_exhaustive]
56#[derive(Debug, Clone, PartialEq)]
57pub struct Claimed {
58    pub envelope: EventEnvelope,
59    /// The 1-based number of this delivery to this consumer — what `decide` reads.
60    pub attempt: u32,
61}
62
63impl Claimed {
64    pub fn new(envelope: EventEnvelope, attempt: u32) -> Self {
65        Self { envelope, attempt }
66    }
67}
68
69/// A consumer's input. The consume loop asks for a batch, hands each event to the handler and
70/// settles it with what `decide` answered; an unsettled claim is delivered again later.
71#[async_trait]
72pub trait EventSubscription: Send {
73    /// The consumer it delivers to — the `[[consumer]] name`.
74    fn consumer(&self) -> &str;
75    /// The most claims one batch hands out, whatever `next_batch` is asked for. A transport that
76    /// keeps an order (Kafka: a partition) answers 1, so a `Retry` holds back what comes after
77    /// it instead of letting the handler see it first.
78    fn max_batch(&self) -> u32 {
79        u32::MAX
80    }
81    /// Up to `max` events due for this consumer. Empty when there is nothing to do.
82    async fn next_batch(&mut self, max: u32) -> Result<Vec<Claimed>, TransportError>;
83    /// Ack → never again; `Retry { delay }` → again after `delay`; `DeadLetter` → never again
84    /// (the dead letter is recorded before this is called).
85    async fn settle(
86        &mut self,
87        claimed: &Claimed,
88        delivery: &Delivery,
89    ) -> Result<(), TransportError>;
90}
91
92/// What is kept of an event no consumer will see again: nothing is retired without one.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct DeadLetterRecord {
95    /// `None` when what arrived was not an envelope at all.
96    pub event_id: Option<Uuid>,
97    pub consumer: String,
98    pub event_type: Option<String>,
99    pub attempts: u32,
100    pub reason: String,
101    pub detail: String,
102    /// At most [`DEAD_LETTER_PAYLOAD_LIMIT`] characters.
103    pub payload: String,
104}
105
106impl DeadLetterRecord {
107    pub fn for_event(
108        consumer: impl Into<String>,
109        event: &EventEnvelope,
110        attempts: u32,
111        reason: &str,
112        detail: impl Into<String>,
113    ) -> Self {
114        Self {
115            event_id: Some(event.id),
116            consumer: consumer.into(),
117            event_type: Some(event.event_type.clone()),
118            attempts,
119            reason: reason.to_string(),
120            detail: detail.into(),
121            payload: trim_payload(&event.payload.to_string()),
122        }
123    }
124
125    /// A message that could not be read as an envelope (a poisoned Kafka record).
126    pub fn unreadable(
127        consumer: impl Into<String>,
128        raw: &str,
129        attempts: u32,
130        reason: &str,
131        detail: impl Into<String>,
132    ) -> Self {
133        Self {
134            event_id: None,
135            consumer: consumer.into(),
136            event_type: None,
137            attempts,
138            reason: reason.to_string(),
139            detail: detail.into(),
140            payload: trim_payload(raw),
141        }
142    }
143}
144
145fn trim_payload(payload: &str) -> String {
146    payload.chars().take(DEAD_LETTER_PAYLOAD_LIMIT).collect()
147}
148
149/// Where dead letters go — Postgres, whatever the transport. Idempotent on
150/// `(event_id, consumer)`: recording the same one twice keeps one.
151#[async_trait]
152pub trait DeadLetterSink: Send + Sync {
153    async fn record(&self, record: &DeadLetterRecord) -> Result<(), TransportError>;
154}
155
156#[cfg(test)]
157mod tests {
158    //! A fake of each port, driven the way the relay and the consume loop will drive them: the
159    //! signatures have to fit those loops, not only compile.
160    use super::*;
161    use crate::events::delivery::{DEFAULT_MAX_DELIVERIES, decide};
162    use crate::events::{DomainEvent, EventHandler, HandlerOutcome, REASON_PAYLOAD_INVALID};
163    use serde_json::{Value, json};
164    use std::sync::Mutex;
165    use std::time::Duration;
166
167    struct Created(Uuid);
168
169    impl DomainEvent for Created {
170        fn event_type(&self) -> &'static str {
171            "product_created"
172        }
173        fn aggregate_type(&self) -> &'static str {
174            "product"
175        }
176        fn aggregate_id(&self) -> Uuid {
177            self.0
178        }
179        fn schema_version(&self) -> u16 {
180            1
181        }
182        fn payload(&self) -> Value {
183            json!({ "id": self.0, "name": "Desk lamp" })
184        }
185    }
186
187    fn envelope() -> EventEnvelope {
188        EventEnvelope::new(&Created(Uuid::now_v7()))
189    }
190
191    #[derive(Default)]
192    struct FakeOutbox {
193        rows: Mutex<Vec<(EventEnvelope, bool)>>,
194    }
195
196    #[async_trait]
197    impl Outbox for FakeOutbox {
198        async fn try_lead(&self) -> Result<bool, TransportError> {
199            Ok(true)
200        }
201        async fn fetch_unpublished(
202            &self,
203            limit: u32,
204        ) -> Result<Vec<EventEnvelope>, TransportError> {
205            let rows = self.rows.lock().map_err(|_| TransportError::Unavailable)?;
206            Ok(rows
207                .iter()
208                .filter(|(_, published)| !published)
209                .take(limit as usize)
210                .map(|(event, _)| event.clone())
211                .collect())
212        }
213        async fn mark_published(&self, ids: &[Uuid]) -> Result<(), TransportError> {
214            let mut rows = self.rows.lock().map_err(|_| TransportError::Unavailable)?;
215            for (event, published) in rows.iter_mut() {
216                *published |= ids.contains(&event.id);
217            }
218            Ok(())
219        }
220    }
221
222    /// Accepts `capacity` events, then fails.
223    struct FakePublisher {
224        capacity: usize,
225        published: Mutex<Vec<Uuid>>,
226    }
227
228    #[async_trait]
229    impl EventPublisher for FakePublisher {
230        async fn publish(&self, event: &EventEnvelope) -> Result<(), TransportError> {
231            let mut published = self
232                .published
233                .lock()
234                .map_err(|_| TransportError::Unavailable)?;
235            if published.len() == self.capacity {
236                return Err(TransportError::Unavailable);
237            }
238            published.push(event.id);
239            Ok(())
240        }
241    }
242
243    struct FakeSubscription {
244        due: Vec<Claimed>,
245        settled: Vec<(Uuid, Delivery)>,
246    }
247
248    #[async_trait]
249    impl EventSubscription for FakeSubscription {
250        fn consumer(&self) -> &str {
251            "notify_new_product"
252        }
253        async fn next_batch(&mut self, max: u32) -> Result<Vec<Claimed>, TransportError> {
254            let take = self.due.len().min(max as usize);
255            Ok(self.due.drain(..take).collect())
256        }
257        async fn settle(
258            &mut self,
259            claimed: &Claimed,
260            delivery: &Delivery,
261        ) -> Result<(), TransportError> {
262            self.settled.push((claimed.envelope.id, delivery.clone()));
263            Ok(())
264        }
265    }
266
267    #[derive(Default)]
268    struct FakeSink {
269        records: Mutex<Vec<DeadLetterRecord>>,
270    }
271
272    #[async_trait]
273    impl DeadLetterSink for FakeSink {
274        async fn record(&self, record: &DeadLetterRecord) -> Result<(), TransportError> {
275            let mut records = self
276                .records
277                .lock()
278                .map_err(|_| TransportError::Unavailable)?;
279            let seen = records
280                .iter()
281                .any(|r| r.event_id == record.event_id && r.consumer == record.consumer);
282            if !seen {
283                records.push(record.clone());
284            }
285            Ok(())
286        }
287    }
288
289    /// Acks a payload with a `name`, dead-letters the rest, retries nothing.
290    struct NeedsName;
291
292    #[async_trait]
293    impl EventHandler for NeedsName {
294        async fn handle(&self, event: &EventEnvelope) -> HandlerOutcome {
295            match event.payload.get("name") {
296                Some(_) => HandlerOutcome::Ack,
297                None => HandlerOutcome::dead_letter(REASON_PAYLOAD_INVALID, "missing `name`"),
298            }
299        }
300    }
301
302    #[test]
303    fn should_hand_out_whole_batches_unless_a_transport_keeps_an_order() {
304        let subscription = FakeSubscription {
305            due: vec![],
306            settled: vec![],
307        };
308        assert_eq!(subscription.max_batch(), u32::MAX);
309    }
310
311    #[tokio::test]
312    async fn should_mark_only_the_confirmed_prefix_when_the_publisher_fails_midway() {
313        // Arrange
314        let events: Vec<EventEnvelope> = (0..3).map(|_| envelope()).collect();
315        let outbox = FakeOutbox {
316            rows: Mutex::new(events.iter().cloned().map(|e| (e, false)).collect()),
317        };
318        let publisher = FakePublisher {
319            capacity: 2,
320            published: Mutex::new(Vec::new()),
321        };
322
323        // Act — one relay pass
324        assert_eq!(outbox.try_lead().await, Ok(true));
325        let mut confirmed = Vec::new();
326        for event in outbox.fetch_unpublished(10).await.unwrap_or_default() {
327            if publisher.publish(&event).await.is_err() {
328                break;
329            }
330            confirmed.push(event.id);
331        }
332        let marked = outbox.mark_published(&confirmed).await;
333
334        // Assert
335        assert_eq!(marked, Ok(()));
336        let left = outbox.fetch_unpublished(10).await.unwrap_or_default();
337        assert_eq!(left, vec![events[2].clone()]);
338    }
339
340    #[tokio::test]
341    async fn should_leave_marked_events_marked_when_marked_again() {
342        let event = envelope();
343        let outbox = FakeOutbox {
344            rows: Mutex::new(vec![(event.clone(), false)]),
345        };
346
347        let first = outbox.mark_published(&[event.id]).await;
348        let second = outbox.mark_published(&[event.id]).await;
349
350        assert_eq!((first, second), (Ok(()), Ok(())));
351        assert_eq!(outbox.fetch_unpublished(10).await, Ok(vec![]));
352    }
353
354    #[tokio::test]
355    async fn should_settle_every_claim_and_dead_letter_first_when_driven_by_a_consume_pass() {
356        // Arrange
357        let good = envelope();
358        let mut poisoned = envelope();
359        poisoned.payload = json!({ "id": poisoned.aggregate_id });
360        let mut subscription = FakeSubscription {
361            due: vec![
362                Claimed::new(good.clone(), 1),
363                Claimed::new(poisoned.clone(), 2),
364            ],
365            settled: Vec::new(),
366        };
367        let sink = FakeSink::default();
368
369        // Act — one consume pass
370        for claimed in subscription.next_batch(10).await.unwrap_or_default() {
371            let outcome = NeedsName.handle(&claimed.envelope).await;
372            let delivery = decide(outcome, claimed.attempt, DEFAULT_MAX_DELIVERIES);
373            if let Delivery::DeadLetter { reason, detail } = &delivery {
374                let record = DeadLetterRecord::for_event(
375                    subscription.consumer(),
376                    &claimed.envelope,
377                    claimed.attempt,
378                    reason,
379                    detail.clone(),
380                );
381                assert_eq!(sink.record(&record).await, Ok(()));
382            }
383            assert_eq!(subscription.settle(&claimed, &delivery).await, Ok(()));
384        }
385
386        // Assert
387        assert_eq!(
388            subscription.settled,
389            vec![
390                (good.id, Delivery::Ack),
391                (
392                    poisoned.id,
393                    Delivery::DeadLetter {
394                        reason: "payload_invalid",
395                        detail: "missing `name`".into()
396                    }
397                ),
398            ]
399        );
400        let records = sink.records.lock().map(|r| r.clone()).unwrap_or_default();
401        assert_eq!(records.len(), 1);
402        assert_eq!(records[0].event_id, Some(poisoned.id));
403        assert_eq!(records[0].consumer, "notify_new_product");
404        assert_eq!(records[0].attempts, 2);
405    }
406
407    #[tokio::test]
408    async fn should_hand_out_at_most_the_batch_size_when_more_are_due() {
409        let mut subscription = FakeSubscription {
410            due: (0..3).map(|_| Claimed::new(envelope(), 1)).collect(),
411            settled: Vec::new(),
412        };
413
414        let first = subscription.next_batch(2).await.unwrap_or_default();
415        let second = subscription.next_batch(2).await.unwrap_or_default();
416
417        assert_eq!((first.len(), second.len()), (2, 1));
418    }
419
420    #[tokio::test]
421    async fn should_keep_one_dead_letter_when_the_same_one_is_recorded_twice() {
422        let sink = FakeSink::default();
423        let record = DeadLetterRecord::for_event("audit", &envelope(), 5, "permanent_error", "no");
424
425        let _ = sink.record(&record).await;
426        let _ = sink.record(&record).await;
427
428        assert_eq!(sink.records.lock().map(|r| r.len()).unwrap_or(0), 1);
429    }
430
431    #[test]
432    fn should_describe_the_event_when_dead_lettering_an_envelope() {
433        let event = envelope();
434
435        let record =
436            DeadLetterRecord::for_event("audit", &event, 5, "max_deliveries_exceeded", "timeout");
437
438        assert_eq!(
439            record,
440            DeadLetterRecord {
441                event_id: Some(event.id),
442                consumer: "audit".into(),
443                event_type: Some("product_created".into()),
444                attempts: 5,
445                reason: "max_deliveries_exceeded".into(),
446                detail: "timeout".into(),
447                payload: event.payload.to_string(),
448            }
449        );
450    }
451
452    #[test]
453    fn should_keep_the_raw_message_when_dead_lettering_one_that_is_not_an_envelope() {
454        let record = DeadLetterRecord::unreadable("audit", "not json", 1, "payload_invalid", "eof");
455
456        assert_eq!(record.event_id, None);
457        assert_eq!(record.event_type, None);
458        assert_eq!(record.payload, "not json");
459        assert_eq!(record.reason, "payload_invalid");
460    }
461
462    #[test]
463    fn should_trim_the_payload_when_it_is_longer_than_the_limit() {
464        let at_limit = "é".repeat(DEAD_LETTER_PAYLOAD_LIMIT);
465        let over = format!("{at_limit}tail");
466
467        let kept = DeadLetterRecord::unreadable("audit", &at_limit, 1, "payload_invalid", "");
468        let trimmed = DeadLetterRecord::unreadable("audit", &over, 1, "payload_invalid", "");
469
470        assert_eq!(kept.payload, at_limit);
471        assert_eq!(trimmed.payload, at_limit);
472    }
473
474    #[test]
475    fn should_speak_machine_readable_codes_when_a_transport_fails() {
476        assert_eq!(
477            TransportError::Unavailable.to_string(),
478            "transport.unavailable"
479        );
480        assert_eq!(
481            TransportError::InvalidData.to_string(),
482            "transport.invalid_data"
483        );
484    }
485
486    #[test]
487    fn should_keep_the_retry_delay_when_a_claim_is_settled_for_later() {
488        let delivery = decide(HandlerOutcome::Retry, 2, DEFAULT_MAX_DELIVERIES);
489        assert_eq!(
490            delivery,
491            Delivery::Retry {
492                delay: Duration::from_secs(5)
493            }
494        );
495    }
496}