1use std::collections::HashMap;
14use std::sync::Arc;
15use std::time::Duration;
16
17use async_trait::async_trait;
18use hexeract_outbox::ErasedHandler;
19use hexeract_outbox::Event;
20use hexeract_outbox::Handler;
21use hexeract_outbox::IdempotentOutboxEnqueue;
22use hexeract_outbox::OutboxEnvelope;
23use hexeract_outbox::OutboxError;
24use hexeract_outbox::OutboxPublisher;
25use hexeract_outbox::OutboxStore;
26use hexeract_outbox::OutboxWorker;
27use hexeract_outbox::OutboxWorkerConfig;
28use hexeract_outbox::TypedHandler;
29use sqlx::Acquire;
30use sqlx::Row;
31use sqlx::Sqlite;
32use sqlx::SqlitePool;
33use sqlx::Transaction;
34use sqlx::pool::PoolConnection;
35use uuid::Uuid;
36
37use crate::DEFAULT_TABLE_NAME;
38use crate::dialect::Dialect;
39use crate::envelope::assemble_envelope;
40use crate::envelope::parse_sqlite_utc;
41use crate::validate::validate_event_type;
42use crate::validate::validate_table_name;
43
44const DIALECT: Dialect = Dialect::Sqlite;
45
46const MAX_SQLITE_INTERVAL_SECS: u64 = 9_223_372_036; fn sqlite_seconds_modifier(d: Duration) -> String {
63 let capped = d.min(Duration::from_secs(MAX_SQLITE_INTERVAL_SECS));
64 format!("+{:.3} seconds", capped.as_secs_f64())
65}
66
67fn database_error(error: impl std::error::Error + Send + Sync + 'static) -> OutboxError {
68 OutboxError::Database(Box::new(error))
69}
70
71fn pool_error(error: sqlx::Error) -> OutboxError {
72 if matches!(error, sqlx::Error::PoolTimedOut) {
73 OutboxError::PoolTimeout
74 } else {
75 OutboxError::Database(Box::new(error))
76 }
77}
78
79fn decode_sqlite_row(row: &sqlx::sqlite::SqliteRow) -> Result<OutboxEnvelope, OutboxError> {
85 let event_id: Uuid = row.try_get("event_id").map_err(database_error)?;
86 let event_type: String = row.try_get("event_type").map_err(database_error)?;
87 let payload: serde_json::Value = row.try_get("payload").map_err(database_error)?;
88 let subject_id: Option<Uuid> = row.try_get("subject_id").map_err(database_error)?;
89 let created_at: String = row.try_get("created_at").map_err(database_error)?;
90 let attempts: i64 = row.try_get("attempts").map_err(database_error)?;
91 let last_error: Option<String> = row.try_get("last_error").map_err(database_error)?;
92 let next_retry_at: Option<String> = row.try_get("next_retry_at").map_err(database_error)?;
93
94 let payload = serde_json::to_vec(&payload)?;
95 let next_retry_at = next_retry_at.as_deref().map(parse_sqlite_utc).transpose()?;
96
97 Ok(assemble_envelope(
98 event_id,
99 event_type,
100 payload,
101 subject_id,
102 parse_sqlite_utc(&created_at)?,
103 u32::try_from(attempts.max(0)).unwrap_or(u32::MAX),
104 last_error,
105 next_retry_at,
106 ))
107}
108
109#[derive(Debug, Clone)]
110struct DeadLetterSql {
111 insert_sql: Arc<str>,
112 delete_sql: Arc<str>,
113}
114
115pub async fn ensure_schema(pool: &SqlitePool, table_name: &str) -> Result<(), OutboxError> {
126 let ddl = DIALECT.schema_ddl(table_name)?;
127 sqlx::raw_sql(&ddl)
128 .execute(pool)
129 .await
130 .map_err(database_error)?;
131 Ok(())
132}
133
134#[derive(Debug, Clone)]
139pub struct SqliteOutboxStore {
140 pool: SqlitePool,
141 table_name: Arc<str>,
142 poll_sql: Arc<str>,
143 mark_delivered_sql: Arc<str>,
144 mark_failed_sql: Arc<str>,
145 dead_letter: Option<Arc<DeadLetterSql>>,
146}
147
148impl SqliteOutboxStore {
149 pub fn new(pool: SqlitePool, table_name: impl Into<String>) -> Result<Self, OutboxError> {
156 let table_name = table_name.into();
157 validate_table_name(&table_name)?;
158 let poll_sql = DIALECT.poll_sql(&table_name);
159 let mark_delivered_sql = DIALECT.mark_delivered_sql(&table_name);
160 let mark_failed_sql = DIALECT.mark_failed_sql(&table_name);
161 Ok(Self {
162 pool,
163 table_name: Arc::from(table_name),
164 poll_sql: Arc::from(poll_sql),
165 mark_delivered_sql: Arc::from(mark_delivered_sql),
166 mark_failed_sql: Arc::from(mark_failed_sql),
167 dead_letter: None,
168 })
169 }
170
171 #[must_use]
173 pub fn pool(&self) -> &SqlitePool {
174 &self.pool
175 }
176
177 #[must_use]
179 pub fn table_name(&self) -> &str {
180 &self.table_name
181 }
182
183 pub fn with_dead_letter(mut self, dlq_table: impl Into<String>) -> Result<Self, OutboxError> {
189 let dlq = dlq_table.into();
190 validate_table_name(&dlq)?;
191 let insert_sql = DIALECT.insert_dead_letter_sql(&self.table_name, &dlq);
192 let delete_sql = DIALECT.delete_from_main_sql(&self.table_name);
193 self.dead_letter = Some(Arc::new(DeadLetterSql {
194 insert_sql: Arc::from(insert_sql),
195 delete_sql: Arc::from(delete_sql),
196 }));
197 Ok(self)
198 }
199}
200
201#[async_trait]
202impl OutboxStore for SqliteOutboxStore {
203 type Client = PoolConnection<Sqlite>;
204 type Tx<'tx> = Transaction<'tx, Sqlite>;
205
206 async fn acquire(&self) -> Result<Self::Client, OutboxError> {
207 self.pool.acquire().await.map_err(pool_error)
208 }
209
210 async fn begin<'a>(&self, client: &'a mut Self::Client) -> Result<Self::Tx<'a>, OutboxError> {
211 client.begin().await.map_err(database_error)
212 }
213
214 async fn poll<'a>(
215 &self,
216 tx: &mut Self::Tx<'a>,
217 batch_size: usize,
218 max_attempts: u32,
219 ) -> Result<Vec<OutboxEnvelope>, OutboxError> {
220 let limit = i64::try_from(batch_size).unwrap_or(i64::MAX);
221 let max = i64::from(max_attempts);
222 let rows = sqlx::query(&self.poll_sql)
223 .bind(max)
224 .bind(limit)
225 .fetch_all(&mut **tx)
226 .await
227 .map_err(database_error)?;
228
229 let mut envelopes = Vec::with_capacity(rows.len());
230 for row in rows {
231 match decode_sqlite_row(&row) {
236 Ok(envelope) => envelopes.push(envelope),
237 Err(error) => {
238 let event_id = row.try_get::<Uuid, _>("event_id").ok();
239 tracing::error!(
240 ?event_id,
241 error = %error,
242 "skipping undecodable outbox row; the rest of the batch continues"
243 );
244 }
245 }
246 }
247 Ok(envelopes)
248 }
249
250 async fn mark_delivered<'a>(
251 &self,
252 tx: &mut Self::Tx<'a>,
253 event_id: Uuid,
254 ) -> Result<(), OutboxError> {
255 sqlx::query(&self.mark_delivered_sql)
256 .bind(event_id)
257 .execute(&mut **tx)
258 .await
259 .map_err(database_error)?;
260 Ok(())
261 }
262
263 async fn mark_failed<'a>(
264 &self,
265 tx: &mut Self::Tx<'a>,
266 event_id: Uuid,
267 error: &str,
268 retry_in: Duration,
269 ) -> Result<(), OutboxError> {
270 sqlx::query(&self.mark_failed_sql)
273 .bind(error)
274 .bind(sqlite_seconds_modifier(retry_in))
275 .bind(event_id)
276 .execute(&mut **tx)
277 .await
278 .map_err(database_error)?;
279 Ok(())
280 }
281
282 async fn commit<'a>(&self, tx: Self::Tx<'a>) -> Result<(), OutboxError> {
283 tx.commit().await.map_err(database_error)
284 }
285
286 async fn mark_dead_lettered<'a>(
287 &self,
288 tx: &mut Self::Tx<'a>,
289 event_id: Uuid,
290 _error: &str,
291 ) -> Result<(), OutboxError> {
292 let Some(dlq) = &self.dead_letter else {
293 return Ok(());
294 };
295 sqlx::query(&dlq.insert_sql)
296 .bind(event_id)
297 .execute(&mut **tx)
298 .await
299 .map_err(database_error)?;
300 sqlx::query(&dlq.delete_sql)
301 .bind(event_id)
302 .execute(&mut **tx)
303 .await
304 .map_err(database_error)?;
305 Ok(())
306 }
307
308 async fn claim<'a>(
317 &self,
318 tx: &mut Self::Tx<'a>,
319 event_ids: &[Uuid],
320 lease_for: Duration,
321 ) -> Result<(), OutboxError> {
322 if event_ids.is_empty() {
323 return Ok(());
324 }
325 let sql = DIALECT.claim_sql(&self.table_name, event_ids.len());
327 let mut query = sqlx::query(&sql).bind(sqlite_seconds_modifier(lease_for));
328 for id in event_ids {
329 query = query.bind(*id);
330 }
331 query.execute(&mut **tx).await.map_err(database_error)?;
332 Ok(())
333 }
334}
335
336#[derive(Debug, Clone)]
340pub struct SqliteOutboxPublisher {
341 pool: SqlitePool,
342 table_name: Arc<str>,
343 insert_sql: Arc<str>,
344 idempotent_insert_sql: Arc<str>,
345}
346
347impl SqliteOutboxPublisher {
348 pub fn new(pool: SqlitePool, table_name: impl Into<String>) -> Result<Self, OutboxError> {
355 let table_name = table_name.into();
356 validate_table_name(&table_name)?;
357 let insert_sql = DIALECT.insert_sql(&table_name);
358 let idempotent_insert_sql = DIALECT.insert_idempotent_sql(&table_name);
359 Ok(Self {
360 pool,
361 table_name: Arc::from(table_name),
362 insert_sql: Arc::from(insert_sql),
363 idempotent_insert_sql: Arc::from(idempotent_insert_sql),
364 })
365 }
366
367 #[must_use]
369 pub fn pool(&self) -> &SqlitePool {
370 &self.pool
371 }
372
373 #[must_use]
375 pub fn table_name(&self) -> &str {
376 &self.table_name
377 }
378}
379
380impl OutboxPublisher for SqliteOutboxPublisher {
381 type Tx<'tx> = Transaction<'tx, Sqlite>;
382
383 async fn publish_in_tx<E: Event>(
384 &self,
385 tx: &mut Self::Tx<'_>,
386 event: &E,
387 ) -> Result<Uuid, OutboxError> {
388 validate_event_type(E::EVENT_TYPE)?;
389 let event_id = Uuid::now_v7();
390 let payload = serde_json::to_value(event)?;
391 sqlx::query(&self.insert_sql)
392 .bind(event_id)
393 .bind(E::EVENT_TYPE)
394 .bind(payload)
395 .bind(Option::<Uuid>::None)
396 .execute(&mut **tx)
397 .await
398 .map_err(database_error)?;
399 Ok(event_id)
400 }
401
402 async fn publish_in_tx_with_subject<E: Event>(
403 &self,
404 tx: &mut Self::Tx<'_>,
405 subject_id: Uuid,
406 event: &E,
407 ) -> Result<Uuid, OutboxError> {
408 validate_event_type(E::EVENT_TYPE)?;
409 let event_id = Uuid::now_v7();
410 let payload = serde_json::to_value(event)?;
411 sqlx::query(&self.insert_sql)
412 .bind(event_id)
413 .bind(E::EVENT_TYPE)
414 .bind(payload)
415 .bind(Some(subject_id))
416 .execute(&mut **tx)
417 .await
418 .map_err(database_error)?;
419 Ok(event_id)
420 }
421
422 async fn publish<E: Event>(&self, event: &E) -> Result<Uuid, OutboxError> {
423 let mut tx = self.pool.begin().await.map_err(database_error)?;
424 let event_id = self.publish_in_tx(&mut tx, event).await?;
425 tx.commit().await.map_err(database_error)?;
426 Ok(event_id)
427 }
428}
429
430impl IdempotentOutboxEnqueue for SqliteOutboxPublisher {
431 async fn enqueue_idempotent(
432 &self,
433 event_id: Uuid,
434 event_type: &str,
435 payload: &[u8],
436 ) -> Result<bool, OutboxError> {
437 validate_event_type(event_type)?;
438 let payload_value = serde_json::from_slice::<serde_json::Value>(payload)?;
439 let mut tx = self.pool.begin().await.map_err(database_error)?;
440 let result = sqlx::query(&self.idempotent_insert_sql)
441 .bind(event_id)
442 .bind(event_type)
443 .bind(payload_value)
444 .execute(&mut *tx)
445 .await
446 .map_err(database_error)?;
447 tx.commit().await.map_err(database_error)?;
448 Ok(result.rows_affected() > 0)
449 }
450}
451
452pub struct SqliteOutboxWorkerBuilder {
484 pool: SqlitePool,
485 table_name: String,
486 dead_letter_table: Option<String>,
487 handlers: HashMap<&'static str, Arc<dyn ErasedHandler>>,
488 config: OutboxWorkerConfig,
489}
490
491impl SqliteOutboxWorkerBuilder {
492 #[must_use]
494 pub fn new(pool: SqlitePool) -> Self {
495 Self {
496 pool,
497 table_name: DEFAULT_TABLE_NAME.to_owned(),
498 dead_letter_table: None,
499 handlers: HashMap::new(),
500 config: OutboxWorkerConfig::default(),
501 }
502 }
503
504 #[must_use]
506 pub fn table_name(mut self, name: impl Into<String>) -> Self {
507 self.table_name = name.into();
508 self
509 }
510
511 #[must_use]
513 pub fn dead_letter_table(mut self, name: impl Into<String>) -> Self {
514 self.dead_letter_table = Some(name.into());
515 self
516 }
517
518 #[must_use]
523 pub fn register_handler<E, H>(mut self, handler: H) -> Self
524 where
525 E: Event,
526 H: Handler<E>,
527 {
528 let typed = TypedHandler::<E, H>::new(handler);
529 let erased: Arc<dyn ErasedHandler> = Arc::new(typed);
530 self.handlers.insert(E::EVENT_TYPE, erased);
531 self
532 }
533
534 #[must_use]
536 pub fn shared_handler<E, H>(mut self, handler: Arc<H>) -> Self
537 where
538 E: Event,
539 H: Handler<E>,
540 {
541 let typed = TypedHandler::<E, H>::shared(handler);
542 let erased: Arc<dyn ErasedHandler> = Arc::new(typed);
543 self.handlers.insert(E::EVENT_TYPE, erased);
544 self
545 }
546
547 #[must_use]
549 pub fn poll_interval(mut self, d: Duration) -> Self {
550 self.config.poll_interval = d;
551 self
552 }
553
554 #[must_use]
556 pub fn batch_size(mut self, n: usize) -> Self {
557 self.config.batch_size = n;
558 self
559 }
560
561 #[must_use]
563 pub fn max_attempts(mut self, n: u32) -> Self {
564 self.config.max_attempts = n;
565 self
566 }
567
568 #[must_use]
570 pub fn retry_base_delay(mut self, d: Duration) -> Self {
571 self.config.retry_base_delay = d;
572 self
573 }
574
575 #[must_use]
577 pub fn retry_max_delay(mut self, d: Duration) -> Self {
578 self.config.retry_max_delay = d;
579 self
580 }
581
582 #[must_use]
584 pub fn jitter(mut self, enabled: bool) -> Self {
585 self.config.jitter = enabled;
586 self
587 }
588
589 #[must_use]
598 pub fn dispatch_timeout(mut self, d: Duration) -> Self {
599 self.config.dispatch_timeout = d;
600 self
601 }
602
603 pub fn build(self) -> Result<OutboxWorker<SqliteOutboxStore>, OutboxError> {
610 let mut store = SqliteOutboxStore::new(self.pool, self.table_name)?;
611 if let Some(dlq) = self.dead_letter_table {
612 store = store.with_dead_letter(dlq)?;
613 }
614 Ok(OutboxWorker::new(store, self.handlers, self.config))
615 }
616}
617
618pub async fn ensure_dead_letter_schema(
627 pool: &SqlitePool,
628 table_name: &str,
629) -> Result<(), OutboxError> {
630 let ddl = DIALECT.dead_letter_schema_ddl(table_name)?;
631 sqlx::raw_sql(&ddl)
632 .execute(pool)
633 .await
634 .map_err(database_error)?;
635 Ok(())
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use hexeract_core::HandlerContext;
642 use serde::Deserialize;
643 use serde::Serialize;
644
645 fn lazy_pool() -> SqlitePool {
646 SqlitePool::connect_lazy("sqlite::memory:").expect("lazy pool must build from a valid URL")
647 }
648
649 #[derive(Debug, Serialize, Deserialize)]
650 struct UserRegistered {
651 user_id: Uuid,
652 }
653
654 impl Event for UserRegistered {
655 const EVENT_TYPE: &'static str = "users.registered";
656 }
657
658 #[derive(Debug, Serialize, Deserialize)]
659 struct OrderPlaced {
660 order_id: Uuid,
661 }
662
663 impl Event for OrderPlaced {
664 const EVENT_TYPE: &'static str = "orders.placed";
665 }
666
667 struct NoopHandler;
668
669 impl Handler<UserRegistered> for NoopHandler {
670 type Error = OutboxError;
671 async fn handle(
672 &self,
673 _event: UserRegistered,
674 _ctx: &HandlerContext,
675 ) -> Result<(), Self::Error> {
676 Ok(())
677 }
678 }
679
680 impl Handler<OrderPlaced> for NoopHandler {
681 type Error = OutboxError;
682 async fn handle(
683 &self,
684 _event: OrderPlaced,
685 _ctx: &HandlerContext,
686 ) -> Result<(), Self::Error> {
687 Ok(())
688 }
689 }
690
691 #[test]
692 fn pool_error_maps_pool_timed_out_to_pool_timeout_variant() {
693 let err = pool_error(sqlx::Error::PoolTimedOut);
694 assert!(
695 matches!(err, OutboxError::PoolTimeout),
696 "PoolTimedOut must map to OutboxError::PoolTimeout, got {err:?}"
697 );
698 }
699
700 #[test]
701 fn pool_error_wraps_other_errors_as_database_error() {
702 let err = pool_error(sqlx::Error::RowNotFound);
703 assert!(
704 matches!(err, OutboxError::Database(_)),
705 "non-timeout errors must map to OutboxError::Database, got {err:?}"
706 );
707 }
708
709 #[tokio::test]
710 async fn store_new_rejects_invalid_table_name() {
711 let err = SqliteOutboxStore::new(lazy_pool(), "bad name; DROP").unwrap_err();
712 assert!(matches!(err, OutboxError::Internal(_)));
713 }
714
715 #[tokio::test]
716 async fn store_new_caches_sqlite_sql_without_skip_locked() {
717 let store = SqliteOutboxStore::new(lazy_pool(), "audit_outbox").unwrap();
718 assert_eq!(store.table_name(), "audit_outbox");
719 assert!(store.poll_sql.contains("FROM \"audit_outbox\""));
720 assert!(!store.poll_sql.contains("FOR UPDATE SKIP LOCKED"));
721 assert!(store.poll_sql.contains("strftime"));
722 assert!(!store.mark_failed_sql.contains("attempts = attempts + 1"));
725 }
726
727 #[tokio::test]
728 async fn publisher_new_caches_insert_sql_with_question_marks() {
729 let publisher = SqliteOutboxPublisher::new(lazy_pool(), "audit_outbox").unwrap();
730 assert_eq!(publisher.table_name(), "audit_outbox");
731 assert!(
732 publisher
733 .insert_sql
734 .contains("INSERT INTO \"audit_outbox\"")
735 );
736 assert!(publisher.insert_sql.contains("?, ?, ?, ?"));
737 }
738
739 #[test]
740 fn sqlite_seconds_modifier_caps_huge_duration() {
741 let modifier = sqlite_seconds_modifier(Duration::MAX);
744 assert!(
745 !modifier.contains("inf"),
746 "Duration::MAX must not produce an inf modifier, got: {modifier}"
747 );
748 assert!(modifier.starts_with('+'), "modifier must start with '+'");
749 assert!(
750 modifier.ends_with(" seconds"),
751 "modifier must end with ' seconds'"
752 );
753 }
754
755 #[test]
756 fn sqlite_seconds_modifier_preserves_ordinary_values() {
757 let modifier = sqlite_seconds_modifier(Duration::from_millis(1_500));
758 assert_eq!(modifier, "+1.500 seconds");
759 }
760
761 #[tokio::test]
762 async fn builder_register_handler_records_event_types() {
763 let builder = SqliteOutboxWorkerBuilder::new(lazy_pool())
764 .register_handler::<UserRegistered, _>(NoopHandler)
765 .register_handler::<OrderPlaced, _>(NoopHandler);
766 assert_eq!(builder.handlers.len(), 2);
767 assert!(builder.handlers.contains_key("users.registered"));
768 assert!(builder.handlers.contains_key("orders.placed"));
769 }
770
771 #[tokio::test]
772 async fn builder_build_rejects_invalid_table_name() {
773 let result = SqliteOutboxWorkerBuilder::new(lazy_pool())
774 .table_name("bad name; DROP TABLE")
775 .build();
776 assert!(matches!(result, Err(OutboxError::Internal(_))));
777 }
778
779 #[tokio::test]
780 async fn builder_build_with_default_table_name_succeeds() {
781 let worker = SqliteOutboxWorkerBuilder::new(lazy_pool()).build();
782 assert!(worker.is_ok());
783 }
784}