1use async_trait::async_trait;
11use thiserror::Error;
12use uuid::Uuid;
13
14use super::EventEnvelope;
15use super::delivery::Delivery;
16
17pub const DEAD_LETTER_PAYLOAD_LIMIT: usize = 4000;
19
20#[non_exhaustive]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
24pub enum TransportError {
25 #[error("transport.unavailable")]
27 Unavailable,
28 #[error("transport.invalid_data")]
30 InvalidData,
31}
32
33#[async_trait]
35pub trait Outbox: Send + Sync {
36 async fn try_lead(&self) -> Result<bool, TransportError>;
40 async fn fetch_unpublished(&self, limit: u32) -> Result<Vec<EventEnvelope>, TransportError>;
42 async fn mark_published(&self, ids: &[Uuid]) -> Result<(), TransportError>;
44}
45
46#[async_trait]
50pub trait EventPublisher: Send + Sync {
51 async fn publish(&self, event: &EventEnvelope) -> Result<(), TransportError>;
52}
53
54#[non_exhaustive]
56#[derive(Debug, Clone, PartialEq)]
57pub struct Claimed {
58 pub envelope: EventEnvelope,
59 pub attempt: u32,
61}
62
63impl Claimed {
64 pub fn new(envelope: EventEnvelope, attempt: u32) -> Self {
65 Self { envelope, attempt }
66 }
67}
68
69#[async_trait]
72pub trait EventSubscription: Send {
73 fn consumer(&self) -> &str;
75 fn max_batch(&self) -> u32 {
79 u32::MAX
80 }
81 async fn next_batch(&mut self, max: u32) -> Result<Vec<Claimed>, TransportError>;
83 async fn settle(
86 &mut self,
87 claimed: &Claimed,
88 delivery: &Delivery,
89 ) -> Result<(), TransportError>;
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct DeadLetterRecord {
95 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 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 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#[async_trait]
152pub trait DeadLetterSink: Send + Sync {
153 async fn record(&self, record: &DeadLetterRecord) -> Result<(), TransportError>;
154}
155
156#[cfg(test)]
157mod tests {
158 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 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 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 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 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_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 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 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_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}