1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use async_trait::async_trait;
6use hexeract_outbox::ErasedHandler;
7use hexeract_outbox::Event;
8use hexeract_outbox::Handler;
9use hexeract_outbox::IdempotentOutboxEnqueue;
10use hexeract_outbox::OutboxEnvelope;
11use hexeract_outbox::OutboxError;
12use hexeract_outbox::OutboxPublisher;
13use hexeract_outbox::OutboxStore;
14use hexeract_outbox::OutboxWorker;
15use hexeract_outbox::OutboxWorkerConfig;
16use hexeract_outbox::TypedHandler;
17use sqlx::Acquire;
18use sqlx::MySql;
19use sqlx::MySqlPool;
20use sqlx::Row;
21use sqlx::Transaction;
22use sqlx::pool::PoolConnection;
23use time::PrimitiveDateTime;
24use uuid::Uuid;
25
26use crate::DEFAULT_TABLE_NAME;
27use crate::dialect::Dialect;
28use crate::envelope::assemble_envelope;
29use crate::envelope::primitive_utc_to_system_time;
30use crate::validate::validate_event_type;
31use crate::validate::validate_table_name;
32
33const DIALECT: Dialect = Dialect::MySql;
34
35fn duration_to_micros(d: Duration) -> i64 {
38 i64::try_from(d.as_micros()).unwrap_or(i64::MAX)
39}
40
41fn database_error(error: impl std::error::Error + Send + Sync + 'static) -> OutboxError {
42 OutboxError::Database(Box::new(error))
43}
44
45fn pool_error(error: sqlx::Error) -> OutboxError {
46 if matches!(error, sqlx::Error::PoolTimedOut) {
47 OutboxError::PoolTimeout
48 } else {
49 OutboxError::Database(Box::new(error))
50 }
51}
52
53fn decode_mysql_row(row: &sqlx::mysql::MySqlRow) -> Result<OutboxEnvelope, OutboxError> {
58 let event_id: Uuid = row.try_get("event_id").map_err(database_error)?;
59 let event_type: String = row.try_get("event_type").map_err(database_error)?;
60 let payload: serde_json::Value = row.try_get("payload").map_err(database_error)?;
61 let subject_id: Option<Uuid> = row.try_get("subject_id").map_err(database_error)?;
62 let created_at: PrimitiveDateTime = row.try_get("created_at").map_err(database_error)?;
63 let attempts: i32 = row.try_get("attempts").map_err(database_error)?;
64 let last_error: Option<String> = row.try_get("last_error").map_err(database_error)?;
65 let next_retry_at: Option<PrimitiveDateTime> =
66 row.try_get("next_retry_at").map_err(database_error)?;
67
68 let payload = serde_json::to_vec(&payload)?;
69
70 Ok(assemble_envelope(
71 event_id,
72 event_type,
73 payload,
74 subject_id,
75 primitive_utc_to_system_time(created_at),
76 u32::try_from(attempts.max(0)).unwrap_or(u32::MAX),
77 last_error,
78 next_retry_at.map(primitive_utc_to_system_time),
79 ))
80}
81
82#[derive(Debug, Clone)]
83struct DeadLetterSql {
84 insert_sql: Arc<str>,
85 delete_sql: Arc<str>,
86}
87
88pub async fn ensure_schema(pool: &MySqlPool, table_name: &str) -> Result<(), OutboxError> {
99 let ddl = DIALECT.schema_ddl(table_name)?;
100 sqlx::raw_sql(&ddl)
101 .execute(pool)
102 .await
103 .map_err(database_error)?;
104 Ok(())
105}
106
107#[derive(Debug, Clone)]
111pub struct MySqlOutboxStore {
112 pool: MySqlPool,
113 table_name: Arc<str>,
114 poll_sql: Arc<str>,
115 mark_delivered_sql: Arc<str>,
116 mark_failed_sql: Arc<str>,
117 dead_letter: Option<Arc<DeadLetterSql>>,
118}
119
120impl MySqlOutboxStore {
121 pub fn new(pool: MySqlPool, table_name: impl Into<String>) -> Result<Self, OutboxError> {
128 let table_name = table_name.into();
129 validate_table_name(&table_name)?;
130 let poll_sql = DIALECT.poll_sql(&table_name);
131 let mark_delivered_sql = DIALECT.mark_delivered_sql(&table_name);
132 let mark_failed_sql = DIALECT.mark_failed_sql(&table_name);
133 Ok(Self {
134 pool,
135 table_name: Arc::from(table_name),
136 poll_sql: Arc::from(poll_sql),
137 mark_delivered_sql: Arc::from(mark_delivered_sql),
138 mark_failed_sql: Arc::from(mark_failed_sql),
139 dead_letter: None,
140 })
141 }
142
143 #[must_use]
145 pub fn pool(&self) -> &MySqlPool {
146 &self.pool
147 }
148
149 #[must_use]
151 pub fn table_name(&self) -> &str {
152 &self.table_name
153 }
154
155 pub fn with_dead_letter(mut self, dlq_table: impl Into<String>) -> Result<Self, OutboxError> {
161 let dlq = dlq_table.into();
162 validate_table_name(&dlq)?;
163 let insert_sql = DIALECT.insert_dead_letter_sql(&self.table_name, &dlq);
164 let delete_sql = DIALECT.delete_from_main_sql(&self.table_name);
165 self.dead_letter = Some(Arc::new(DeadLetterSql {
166 insert_sql: Arc::from(insert_sql),
167 delete_sql: Arc::from(delete_sql),
168 }));
169 Ok(self)
170 }
171}
172
173#[async_trait]
174impl OutboxStore for MySqlOutboxStore {
175 type Client = PoolConnection<MySql>;
176 type Tx<'tx> = Transaction<'tx, MySql>;
177
178 async fn acquire(&self) -> Result<Self::Client, OutboxError> {
179 self.pool.acquire().await.map_err(pool_error)
180 }
181
182 async fn begin<'a>(&self, client: &'a mut Self::Client) -> Result<Self::Tx<'a>, OutboxError> {
183 client.begin().await.map_err(database_error)
184 }
185
186 async fn poll<'a>(
187 &self,
188 tx: &mut Self::Tx<'a>,
189 batch_size: usize,
190 max_attempts: u32,
191 ) -> Result<Vec<OutboxEnvelope>, OutboxError> {
192 let limit = i64::try_from(batch_size).unwrap_or(i64::MAX);
193 let max = i32::try_from(max_attempts).unwrap_or(i32::MAX);
194 let rows = sqlx::query(&self.poll_sql)
195 .bind(max)
196 .bind(limit)
197 .fetch_all(&mut **tx)
198 .await
199 .map_err(database_error)?;
200
201 let mut envelopes = Vec::with_capacity(rows.len());
202 for row in rows {
203 match decode_mysql_row(&row) {
207 Ok(envelope) => envelopes.push(envelope),
208 Err(error) => {
209 let event_id = row.try_get::<Uuid, _>("event_id").ok();
210 tracing::error!(
211 ?event_id,
212 error = %error,
213 "skipping undecodable outbox row; the rest of the batch continues"
214 );
215 }
216 }
217 }
218 Ok(envelopes)
219 }
220
221 async fn mark_delivered<'a>(
222 &self,
223 tx: &mut Self::Tx<'a>,
224 event_id: Uuid,
225 ) -> Result<(), OutboxError> {
226 sqlx::query(&self.mark_delivered_sql)
227 .bind(event_id)
228 .execute(&mut **tx)
229 .await
230 .map_err(database_error)?;
231 Ok(())
232 }
233
234 async fn mark_failed<'a>(
235 &self,
236 tx: &mut Self::Tx<'a>,
237 event_id: Uuid,
238 error: &str,
239 retry_in: Duration,
240 ) -> Result<(), OutboxError> {
241 sqlx::query(&self.mark_failed_sql)
243 .bind(error)
244 .bind(duration_to_micros(retry_in))
245 .bind(event_id)
246 .execute(&mut **tx)
247 .await
248 .map_err(database_error)?;
249 Ok(())
250 }
251
252 async fn commit<'a>(&self, tx: Self::Tx<'a>) -> Result<(), OutboxError> {
253 tx.commit().await.map_err(database_error)
254 }
255
256 async fn mark_dead_lettered<'a>(
257 &self,
258 tx: &mut Self::Tx<'a>,
259 event_id: Uuid,
260 _error: &str,
261 ) -> Result<(), OutboxError> {
262 let Some(dlq) = &self.dead_letter else {
263 return Ok(());
264 };
265 sqlx::query(&dlq.insert_sql)
266 .bind(event_id)
267 .execute(&mut **tx)
268 .await
269 .map_err(database_error)?;
270 sqlx::query(&dlq.delete_sql)
271 .bind(event_id)
272 .execute(&mut **tx)
273 .await
274 .map_err(database_error)?;
275 Ok(())
276 }
277
278 async fn claim<'a>(
279 &self,
280 tx: &mut Self::Tx<'a>,
281 event_ids: &[Uuid],
282 lease_for: Duration,
283 ) -> Result<(), OutboxError> {
284 if event_ids.is_empty() {
285 return Ok(());
286 }
287 let sql = DIALECT.claim_sql(&self.table_name, event_ids.len());
288 let mut query = sqlx::query(&sql).bind(duration_to_micros(lease_for));
290 for id in event_ids {
291 query = query.bind(*id);
292 }
293 query.execute(&mut **tx).await.map_err(database_error)?;
294 Ok(())
295 }
296}
297
298#[derive(Debug, Clone)]
302pub struct MySqlOutboxPublisher {
303 pool: MySqlPool,
304 table_name: Arc<str>,
305 insert_sql: Arc<str>,
306 idempotent_insert_sql: Arc<str>,
307}
308
309impl MySqlOutboxPublisher {
310 pub fn new(pool: MySqlPool, table_name: impl Into<String>) -> Result<Self, OutboxError> {
317 let table_name = table_name.into();
318 validate_table_name(&table_name)?;
319 let insert_sql = DIALECT.insert_sql(&table_name);
320 let idempotent_insert_sql = DIALECT.insert_idempotent_sql(&table_name);
321 Ok(Self {
322 pool,
323 table_name: Arc::from(table_name),
324 insert_sql: Arc::from(insert_sql),
325 idempotent_insert_sql: Arc::from(idempotent_insert_sql),
326 })
327 }
328
329 #[must_use]
331 pub fn pool(&self) -> &MySqlPool {
332 &self.pool
333 }
334
335 #[must_use]
337 pub fn table_name(&self) -> &str {
338 &self.table_name
339 }
340}
341
342impl OutboxPublisher for MySqlOutboxPublisher {
343 type Tx<'tx> = Transaction<'tx, MySql>;
344
345 async fn publish_in_tx<E: Event>(
346 &self,
347 tx: &mut Self::Tx<'_>,
348 event: &E,
349 ) -> Result<Uuid, OutboxError> {
350 validate_event_type(E::EVENT_TYPE)?;
351 let event_id = Uuid::now_v7();
352 let payload = serde_json::to_value(event)?;
353 sqlx::query(&self.insert_sql)
354 .bind(event_id)
355 .bind(E::EVENT_TYPE)
356 .bind(payload)
357 .bind(Option::<Uuid>::None)
358 .execute(&mut **tx)
359 .await
360 .map_err(database_error)?;
361 Ok(event_id)
362 }
363
364 async fn publish_in_tx_with_subject<E: Event>(
365 &self,
366 tx: &mut Self::Tx<'_>,
367 subject_id: Uuid,
368 event: &E,
369 ) -> Result<Uuid, OutboxError> {
370 validate_event_type(E::EVENT_TYPE)?;
371 let event_id = Uuid::now_v7();
372 let payload = serde_json::to_value(event)?;
373 sqlx::query(&self.insert_sql)
374 .bind(event_id)
375 .bind(E::EVENT_TYPE)
376 .bind(payload)
377 .bind(Some(subject_id))
378 .execute(&mut **tx)
379 .await
380 .map_err(database_error)?;
381 Ok(event_id)
382 }
383
384 async fn publish<E: Event>(&self, event: &E) -> Result<Uuid, OutboxError> {
385 let mut tx = self.pool.begin().await.map_err(database_error)?;
386 let event_id = self.publish_in_tx(&mut tx, event).await?;
387 tx.commit().await.map_err(database_error)?;
388 Ok(event_id)
389 }
390}
391
392impl IdempotentOutboxEnqueue for MySqlOutboxPublisher {
393 async fn enqueue_idempotent(
394 &self,
395 event_id: Uuid,
396 event_type: &str,
397 payload: &[u8],
398 ) -> Result<bool, OutboxError> {
399 validate_event_type(event_type)?;
400 let payload_value = serde_json::from_slice::<serde_json::Value>(payload)?;
401 let mut tx = self.pool.begin().await.map_err(database_error)?;
402 let result = sqlx::query(&self.idempotent_insert_sql)
403 .bind(event_id)
404 .bind(event_type)
405 .bind(payload_value)
406 .bind(event_id)
409 .execute(&mut *tx)
410 .await
411 .map_err(database_error)?;
412 tx.commit().await.map_err(database_error)?;
413 Ok(result.rows_affected() > 0)
414 }
415}
416
417pub struct MySqlOutboxWorkerBuilder {
449 pool: MySqlPool,
450 table_name: String,
451 dead_letter_table: Option<String>,
452 handlers: HashMap<&'static str, Arc<dyn ErasedHandler>>,
453 config: OutboxWorkerConfig,
454}
455
456impl MySqlOutboxWorkerBuilder {
457 #[must_use]
459 pub fn new(pool: MySqlPool) -> Self {
460 Self {
461 pool,
462 table_name: DEFAULT_TABLE_NAME.to_owned(),
463 dead_letter_table: None,
464 handlers: HashMap::new(),
465 config: OutboxWorkerConfig::default(),
466 }
467 }
468
469 #[must_use]
471 pub fn table_name(mut self, name: impl Into<String>) -> Self {
472 self.table_name = name.into();
473 self
474 }
475
476 #[must_use]
478 pub fn dead_letter_table(mut self, name: impl Into<String>) -> Self {
479 self.dead_letter_table = Some(name.into());
480 self
481 }
482
483 #[must_use]
488 pub fn register_handler<E, H>(mut self, handler: H) -> Self
489 where
490 E: Event,
491 H: Handler<E>,
492 {
493 let typed = TypedHandler::<E, H>::new(handler);
494 let erased: Arc<dyn ErasedHandler> = Arc::new(typed);
495 self.handlers.insert(E::EVENT_TYPE, erased);
496 self
497 }
498
499 #[must_use]
501 pub fn shared_handler<E, H>(mut self, handler: Arc<H>) -> Self
502 where
503 E: Event,
504 H: Handler<E>,
505 {
506 let typed = TypedHandler::<E, H>::shared(handler);
507 let erased: Arc<dyn ErasedHandler> = Arc::new(typed);
508 self.handlers.insert(E::EVENT_TYPE, erased);
509 self
510 }
511
512 #[must_use]
514 pub fn poll_interval(mut self, d: Duration) -> Self {
515 self.config.poll_interval = d;
516 self
517 }
518
519 #[must_use]
521 pub fn batch_size(mut self, n: usize) -> Self {
522 self.config.batch_size = n;
523 self
524 }
525
526 #[must_use]
528 pub fn max_attempts(mut self, n: u32) -> Self {
529 self.config.max_attempts = n;
530 self
531 }
532
533 #[must_use]
535 pub fn retry_base_delay(mut self, d: Duration) -> Self {
536 self.config.retry_base_delay = d;
537 self
538 }
539
540 #[must_use]
542 pub fn retry_max_delay(mut self, d: Duration) -> Self {
543 self.config.retry_max_delay = d;
544 self
545 }
546
547 #[must_use]
549 pub fn jitter(mut self, enabled: bool) -> Self {
550 self.config.jitter = enabled;
551 self
552 }
553
554 #[must_use]
561 pub fn dispatch_timeout(mut self, d: Duration) -> Self {
562 self.config.dispatch_timeout = d;
563 self
564 }
565
566 pub fn build(self) -> Result<OutboxWorker<MySqlOutboxStore>, OutboxError> {
573 let mut store = MySqlOutboxStore::new(self.pool, self.table_name)?;
574 if let Some(dlq) = self.dead_letter_table {
575 store = store.with_dead_letter(dlq)?;
576 }
577 Ok(OutboxWorker::new(store, self.handlers, self.config))
578 }
579}
580
581pub async fn ensure_dead_letter_schema(
590 pool: &MySqlPool,
591 table_name: &str,
592) -> Result<(), OutboxError> {
593 let ddl = DIALECT.dead_letter_schema_ddl(table_name)?;
594 sqlx::raw_sql(&ddl)
595 .execute(pool)
596 .await
597 .map_err(database_error)?;
598 Ok(())
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use hexeract_core::HandlerContext;
605 use serde::Deserialize;
606 use serde::Serialize;
607
608 fn lazy_pool() -> MySqlPool {
609 MySqlPool::connect_lazy("mysql://nobody:nobody@127.0.0.1:1/nobody")
610 .expect("lazy pool must build from a valid URL")
611 }
612
613 #[derive(Debug, Serialize, Deserialize)]
614 struct UserRegistered {
615 user_id: Uuid,
616 }
617
618 impl Event for UserRegistered {
619 const EVENT_TYPE: &'static str = "users.registered";
620 }
621
622 #[derive(Debug, Serialize, Deserialize)]
623 struct OrderPlaced {
624 order_id: Uuid,
625 }
626
627 impl Event for OrderPlaced {
628 const EVENT_TYPE: &'static str = "orders.placed";
629 }
630
631 struct NoopHandler;
632
633 impl Handler<UserRegistered> for NoopHandler {
634 type Error = OutboxError;
635 async fn handle(
636 &self,
637 _event: UserRegistered,
638 _ctx: &HandlerContext,
639 ) -> Result<(), Self::Error> {
640 Ok(())
641 }
642 }
643
644 impl Handler<OrderPlaced> for NoopHandler {
645 type Error = OutboxError;
646 async fn handle(
647 &self,
648 _event: OrderPlaced,
649 _ctx: &HandlerContext,
650 ) -> Result<(), Self::Error> {
651 Ok(())
652 }
653 }
654
655 #[test]
656 fn pool_error_maps_pool_timed_out_to_pool_timeout_variant() {
657 let err = pool_error(sqlx::Error::PoolTimedOut);
658 assert!(
659 matches!(err, OutboxError::PoolTimeout),
660 "PoolTimedOut must map to OutboxError::PoolTimeout, got {err:?}"
661 );
662 }
663
664 #[test]
665 fn pool_error_wraps_other_errors_as_database_error() {
666 let err = pool_error(sqlx::Error::RowNotFound);
667 assert!(
668 matches!(err, OutboxError::Database(_)),
669 "non-timeout errors must map to OutboxError::Database, got {err:?}"
670 );
671 }
672
673 #[tokio::test]
674 async fn store_new_rejects_invalid_table_name() {
675 let err = MySqlOutboxStore::new(lazy_pool(), "bad name; DROP").unwrap_err();
676 assert!(matches!(err, OutboxError::Internal(_)));
677 }
678
679 #[tokio::test]
680 async fn store_new_caches_mysql_sql_with_validated_table_name() {
681 let store = MySqlOutboxStore::new(lazy_pool(), "audit_outbox").unwrap();
682 assert_eq!(store.table_name(), "audit_outbox");
683 assert!(store.poll_sql.contains("FROM `audit_outbox`"));
684 assert!(store.poll_sql.contains("FOR UPDATE SKIP LOCKED"));
685 assert!(store.poll_sql.contains("UTC_TIMESTAMP(6)"));
686 assert!(store.mark_delivered_sql.contains("UPDATE `audit_outbox`"));
687 assert!(!store.mark_failed_sql.contains("attempts = attempts + 1"));
690 }
691
692 #[tokio::test]
693 async fn publisher_new_caches_insert_sql_with_question_marks() {
694 let publisher = MySqlOutboxPublisher::new(lazy_pool(), "audit_outbox").unwrap();
695 assert_eq!(publisher.table_name(), "audit_outbox");
696 assert!(publisher.insert_sql.contains("INSERT INTO `audit_outbox`"));
697 assert!(publisher.insert_sql.contains("?, ?, ?, ?"));
698 }
699
700 #[tokio::test]
701 async fn builder_starts_with_default_table_and_empty_handlers() {
702 let builder = MySqlOutboxWorkerBuilder::new(lazy_pool());
703 assert_eq!(builder.table_name, DEFAULT_TABLE_NAME);
704 assert!(builder.handlers.is_empty());
705 }
706
707 #[tokio::test]
708 async fn builder_register_handler_records_event_types() {
709 let builder = MySqlOutboxWorkerBuilder::new(lazy_pool())
710 .register_handler::<UserRegistered, _>(NoopHandler)
711 .register_handler::<OrderPlaced, _>(NoopHandler);
712 assert_eq!(builder.handlers.len(), 2);
713 assert!(builder.handlers.contains_key("users.registered"));
714 assert!(builder.handlers.contains_key("orders.placed"));
715 }
716
717 #[tokio::test]
718 async fn builder_build_rejects_invalid_table_name() {
719 let result = MySqlOutboxWorkerBuilder::new(lazy_pool())
720 .table_name("bad name; DROP TABLE")
721 .build();
722 assert!(matches!(result, Err(OutboxError::Internal(_))));
723 }
724
725 #[tokio::test]
726 async fn builder_build_with_default_table_name_succeeds() {
727 let worker = MySqlOutboxWorkerBuilder::new(lazy_pool()).build();
728 assert!(worker.is_ok());
729 }
730
731 #[tokio::test]
732 async fn builder_dispatch_timeout_overrides_default() {
733 let worker = MySqlOutboxWorkerBuilder::new(lazy_pool())
734 .dispatch_timeout(Duration::from_secs(60))
735 .build()
736 .unwrap();
737 drop(worker);
738 }
739
740 #[test]
741 fn store_claim_sql_embeds_table_name_and_question_mark_placeholders() {
742 let sql = DIALECT.claim_sql("audit_outbox", 2);
743 assert!(sql.contains("UPDATE `audit_outbox`"));
744 assert!(sql.contains("next_retry_at = (UTC_TIMESTAMP(6) + INTERVAL ? MICROSECOND)"));
745 assert!(sql.contains("WHERE event_id IN (?, ?)"));
746 assert!(sql.contains("attempts = attempts + 1"));
747 }
748}