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
35const MAX_PG_INTERVAL_SECS: f64 = 9_223_372_036.0; fn 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
66fn 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
101pub 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#[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 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 #[must_use]
163 pub fn pool(&self) -> &PgPool {
164 &self.pool
165 }
166
167 #[must_use]
169 pub fn table_name(&self) -> &str {
170 &self.table_name
171 }
172
173 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 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 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 let sql = DIALECT.claim_sql(&self.table_name, event_ids.len());
317 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#[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 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 #[must_use]
363 pub fn pool(&self) -> &PgPool {
364 &self.pool
365 }
366
367 #[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(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
449pub 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 #[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 #[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 #[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 #[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 #[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 #[must_use]
550 pub fn poll_interval(mut self, d: Duration) -> Self {
551 self.config.poll_interval = d;
552 self
553 }
554
555 #[must_use]
557 pub fn batch_size(mut self, n: usize) -> Self {
558 self.config.batch_size = n;
559 self
560 }
561
562 #[must_use]
564 pub fn max_attempts(mut self, n: u32) -> Self {
565 self.config.max_attempts = n;
566 self
567 }
568
569 #[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 #[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 #[must_use]
593 pub fn jitter(mut self, enabled: bool) -> Self {
594 self.config.jitter = enabled;
595 self
596 }
597
598 #[must_use]
605 pub fn dispatch_timeout(mut self, d: Duration) -> Self {
606 self.config.dispatch_timeout = d;
607 self
608 }
609
610 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
625pub 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 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 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 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 assert!(
866 (capped - MAX_PG_INTERVAL_SECS).abs() < 1.0,
867 "capped value must equal MAX_PG_INTERVAL_SECS, got {capped}"
868 );
869
870 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 const OVERLENGTH_EVENT_TYPE: &str =
884 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1";
885
886 let publisher = PgOutboxPublisher::new(lazy_pool(), "audit_outbox").unwrap();
887 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}