Skip to main content

hexeract_outbox_sql/
mysql.rs

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
35/// Convert a backoff/lease [`Duration`] into whole microseconds for binding to
36/// a MySQL `INTERVAL ? MICROSECOND` expression, saturating at [`i64::MAX`].
37fn 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
53/// Decode one polled row into an [`OutboxEnvelope`].
54///
55/// Kept separate from the poll loop so a decode failure can be isolated to the
56/// offending row (logged and skipped) instead of aborting the whole batch.
57fn 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
88/// Apply the canonical MySQL outbox schema to the target database.
89///
90/// **Intended for POCs, integration tests and local development.**
91/// Production deployments should run their own migration tooling against the
92/// SQL rendered by [`Dialect::schema_ddl`].
93///
94/// # Errors
95///
96/// - [`OutboxError::Internal`] if `table_name` is not a valid identifier.
97/// - [`OutboxError::Database`] if the connection or the DDL statement fails.
98pub 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/// MySQL implementation of [`OutboxStore`] backed by `sqlx::MySqlPool`.
108///
109/// Cheap to clone (the pool and the cached SQL strings are reference-counted).
110#[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    /// Build a store for the given pool and table.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`OutboxError::Internal`] if `table_name` is not a valid
126    /// identifier matching `^[a-zA-Z_][a-zA-Z0-9_]*$`.
127    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    /// Underlying pool.
144    #[must_use]
145    pub fn pool(&self) -> &MySqlPool {
146        &self.pool
147    }
148
149    /// Configured table name.
150    #[must_use]
151    pub fn table_name(&self) -> &str {
152        &self.table_name
153    }
154
155    /// Activate dead-letter persistence for poison messages.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`OutboxError::Internal`] if `dlq_table` is not a valid identifier.
160    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            // A single undecodable row (schema drift, corrupt payload) must not
204            // abort the whole poll: that head-of-line poisons the queue forever
205            // (#214). Log it and skip so the rest of the batch keeps draining.
206            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        // The SQL adds this many microseconds to the DB clock (#230).
242        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        // The SQL adds this many microseconds to the DB clock (#230).
289        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/// MySQL implementation of [`OutboxPublisher`] backed by `sqlx::MySqlPool`.
299///
300/// Cheap to clone (the pool and the cached insert statement are reference-counted).
301#[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    /// Create a new publisher for the given pool and table.
311    ///
312    /// # Errors
313    ///
314    /// Returns [`OutboxError::Internal`] if `table_name` is not a valid
315    /// identifier matching `^[a-zA-Z_][a-zA-Z0-9_]*$`.
316    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    /// Underlying pool, exposed for callers that open their own transactions.
330    #[must_use]
331    pub fn pool(&self) -> &MySqlPool {
332        &self.pool
333    }
334
335    /// Configured table name.
336    #[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            // MySQL uses INSERT ... SELECT ... WHERE NOT EXISTS, so event_id
407            // must be bound a second time for the EXISTS sub-query.
408            .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
417/// Fluent builder for an [`OutboxWorker`] backed by [`MySqlOutboxStore`].
418///
419/// # Pool sizing and acquire timeout
420///
421/// The worker and every concurrent publisher draw connections from the same
422/// pool. To avoid indefinite blocking under pressure, configure an acquire
423/// timeout on the `MySqlPool` before passing it here:
424///
425/// ```rust,ignore
426/// use sqlx::mysql::MySqlPoolOptions;
427/// use std::time::Duration;
428///
429/// let pool = MySqlPoolOptions::new()
430///     // 1 connection for the claim cycle + 1 per concurrent publisher + 2 headroom
431///     .max_connections(batch_size + num_publishers + 2)
432///     // surface PoolTimeout instead of blocking indefinitely
433///     .acquire_timeout(Duration::from_secs(5))
434///     .connect("mysql://...")
435///     .await?;
436///
437/// let worker = MySqlOutboxWorkerBuilder::new(pool)
438///     .batch_size(batch_size)
439///     .build()?;
440/// ```
441///
442/// When `acquire_timeout` expires, [`OutboxStore::acquire`] returns
443/// [`OutboxError::PoolTimeout`] instead of hanging. The worker logs the
444/// error and retries after [`OutboxWorkerConfig::poll_interval`].
445///
446/// [`OutboxError::PoolTimeout`]: hexeract_outbox::OutboxError::PoolTimeout
447/// [`OutboxWorkerConfig::poll_interval`]: hexeract_outbox::OutboxWorkerConfig::poll_interval
448pub 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    /// Start a new builder for the given pool.
458    #[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    /// Override the outbox table name (default `"audit_outbox"`).
470    #[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    /// Enable dead-letter persistence for poison messages.
477    #[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    /// Register a typed handler for the event type `E`.
484    ///
485    /// Registering twice for the same event type silently replaces the
486    /// previous handler.
487    #[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    /// Register a handler already shared behind an `Arc`.
500    #[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    /// Override the poll interval (default 100 ms).
513    #[must_use]
514    pub fn poll_interval(mut self, d: Duration) -> Self {
515        self.config.poll_interval = d;
516        self
517    }
518
519    /// Override the batch size per poll (default 10).
520    #[must_use]
521    pub fn batch_size(mut self, n: usize) -> Self {
522        self.config.batch_size = n;
523        self
524    }
525
526    /// Override the maximum number of attempts per envelope (default 5).
527    #[must_use]
528    pub fn max_attempts(mut self, n: u32) -> Self {
529        self.config.max_attempts = n;
530        self
531    }
532
533    /// Override the base delay for exponential backoff (default 1 s).
534    #[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    /// Override the maximum backoff delay (default 5 min).
541    #[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    /// Enable or disable full jitter on the backoff delay (default `true`).
548    #[must_use]
549    pub fn jitter(mut self, enabled: bool) -> Self {
550        self.config.jitter = enabled;
551        self
552    }
553
554    /// Override the per-envelope handler deadline and soft-lease unit
555    /// (default 30 s).
556    ///
557    /// Each handler invocation is wrapped in a hard `tokio` timeout of this
558    /// duration; the batch lease is sized as `batch_size x dispatch_timeout`
559    /// internally. Set it to the worst-case duration of a single handler.
560    #[must_use]
561    pub fn dispatch_timeout(mut self, d: Duration) -> Self {
562        self.config.dispatch_timeout = d;
563        self
564    }
565
566    /// Consume the builder and produce an [`OutboxWorker`] ready to spawn.
567    ///
568    /// # Errors
569    ///
570    /// Returns [`OutboxError::Internal`] if the configured `table_name`
571    /// is not a valid identifier.
572    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
581/// Apply the dead-letter schema to the target MySQL database.
582///
583/// **Intended for POCs, integration tests and local development.**
584///
585/// # Errors
586///
587/// - [`OutboxError::Internal`] if `table_name` is not a valid identifier.
588/// - [`OutboxError::Database`] if the connection or the DDL statement fails.
589pub 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        // The attempt increment lives in claim_sql now (see #213), so
688        // mark_failed must not increment again.
689        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}