Skip to main content

hexeract_outbox_sql/
postgres.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::PgPool;
19use sqlx::Postgres;
20use sqlx::Row;
21use sqlx::Transaction;
22use sqlx::pool::PoolConnection;
23use time::OffsetDateTime;
24use uuid::Uuid;
25
26use crate::DEFAULT_TABLE_NAME;
27use crate::dialect::Dialect;
28use crate::envelope::assemble_envelope;
29use crate::envelope::to_system_time;
30use crate::validate::validate_event_type;
31use crate::validate::validate_table_name;
32
33const DIALECT: Dialect = Dialect::Postgres;
34
35/// Maximum interval in seconds that can be safely cast to `DOUBLE PRECISION`
36/// and added to a PostgreSQL `TIMESTAMPTZ` or `INTERVAL`.
37///
38/// PostgreSQL's `TIMESTAMPTZ` spans roughly from 4713 BC to 294276 AD.
39/// A `DOUBLE PRECISION` representation of a duration near [`Duration::MAX`]
40/// overflows to `inf`, which PostgreSQL rejects with `ERROR: interval out of
41/// range`. Capping at this value (roughly 292 years) prevents that error while
42/// being far beyond any practical retry interval.
43const MAX_PG_INTERVAL_SECS: f64 = 9_223_372_036.0; // i64::MAX seconds
44
45/// Convert a backoff/lease [`Duration`] to seconds for binding as
46/// `DOUBLE PRECISION` in `(NOW() + CAST($n AS DOUBLE PRECISION) * INTERVAL '1 second')`.
47///
48/// Caps at [`MAX_PG_INTERVAL_SECS`] so that pathologically large durations
49/// do not produce `inf`, which PostgreSQL would reject.
50fn duration_to_pg_secs(d: Duration) -> f64 {
51    d.as_secs_f64().min(MAX_PG_INTERVAL_SECS)
52}
53
54fn database_error(error: impl std::error::Error + Send + Sync + 'static) -> OutboxError {
55    OutboxError::Database(Box::new(error))
56}
57
58fn pool_error(error: sqlx::Error) -> OutboxError {
59    if matches!(error, sqlx::Error::PoolTimedOut) {
60        OutboxError::PoolTimeout
61    } else {
62        OutboxError::Database(Box::new(error))
63    }
64}
65
66/// Decode one polled row into an [`OutboxEnvelope`].
67///
68/// Kept separate from the poll loop so a decode failure can be isolated to the
69/// offending row (logged and skipped) instead of aborting the whole batch.
70fn decode_pg_row(row: &sqlx::postgres::PgRow) -> Result<OutboxEnvelope, OutboxError> {
71    let event_id: Uuid = row.try_get("event_id").map_err(database_error)?;
72    let event_type: String = row.try_get("event_type").map_err(database_error)?;
73    let payload: serde_json::Value = row.try_get("payload").map_err(database_error)?;
74    let subject_id: Option<Uuid> = row.try_get("subject_id").map_err(database_error)?;
75    let created_at: OffsetDateTime = row.try_get("created_at").map_err(database_error)?;
76    let attempts: i32 = row.try_get("attempts").map_err(database_error)?;
77    let last_error: Option<String> = row.try_get("last_error").map_err(database_error)?;
78    let next_retry_at: Option<OffsetDateTime> =
79        row.try_get("next_retry_at").map_err(database_error)?;
80
81    let payload = serde_json::to_vec(&payload)?;
82
83    Ok(assemble_envelope(
84        event_id,
85        event_type,
86        payload,
87        subject_id,
88        to_system_time(created_at),
89        u32::try_from(attempts.max(0)).unwrap_or(u32::MAX),
90        last_error,
91        next_retry_at.map(to_system_time),
92    ))
93}
94
95#[derive(Debug, Clone)]
96struct DeadLetterSql {
97    insert_sql: Arc<str>,
98    delete_sql: Arc<str>,
99}
100
101/// Apply the canonical Postgres outbox schema to the target database.
102///
103/// **Intended for POCs, integration tests and local development.**
104/// Production deployments should run their own migration tooling against the
105/// SQL rendered by [`Dialect::schema_ddl`]. Applying DDL from the running
106/// application typically requires elevated privileges that the runtime role
107/// should not own, and clashes with versioned migration workflows.
108///
109/// # Errors
110///
111/// - [`OutboxError::Internal`] if `table_name` is not a valid identifier.
112/// - [`OutboxError::Database`] if the connection or the DDL statement fails.
113pub async fn ensure_schema(pool: &PgPool, table_name: &str) -> Result<(), OutboxError> {
114    let ddl = DIALECT.schema_ddl(table_name)?;
115    sqlx::raw_sql(&ddl)
116        .execute(pool)
117        .await
118        .map_err(database_error)?;
119    Ok(())
120}
121
122/// PostgreSQL implementation of [`OutboxStore`] backed by `sqlx::PgPool`.
123///
124/// Cheap to clone (the pool and the cached SQL strings are reference-counted).
125#[derive(Debug, Clone)]
126pub struct PgOutboxStore {
127    pool: PgPool,
128    table_name: Arc<str>,
129    poll_sql: Arc<str>,
130    mark_delivered_sql: Arc<str>,
131    mark_failed_sql: Arc<str>,
132    dead_letter: Option<Arc<DeadLetterSql>>,
133}
134
135impl PgOutboxStore {
136    /// Build a store for the given pool and table.
137    ///
138    /// SQL statements are templated and cached at construction so each poll
139    /// cycle re-uses the same strings.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`OutboxError::Internal`] if `table_name` is not a valid
144    /// identifier matching `^[a-zA-Z_][a-zA-Z0-9_]*$`.
145    pub fn new(pool: PgPool, table_name: impl Into<String>) -> Result<Self, OutboxError> {
146        let table_name = table_name.into();
147        validate_table_name(&table_name)?;
148        let poll_sql = DIALECT.poll_sql(&table_name);
149        let mark_delivered_sql = DIALECT.mark_delivered_sql(&table_name);
150        let mark_failed_sql = DIALECT.mark_failed_sql(&table_name);
151        Ok(Self {
152            pool,
153            table_name: Arc::from(table_name),
154            poll_sql: Arc::from(poll_sql),
155            mark_delivered_sql: Arc::from(mark_delivered_sql),
156            mark_failed_sql: Arc::from(mark_failed_sql),
157            dead_letter: None,
158        })
159    }
160
161    /// Underlying pool.
162    #[must_use]
163    pub fn pool(&self) -> &PgPool {
164        &self.pool
165    }
166
167    /// Configured table name.
168    #[must_use]
169    pub fn table_name(&self) -> &str {
170        &self.table_name
171    }
172
173    /// Activate dead-letter persistence for this store.
174    ///
175    /// When enabled, envelopes that exhaust `max_attempts` are atomically
176    /// moved to `dlq_table` by [`OutboxStore::mark_dead_lettered`].
177    ///
178    /// # Errors
179    ///
180    /// Returns [`OutboxError::Internal`] if `dlq_table` is not a valid
181    /// identifier.
182    pub fn with_dead_letter(mut self, dlq_table: impl Into<String>) -> Result<Self, OutboxError> {
183        let dlq = dlq_table.into();
184        validate_table_name(&dlq)?;
185        let insert_sql = DIALECT.insert_dead_letter_sql(&self.table_name, &dlq);
186        let delete_sql = DIALECT.delete_from_main_sql(&self.table_name);
187        self.dead_letter = Some(Arc::new(DeadLetterSql {
188            insert_sql: Arc::from(insert_sql),
189            delete_sql: Arc::from(delete_sql),
190        }));
191        Ok(self)
192    }
193}
194
195#[async_trait]
196impl OutboxStore for PgOutboxStore {
197    type Client = PoolConnection<Postgres>;
198    type Tx<'tx> = Transaction<'tx, Postgres>;
199
200    async fn acquire(&self) -> Result<Self::Client, OutboxError> {
201        self.pool.acquire().await.map_err(pool_error)
202    }
203
204    async fn begin<'a>(&self, client: &'a mut Self::Client) -> Result<Self::Tx<'a>, OutboxError> {
205        client.begin().await.map_err(database_error)
206    }
207
208    async fn poll<'a>(
209        &self,
210        tx: &mut Self::Tx<'a>,
211        batch_size: usize,
212        max_attempts: u32,
213    ) -> Result<Vec<OutboxEnvelope>, OutboxError> {
214        let limit = i64::try_from(batch_size).unwrap_or(i64::MAX);
215        let max = i32::try_from(max_attempts).unwrap_or(i32::MAX);
216        let rows = sqlx::query(&self.poll_sql)
217            .bind(max)
218            .bind(limit)
219            .fetch_all(&mut **tx)
220            .await
221            .map_err(database_error)?;
222
223        let mut envelopes = Vec::with_capacity(rows.len());
224        for row in rows {
225            // A single undecodable row (schema drift, corrupt payload) must not
226            // abort the whole poll: that head-of-line poisons the queue forever
227            // (#214). Log it and skip so the rest of the batch keeps draining.
228            match decode_pg_row(&row) {
229                Ok(envelope) => envelopes.push(envelope),
230                Err(error) => {
231                    let event_id = row.try_get::<Uuid, _>("event_id").ok();
232                    tracing::error!(
233                        ?event_id,
234                        error = %error,
235                        "skipping undecodable outbox row; the rest of the batch continues"
236                    );
237                }
238            }
239        }
240        Ok(envelopes)
241    }
242
243    async fn mark_delivered<'a>(
244        &self,
245        tx: &mut Self::Tx<'a>,
246        event_id: Uuid,
247    ) -> Result<(), OutboxError> {
248        sqlx::query(&self.mark_delivered_sql)
249            .bind(event_id)
250            .execute(&mut **tx)
251            .await
252            .map_err(database_error)?;
253        Ok(())
254    }
255
256    async fn mark_failed<'a>(
257        &self,
258        tx: &mut Self::Tx<'a>,
259        event_id: Uuid,
260        error: &str,
261        retry_in: Duration,
262    ) -> Result<(), OutboxError> {
263        // The SQL adds this many seconds to the DB clock (#230); bind as f64.
264        // Capped so a Duration::MAX does not produce inf and cause Postgres to
265        // reject the interval (#240).
266        sqlx::query(&self.mark_failed_sql)
267            .bind(error)
268            .bind(duration_to_pg_secs(retry_in))
269            .bind(event_id)
270            .execute(&mut **tx)
271            .await
272            .map_err(database_error)?;
273        Ok(())
274    }
275
276    async fn commit<'a>(&self, tx: Self::Tx<'a>) -> Result<(), OutboxError> {
277        tx.commit().await.map_err(database_error)
278    }
279
280    async fn mark_dead_lettered<'a>(
281        &self,
282        tx: &mut Self::Tx<'a>,
283        event_id: Uuid,
284        _error: &str,
285    ) -> Result<(), OutboxError> {
286        let Some(dlq) = &self.dead_letter else {
287            return Ok(());
288        };
289        sqlx::query(&dlq.insert_sql)
290            .bind(event_id)
291            .execute(&mut **tx)
292            .await
293            .map_err(database_error)?;
294        sqlx::query(&dlq.delete_sql)
295            .bind(event_id)
296            .execute(&mut **tx)
297            .await
298            .map_err(database_error)?;
299        Ok(())
300    }
301
302    async fn claim<'a>(
303        &self,
304        tx: &mut Self::Tx<'a>,
305        event_ids: &[Uuid],
306        lease_for: Duration,
307    ) -> Result<(), OutboxError> {
308        if event_ids.is_empty() {
309            return Ok(());
310        }
311        // claim_sql for Postgres generates `= ANY($2)` so the UUID slice is
312        // bound as a single array parameter. This avoids the 65,535
313        // bind-parameter limit that a per-row IN-list would hit at large
314        // batch sizes (#240). n is passed for API consistency but is unused
315        // for Postgres (the SQL always has exactly two bind parameters).
316        let sql = DIALECT.claim_sql(&self.table_name, event_ids.len());
317        // $1: lease interval in seconds added to the DB clock (#230).
318        //     Capped so Duration::MAX does not produce inf (#240).
319        // $2: UUID array for ANY($2).
320        sqlx::query(&sql)
321            .bind(duration_to_pg_secs(lease_for))
322            .bind(event_ids)
323            .execute(&mut **tx)
324            .await
325            .map_err(database_error)?;
326        Ok(())
327    }
328}
329
330/// PostgreSQL implementation of [`OutboxPublisher`] backed by `sqlx::PgPool`.
331///
332/// Cheap to clone (the pool and the cached insert statement are reference-counted).
333#[derive(Debug, Clone)]
334pub struct PgOutboxPublisher {
335    pool: PgPool,
336    table_name: Arc<str>,
337    insert_sql: Arc<str>,
338    idempotent_insert_sql: Arc<str>,
339}
340
341impl PgOutboxPublisher {
342    /// Create a new publisher for the given pool and table.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`OutboxError::Internal`] if `table_name` is not a valid
347    /// identifier matching `^[a-zA-Z_][a-zA-Z0-9_]*$`.
348    pub fn new(pool: PgPool, table_name: impl Into<String>) -> Result<Self, OutboxError> {
349        let table_name = table_name.into();
350        validate_table_name(&table_name)?;
351        let insert_sql = DIALECT.insert_sql(&table_name);
352        let idempotent_insert_sql = DIALECT.insert_idempotent_sql(&table_name);
353        Ok(Self {
354            pool,
355            table_name: Arc::from(table_name),
356            insert_sql: Arc::from(insert_sql),
357            idempotent_insert_sql: Arc::from(idempotent_insert_sql),
358        })
359    }
360
361    /// Underlying pool, exposed for callers that open their own transactions.
362    #[must_use]
363    pub fn pool(&self) -> &PgPool {
364        &self.pool
365    }
366
367    /// Configured table name.
368    #[must_use]
369    pub fn table_name(&self) -> &str {
370        &self.table_name
371    }
372}
373
374impl OutboxPublisher for PgOutboxPublisher {
375    type Tx<'tx> = Transaction<'tx, Postgres>;
376
377    async fn publish_in_tx<E: Event>(
378        &self,
379        tx: &mut Self::Tx<'_>,
380        event: &E,
381    ) -> Result<Uuid, OutboxError> {
382        // Validate event_type length before the INSERT so a misconfigured
383        // implementation produces a clear OutboxError::Internal rather than an
384        // opaque database truncation/rejection error (#240).
385        validate_event_type(E::EVENT_TYPE)?;
386        let event_id = Uuid::now_v7();
387        let payload = serde_json::to_value(event)?;
388        sqlx::query(&self.insert_sql)
389            .bind(event_id)
390            .bind(E::EVENT_TYPE)
391            .bind(payload)
392            .bind(Option::<Uuid>::None)
393            .execute(&mut **tx)
394            .await
395            .map_err(database_error)?;
396        Ok(event_id)
397    }
398
399    async fn publish_in_tx_with_subject<E: Event>(
400        &self,
401        tx: &mut Self::Tx<'_>,
402        subject_id: Uuid,
403        event: &E,
404    ) -> Result<Uuid, OutboxError> {
405        validate_event_type(E::EVENT_TYPE)?;
406        let event_id = Uuid::now_v7();
407        let payload = serde_json::to_value(event)?;
408        sqlx::query(&self.insert_sql)
409            .bind(event_id)
410            .bind(E::EVENT_TYPE)
411            .bind(payload)
412            .bind(Some(subject_id))
413            .execute(&mut **tx)
414            .await
415            .map_err(database_error)?;
416        Ok(event_id)
417    }
418
419    async fn publish<E: Event>(&self, event: &E) -> Result<Uuid, OutboxError> {
420        let mut tx = self.pool.begin().await.map_err(database_error)?;
421        let event_id = self.publish_in_tx(&mut tx, event).await?;
422        tx.commit().await.map_err(database_error)?;
423        Ok(event_id)
424    }
425}
426
427impl IdempotentOutboxEnqueue for PgOutboxPublisher {
428    async fn enqueue_idempotent(
429        &self,
430        event_id: Uuid,
431        event_type: &str,
432        payload: &[u8],
433    ) -> Result<bool, OutboxError> {
434        validate_event_type(event_type)?;
435        let payload_value = serde_json::from_slice::<serde_json::Value>(payload)?;
436        let mut tx = self.pool.begin().await.map_err(database_error)?;
437        let result = sqlx::query(&self.idempotent_insert_sql)
438            .bind(event_id)
439            .bind(event_type)
440            .bind(payload_value)
441            .execute(&mut *tx)
442            .await
443            .map_err(database_error)?;
444        tx.commit().await.map_err(database_error)?;
445        Ok(result.rows_affected() > 0)
446    }
447}
448
449/// Fluent builder for an [`OutboxWorker`] backed by [`PgOutboxStore`].
450///
451/// # Pool sizing and acquire timeout
452///
453/// The worker and every concurrent publisher draw connections from the same
454/// pool. To avoid indefinite blocking under pressure, configure an acquire
455/// timeout on the `PgPool` before passing it here:
456///
457/// ```rust,ignore
458/// use sqlx::postgres::PgPoolOptions;
459/// use std::time::Duration;
460///
461/// let pool = PgPoolOptions::new()
462///     // 1 connection for the claim cycle + 1 per concurrent publisher + 2 headroom
463///     .max_connections(batch_size + num_publishers + 2)
464///     // surface PoolTimeout instead of blocking indefinitely
465///     .acquire_timeout(Duration::from_secs(5))
466///     .connect("postgres://...")
467///     .await?;
468///
469/// let worker = PgOutboxWorkerBuilder::new(pool)
470///     .batch_size(batch_size)
471///     .build()?;
472/// ```
473///
474/// When `acquire_timeout` expires, [`OutboxStore::acquire`] returns
475/// [`OutboxError::PoolTimeout`] instead of hanging. The worker logs the
476/// error and retries after [`OutboxWorkerConfig::poll_interval`].
477///
478/// [`OutboxError::PoolTimeout`]: hexeract_outbox::OutboxError::PoolTimeout
479/// [`OutboxWorkerConfig::poll_interval`]: hexeract_outbox::OutboxWorkerConfig::poll_interval
480pub struct PgOutboxWorkerBuilder {
481    pool: PgPool,
482    table_name: String,
483    dead_letter_table: Option<String>,
484    handlers: HashMap<&'static str, Arc<dyn ErasedHandler>>,
485    config: OutboxWorkerConfig,
486}
487
488impl PgOutboxWorkerBuilder {
489    /// Start a new builder for the given pool.
490    #[must_use]
491    pub fn new(pool: PgPool) -> Self {
492        Self {
493            pool,
494            table_name: DEFAULT_TABLE_NAME.to_owned(),
495            dead_letter_table: None,
496            handlers: HashMap::new(),
497            config: OutboxWorkerConfig::default(),
498        }
499    }
500
501    /// Override the outbox table name (default `"audit_outbox"`).
502    #[must_use]
503    pub fn table_name(mut self, name: impl Into<String>) -> Self {
504        self.table_name = name.into();
505        self
506    }
507
508    /// Enable dead-letter persistence for poison messages.
509    ///
510    /// Envelopes that exhaust their retry budget are atomically moved to
511    /// `dlq_table` (INSERT + DELETE in the same transaction). When not set,
512    /// exhausted envelopes are logged via `tracing::error!` but not moved.
513    #[must_use]
514    pub fn dead_letter_table(mut self, name: impl Into<String>) -> Self {
515        self.dead_letter_table = Some(name.into());
516        self
517    }
518
519    /// Register a typed handler for the event type `E`.
520    ///
521    /// Registering twice for the same event type silently replaces the
522    /// previous handler.
523    #[must_use]
524    pub fn register_handler<E, H>(mut self, handler: H) -> Self
525    where
526        E: Event,
527        H: Handler<E>,
528    {
529        let typed = TypedHandler::<E, H>::new(handler);
530        let erased: Arc<dyn ErasedHandler> = Arc::new(typed);
531        self.handlers.insert(E::EVENT_TYPE, erased);
532        self
533    }
534
535    /// Register a handler already shared behind an `Arc`.
536    #[must_use]
537    pub fn shared_handler<E, H>(mut self, handler: Arc<H>) -> Self
538    where
539        E: Event,
540        H: Handler<E>,
541    {
542        let typed = TypedHandler::<E, H>::shared(handler);
543        let erased: Arc<dyn ErasedHandler> = Arc::new(typed);
544        self.handlers.insert(E::EVENT_TYPE, erased);
545        self
546    }
547
548    /// Override the poll interval (default 100 ms).
549    #[must_use]
550    pub fn poll_interval(mut self, d: Duration) -> Self {
551        self.config.poll_interval = d;
552        self
553    }
554
555    /// Override the batch size per poll (default 10).
556    #[must_use]
557    pub fn batch_size(mut self, n: usize) -> Self {
558        self.config.batch_size = n;
559        self
560    }
561
562    /// Override the maximum number of attempts per envelope (default 5).
563    #[must_use]
564    pub fn max_attempts(mut self, n: u32) -> Self {
565        self.config.max_attempts = n;
566        self
567    }
568
569    /// Override the base delay for exponential backoff (default 1 s).
570    ///
571    /// The actual delay before attempt `n` is `min(retry_max_delay, base × 2^n)`,
572    /// optionally jittered. See [`OutboxWorkerConfig::retry_base_delay`].
573    #[must_use]
574    pub fn retry_base_delay(mut self, d: Duration) -> Self {
575        self.config.retry_base_delay = d;
576        self
577    }
578
579    /// Override the maximum backoff delay (default 5 min).
580    ///
581    /// Caps `retry_base_delay × 2^n` regardless of the attempt count.
582    #[must_use]
583    pub fn retry_max_delay(mut self, d: Duration) -> Self {
584        self.config.retry_max_delay = d;
585        self
586    }
587
588    /// Enable or disable full jitter on the backoff delay (default `true`).
589    ///
590    /// When `true` the worker draws a uniform random value in `[0, computed_delay]`
591    /// instead of using the deterministic exponential.
592    #[must_use]
593    pub fn jitter(mut self, enabled: bool) -> Self {
594        self.config.jitter = enabled;
595        self
596    }
597
598    /// Override the per-envelope handler deadline and soft-lease unit
599    /// (default 30 s).
600    ///
601    /// Each handler invocation is wrapped in a hard `tokio` timeout of this
602    /// duration; the batch lease is sized as `batch_size x dispatch_timeout`
603    /// internally. Set it to the worst-case duration of a single handler.
604    #[must_use]
605    pub fn dispatch_timeout(mut self, d: Duration) -> Self {
606        self.config.dispatch_timeout = d;
607        self
608    }
609
610    /// Consume the builder and produce an [`OutboxWorker`] ready to spawn.
611    ///
612    /// # Errors
613    ///
614    /// Returns [`OutboxError::Internal`] if the configured `table_name`
615    /// is not a valid identifier.
616    pub fn build(self) -> Result<OutboxWorker<PgOutboxStore>, OutboxError> {
617        let mut store = PgOutboxStore::new(self.pool, self.table_name)?;
618        if let Some(dlq) = self.dead_letter_table {
619            store = store.with_dead_letter(dlq)?;
620        }
621        Ok(OutboxWorker::new(store, self.handlers, self.config))
622    }
623}
624
625/// Apply the dead-letter schema to the target Postgres database.
626///
627/// **Intended for POCs, integration tests and local development.**
628///
629/// # Errors
630///
631/// - [`OutboxError::Internal`] if `table_name` is not a valid identifier.
632/// - [`OutboxError::Database`] if the connection or the DDL statement fails.
633pub async fn ensure_dead_letter_schema(pool: &PgPool, table_name: &str) -> Result<(), OutboxError> {
634    let ddl = DIALECT.dead_letter_schema_ddl(table_name)?;
635    sqlx::raw_sql(&ddl)
636        .execute(pool)
637        .await
638        .map_err(database_error)?;
639    Ok(())
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use hexeract_core::HandlerContext;
646    use serde::Deserialize;
647    use serde::Serialize;
648
649    fn lazy_pool() -> PgPool {
650        PgPool::connect_lazy("postgres://nobody:nobody@127.0.0.1:1/nobody")
651            .expect("lazy pool must build from a valid URL")
652    }
653
654    #[derive(Debug, Serialize, Deserialize)]
655    struct UserRegistered {
656        user_id: Uuid,
657    }
658
659    impl Event for UserRegistered {
660        const EVENT_TYPE: &'static str = "users.registered";
661    }
662
663    #[derive(Debug, Serialize, Deserialize)]
664    struct OrderPlaced {
665        order_id: Uuid,
666    }
667
668    impl Event for OrderPlaced {
669        const EVENT_TYPE: &'static str = "orders.placed";
670    }
671
672    struct NoopHandler;
673
674    impl Handler<UserRegistered> for NoopHandler {
675        type Error = OutboxError;
676        async fn handle(
677            &self,
678            _event: UserRegistered,
679            _ctx: &HandlerContext,
680        ) -> Result<(), Self::Error> {
681            Ok(())
682        }
683    }
684
685    impl Handler<OrderPlaced> for NoopHandler {
686        type Error = OutboxError;
687        async fn handle(
688            &self,
689            _event: OrderPlaced,
690            _ctx: &HandlerContext,
691        ) -> Result<(), Self::Error> {
692            Ok(())
693        }
694    }
695
696    #[test]
697    fn pool_error_maps_pool_timed_out_to_pool_timeout_variant() {
698        let err = pool_error(sqlx::Error::PoolTimedOut);
699        assert!(
700            matches!(err, OutboxError::PoolTimeout),
701            "PoolTimedOut must map to OutboxError::PoolTimeout, got {err:?}"
702        );
703    }
704
705    #[test]
706    fn pool_error_wraps_other_errors_as_database_error() {
707        let err = pool_error(sqlx::Error::RowNotFound);
708        assert!(
709            matches!(err, OutboxError::Database(_)),
710            "non-timeout errors must map to OutboxError::Database, got {err:?}"
711        );
712    }
713
714    #[tokio::test]
715    async fn store_new_rejects_invalid_table_name() {
716        let err = PgOutboxStore::new(lazy_pool(), "bad name; DROP").unwrap_err();
717        assert!(matches!(err, OutboxError::Internal(_)));
718    }
719
720    #[tokio::test]
721    async fn store_new_caches_postgres_sql_with_validated_table_name() {
722        let store = PgOutboxStore::new(lazy_pool(), "audit_outbox").unwrap();
723        assert_eq!(store.table_name(), "audit_outbox");
724        assert!(store.poll_sql.contains("FROM \"audit_outbox\""));
725        assert!(store.poll_sql.contains("FOR UPDATE SKIP LOCKED"));
726        assert!(store.mark_delivered_sql.contains("UPDATE \"audit_outbox\""));
727        // The attempt increment lives in claim_sql now (see #213), so
728        // mark_failed must not increment again.
729        assert!(!store.mark_failed_sql.contains("attempts = attempts + 1"));
730    }
731
732    #[tokio::test]
733    async fn publisher_new_rejects_invalid_table_name() {
734        let err = PgOutboxPublisher::new(lazy_pool(), "bad name; DROP").unwrap_err();
735        assert!(matches!(err, OutboxError::Internal(_)));
736    }
737
738    #[tokio::test]
739    async fn publisher_new_caches_insert_sql_with_validated_table_name() {
740        let publisher = PgOutboxPublisher::new(lazy_pool(), "audit_outbox").unwrap();
741        assert_eq!(publisher.table_name(), "audit_outbox");
742        assert!(
743            publisher
744                .insert_sql
745                .contains("INSERT INTO \"audit_outbox\"")
746        );
747        assert!(publisher.insert_sql.contains("$1, $2, $3, $4"));
748    }
749
750    #[tokio::test]
751    async fn builder_starts_with_default_table_and_empty_handlers() {
752        let builder = PgOutboxWorkerBuilder::new(lazy_pool());
753        assert_eq!(builder.table_name, DEFAULT_TABLE_NAME);
754        assert!(builder.handlers.is_empty());
755        let default_cfg = OutboxWorkerConfig::default();
756        assert_eq!(builder.config.batch_size, default_cfg.batch_size);
757        assert_eq!(builder.config.max_attempts, default_cfg.max_attempts);
758    }
759
760    #[tokio::test]
761    async fn builder_table_name_can_be_customized() {
762        let builder = PgOutboxWorkerBuilder::new(lazy_pool()).table_name("my_outbox");
763        assert_eq!(builder.table_name, "my_outbox");
764    }
765
766    #[tokio::test]
767    async fn builder_register_handler_records_event_types() {
768        let builder = PgOutboxWorkerBuilder::new(lazy_pool())
769            .register_handler::<UserRegistered, _>(NoopHandler)
770            .register_handler::<OrderPlaced, _>(NoopHandler);
771        assert_eq!(builder.handlers.len(), 2);
772        assert!(builder.handlers.contains_key("users.registered"));
773        assert!(builder.handlers.contains_key("orders.placed"));
774    }
775
776    #[tokio::test]
777    async fn builder_register_handler_twice_replaces_silently() {
778        let builder = PgOutboxWorkerBuilder::new(lazy_pool())
779            .register_handler::<UserRegistered, _>(NoopHandler)
780            .register_handler::<UserRegistered, _>(NoopHandler);
781        assert_eq!(builder.handlers.len(), 1);
782    }
783
784    #[tokio::test]
785    async fn builder_build_rejects_invalid_table_name() {
786        let result = PgOutboxWorkerBuilder::new(lazy_pool())
787            .table_name("bad name; DROP TABLE")
788            .build();
789        assert!(matches!(result, Err(OutboxError::Internal(_))));
790    }
791
792    #[tokio::test]
793    async fn builder_build_with_default_table_name_succeeds() {
794        let worker = PgOutboxWorkerBuilder::new(lazy_pool()).build();
795        assert!(worker.is_ok());
796    }
797
798    #[tokio::test]
799    async fn store_with_dead_letter_caches_sql_for_dlq() {
800        let store = PgOutboxStore::new(lazy_pool(), "audit_outbox")
801            .unwrap()
802            .with_dead_letter("audit_outbox_dead_letter")
803            .unwrap();
804        let dlq = store.dead_letter.as_ref().unwrap();
805        assert!(
806            dlq.insert_sql
807                .contains("INSERT INTO \"audit_outbox_dead_letter\"")
808        );
809        assert!(dlq.insert_sql.contains("FROM \"audit_outbox\""));
810        assert!(dlq.insert_sql.contains("$1"));
811        assert!(dlq.delete_sql.contains("DELETE FROM \"audit_outbox\""));
812        assert!(dlq.delete_sql.contains("$1"));
813    }
814
815    #[tokio::test]
816    async fn store_with_dead_letter_rejects_invalid_dlq_name() {
817        let err = PgOutboxStore::new(lazy_pool(), "audit_outbox")
818            .unwrap()
819            .with_dead_letter("bad name; DROP")
820            .unwrap_err();
821        assert!(matches!(err, OutboxError::Internal(_)));
822    }
823
824    #[tokio::test]
825    async fn builder_dead_letter_table_propagates_to_store() {
826        let worker = PgOutboxWorkerBuilder::new(lazy_pool())
827            .dead_letter_table("audit_outbox_dead_letter")
828            .build()
829            .unwrap();
830        drop(worker);
831    }
832
833    #[tokio::test]
834    async fn builder_dispatch_timeout_overrides_default() {
835        let worker = PgOutboxWorkerBuilder::new(lazy_pool())
836            .dispatch_timeout(Duration::from_secs(60))
837            .build()
838            .unwrap();
839        drop(worker);
840    }
841
842    #[test]
843    fn store_claim_sql_uses_any_array_bind() {
844        // Postgres claim uses = ANY($2) so the bind count is always 2,
845        // regardless of batch size (#240).
846        let sql = DIALECT.claim_sql("audit_outbox", 3);
847        assert!(sql.contains("UPDATE \"audit_outbox\""));
848        assert!(sql.contains("next_retry_at = (NOW() +"));
849        assert!(sql.contains("$1"));
850        assert!(sql.contains("WHERE event_id = ANY($2)"));
851        assert!(!sql.contains("$3"));
852        assert!(sql.contains("attempts = attempts + 1"));
853    }
854
855    #[test]
856    fn duration_to_pg_secs_caps_at_max_interval() {
857        // Duration::MAX produces inf via as_secs_f64(), which Postgres rejects
858        // with "interval out of range". The helper must cap the output.
859        let capped = duration_to_pg_secs(Duration::MAX);
860        assert!(
861            capped.is_finite(),
862            "Duration::MAX must produce a finite f64, got {capped}"
863        );
864        // Use approximate comparison for f64 (float_cmp lint).
865        assert!(
866            (capped - MAX_PG_INTERVAL_SECS).abs() < 1.0,
867            "capped value must equal MAX_PG_INTERVAL_SECS, got {capped}"
868        );
869
870        // Ordinary values must pass through unchanged.
871        let ordinary = Duration::from_secs(300);
872        assert!(
873            (duration_to_pg_secs(ordinary) - 300.0_f64).abs() < f64::EPSILON,
874            "ordinary duration must not be capped"
875        );
876    }
877
878    #[tokio::test]
879    async fn publisher_rejects_event_type_exceeding_64_bytes() {
880        // publish_in_tx must validate E::EVENT_TYPE before touching the DB so
881        // the caller gets OutboxError::Internal instead of an opaque DB error.
882        // 65 bytes: exceeds VARCHAR(64)
883        const OVERLENGTH_EVENT_TYPE: &str =
884            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1";
885
886        let publisher = PgOutboxPublisher::new(lazy_pool(), "audit_outbox").unwrap();
887        // We cannot call publish_in_tx without a real transaction, so verify
888        // the validation function directly with the same constant used at publish
889        // time.
890        let result = validate_event_type(OVERLENGTH_EVENT_TYPE);
891        assert!(
892            matches!(result, Err(OutboxError::Internal(_))),
893            "overlength EVENT_TYPE must be rejected before the DB insert"
894        );
895        drop(publisher);
896    }
897}