Skip to main content

outbox_core/
manager.rs

1//! Top-level worker that owns the outbox event loop.
2//!
3//! [`OutboxManager`] ties together storage, transport, configuration and a
4//! shutdown signal. It drives the main processing loop — waiting for database
5//! notifications, polling on a fixed interval, draining pending events via
6//! [`OutboxProcessor`], and running a periodic [`GarbageCollector`] task on
7//! the side. Construct one via [`OutboxManagerBuilder`](crate::builder::OutboxManagerBuilder).
8
9use crate::config::OutboxConfig;
10use crate::dlq::processor::DlqProcessor;
11use crate::error::OutboxError;
12use crate::gc::GarbageCollector;
13use crate::processor::OutboxProcessor;
14use crate::publisher::Transport;
15use crate::storage::OutboxStorage;
16use serde::Serialize;
17use std::fmt::Debug;
18use std::sync::Arc;
19use std::time::Duration;
20use tokio::sync::watch::Receiver;
21use tracing::{debug, error, info, trace};
22
23/// Long-running worker that publishes pending outbox events to the broker.
24///
25/// The manager owns one concrete [`OutboxStorage`] implementation (`S`), one
26/// [`Transport`] implementation (`P`), and the user's domain event payload type
27/// (`PT`). After [`run`](Self::run) is invoked, it will keep processing events
28/// until the watched shutdown channel flips to `true`.
29///
30/// Construct one through
31/// [`OutboxManagerBuilder`](crate::builder::OutboxManagerBuilder): it reports
32/// missing dependencies with a structured [`OutboxError::ConfigError`] and
33/// shields callers from the manager's `cfg`-dependent constructor signature.
34pub struct OutboxManager<S, P, PT>
35where
36    PT: Debug + Clone + Serialize,
37{
38    storage: Arc<S>,
39    publisher: Arc<P>,
40    config: Arc<OutboxConfig<PT>>,
41    shutdown_rx: Receiver<bool>,
42    #[cfg(feature = "dlq")]
43    dlq_heap: Arc<dyn crate::dlq::storage::DlqHeap>,
44}
45
46impl<S, P, PT> OutboxManager<S, P, PT>
47where
48    S: OutboxStorage<PT> + Send + Sync + 'static,
49    P: Transport<PT> + Send + Sync + 'static,
50    PT: Debug + Clone + Serialize + Send + Sync + 'static,
51{
52    /// Direct constructor used by
53    /// [`OutboxManagerBuilder`](crate::builder::OutboxManagerBuilder).
54    ///
55    /// Crate-private because the signature changes with the `dlq` feature flag,
56    /// which makes it unstable as a public entry point. Application code goes
57    /// through the builder.
58    #[cfg(feature = "dlq")]
59    pub(crate) fn new(
60        storage: Arc<S>,
61        publisher: Arc<P>,
62        config: Arc<OutboxConfig<PT>>,
63        dlq_heap: Arc<dyn crate::dlq::storage::DlqHeap>,
64        shutdown_rx: Receiver<bool>,
65    ) -> Self {
66        Self {
67            storage,
68            publisher,
69            config,
70            shutdown_rx,
71            dlq_heap,
72        }
73    }
74
75    /// Direct constructor used by
76    /// [`OutboxManagerBuilder`](crate::builder::OutboxManagerBuilder).
77    ///
78    /// Crate-private; see the `dlq`-enabled variant for the rationale.
79    #[cfg(not(feature = "dlq"))]
80    pub(crate) fn new(
81        storage: Arc<S>,
82        publisher: Arc<P>,
83        config: Arc<OutboxConfig<PT>>,
84        shutdown_rx: Receiver<bool>,
85    ) -> Self {
86        Self {
87            storage,
88            publisher,
89            config,
90            shutdown_rx,
91        }
92    }
93
94    /// Starts the main outbox worker loop.
95    ///
96    /// This method will run until a shutdown signal is received via the
97    /// `shutdown_rx` channel. It coordinates three concerns:
98    ///
99    /// - **Event processing** — on each wake-up it drives
100    ///   [`OutboxProcessor`] in an inner drain loop until the fetched batch is
101    ///   empty, at which point it returns to waiting.
102    /// - **Wake-up sources** — a `tokio::select!` races a storage-level
103    ///   `LISTEN`/notify call, a poll interval (`config.poll_interval_secs`),
104    ///   and the shutdown receiver. A notification error is logged and the
105    ///   loop sleeps for 5 seconds before retrying.
106    /// - **Garbage collection** — a background task is spawned that ticks on
107    ///   `config.gc_interval_secs` and calls [`GarbageCollector::collect_garbage`],
108    ///   exiting when the shutdown signal fires.
109    ///
110    /// # Errors
111    ///
112    /// The `Result` is reserved for forward compatibility. In the current
113    /// implementation `run` only returns `Ok(())` — it returns when the
114    /// shutdown signal flips to `true`. Transient storage and transport
115    /// failures are logged via `tracing::error!` and the loop continues after
116    /// a short back-off. A future revision may surface terminal errors (for
117    /// example, an unrecoverable storage configuration) through the `Err`
118    /// arm; treat `Err` as a hard stop if and when it appears.
119    ///
120    /// # Example
121    ///
122    /// ```ignore
123    /// use std::sync::Arc;
124    /// use tokio::sync::watch;
125    /// use outbox_core::prelude::*;
126    ///
127    /// # async fn start(
128    /// #     storage: Arc<impl OutboxStorage<MyEvent> + Send + Sync + 'static>,
129    /// #     publisher: Arc<impl Transport<MyEvent> + Send + Sync + 'static>,
130    /// # ) -> Result<(), OutboxError>
131    /// # where MyEvent: std::fmt::Debug + Clone + serde::Serialize + Send + Sync + 'static,
132    /// # {
133    /// let (shutdown_tx, shutdown_rx) = watch::channel(false);
134    /// let manager = OutboxManagerBuilder::new()
135    ///     .storage(storage)
136    ///     .publisher(publisher)
137    ///     .config(Arc::new(OutboxConfig::default()))
138    ///     .shutdown_rx(shutdown_rx)
139    ///     .build()?;
140    ///
141    /// let handle = tokio::spawn(async move { manager.run().await });
142    ///
143    /// // ... later, on a signal or process exit:
144    /// let _ = shutdown_tx.send(true);
145    /// handle.await.expect("worker panicked")?;
146    /// # Ok(()) }
147    /// ```
148    pub async fn run(self) -> Result<(), OutboxError> {
149        let storage_for_listen = self.storage.clone();
150        let processor = OutboxProcessor::new(
151            self.storage.clone(),
152            self.publisher.clone(),
153            self.config.clone(),
154        );
155
156        let gc = GarbageCollector::new(self.storage.clone());
157        let mut rx_gc = self.shutdown_rx.clone();
158        let gc_interval_secs = self.config.gc_interval_secs;
159        tokio::spawn(async move {
160            gc.run(Duration::from_secs(gc_interval_secs), &mut rx_gc)
161                .await
162        });
163
164        #[cfg(feature = "dlq")]
165        {
166            let dlq_processor = DlqProcessor::new(
167                self.dlq_heap.clone(),
168                self.storage.clone(),
169                self.config.clone(),
170                self.shutdown_rx.clone(),
171            );
172            tokio::spawn(async move { dlq_processor.run().await });
173        }
174
175        let mut rx_listen = self.shutdown_rx.clone();
176        let poll_interval = self.config.poll_interval_secs;
177        let mut interval = tokio::time::interval(Duration::from_secs(poll_interval));
178
179        info!("Outbox worker loop started");
180
181        loop {
182            tokio::select! {
183                signal = storage_for_listen.wait_for_notification("outbox_event") => {
184                    if let Err(e) = signal {
185                        error!("Listen error: {}", e);
186                        tokio::time::sleep(Duration::from_secs(5)).await;
187                        continue;
188                    }
189                }
190                _ = interval.tick() => {
191                    trace!("Checking for stale or pending events via interval");
192                }
193                _ = rx_listen.changed() => {
194                    if rx_listen.has_changed().is_err(){
195                        break;
196                    }
197                    if *rx_listen.borrow() {
198                        break
199                    }
200                }
201            }
202            loop {
203                if *rx_listen.borrow() {
204                    return Ok(());
205                }
206                #[cfg(feature = "dlq")]
207                match processor
208                    .process_pending_events(self.dlq_heap.clone())
209                    .await
210                {
211                    Ok(0) => break,
212                    Ok(count) => debug!("Processed {} events", count),
213                    Err(e) => {
214                        error!("Processing error: {}", e);
215                        tokio::time::sleep(Duration::from_secs(5)).await;
216                        break;
217                    }
218                }
219                #[cfg(not(feature = "dlq"))]
220                match processor.process_pending_events().await {
221                    Ok(0) => break,
222                    Ok(count) => debug!("Processed {} events", count),
223                    Err(e) => {
224                        error!("Processing error: {}", e);
225                        tokio::time::sleep(Duration::from_secs(5)).await;
226                        break;
227                    }
228                }
229            }
230        }
231        debug!("Outbox worker loop stopped");
232        Ok(())
233    }
234}
235
236#[cfg(test)]
237#[allow(clippy::unwrap_used)]
238mod tests {
239    use crate::builder::OutboxManagerBuilder;
240    use crate::config::{IdempotencyStrategy, OutboxConfig};
241    #[cfg(feature = "dlq")]
242    use crate::dlq::storage::MockDlqHeap;
243    use crate::error::OutboxError;
244    use crate::model::{Event, EventStatus};
245    use crate::object::EventType;
246    use crate::prelude::Payload;
247    use crate::publisher::MockTransport;
248    use crate::storage::MockOutboxStorage;
249    use mockall::Sequence;
250    use rstest::rstest;
251    use serde::{Deserialize, Serialize};
252    use std::sync::Arc;
253    use tokio::sync::watch;
254
255    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
256    enum SomeDomainEvent {
257        SomeEvent(String),
258    }
259
260    #[rstest]
261    #[tokio::test]
262    async fn test_event_send_success() {
263        let config = OutboxConfig {
264            batch_size: 100,
265            retention_days: 7,
266            gc_interval_secs: 3600,
267            poll_interval_secs: 5,
268            lock_timeout_mins: 5,
269            idempotency_strategy: IdempotencyStrategy::None,
270            dlq_threshold: 10,
271            dlq_interval_secs: 1,
272        };
273
274        let mut storage_mock = MockOutboxStorage::<SomeDomainEvent>::new();
275        let mut transport_mock = MockTransport::<SomeDomainEvent>::new();
276
277        #[cfg(feature = "dlq")]
278        let mut dlq_heap_mock: MockDlqHeap = MockDlqHeap::new();
279
280        #[cfg(feature = "dlq")]
281        dlq_heap_mock.expect_record_success().returning(|_| Ok(()));
282
283        let (shutdown_tx, shutdown_rx) = watch::channel(false);
284
285        storage_mock
286            .expect_wait_for_notification()
287            .returning(|_| Ok(()));
288
289        storage_mock
290            .expect_fetch_next_to_process()
291            .withf(move |l| l == &config.batch_size)
292            .times(1)
293            .returning(move |_| {
294                let _ = shutdown_tx.send(true);
295                Ok(vec![
296                    Event::new(
297                        EventType::new("1"),
298                        Payload::new(SomeDomainEvent::SomeEvent("test1".to_string())),
299                        None,
300                    ),
301                    Event::new(
302                        EventType::new("2"),
303                        Payload::new(SomeDomainEvent::SomeEvent("test2".to_string())),
304                        None,
305                    ),
306                    Event::new(
307                        EventType::new("3"),
308                        Payload::new(SomeDomainEvent::SomeEvent("test3".to_string())),
309                        None,
310                    ),
311                    Event::new(
312                        EventType::new("4"),
313                        Payload::new(SomeDomainEvent::SomeEvent("test4".to_string())),
314                        None,
315                    ),
316                ])
317            });
318
319        storage_mock
320            .expect_fetch_next_to_process()
321            .withf(move |l| l == &config.batch_size)
322            .returning(move |_| Ok(vec![]));
323
324        storage_mock
325            .expect_update_status()
326            .withf(|ids, s| ids.len() == 4 && s == &EventStatus::Sent)
327            .returning(|_, _| Ok(()));
328
329        storage_mock.expect_delete_garbage().returning(|| Ok(()));
330
331        let mut seq = Sequence::new();
332
333        for i in 1..=4 {
334            let expected_type = i.to_string();
335            let expected_val = SomeDomainEvent::SomeEvent(format!("test{}", i));
336
337            transport_mock
338                .expect_publish()
339                .withf(move |event| {
340                    let type_matches = event.event_type.as_str() == expected_type;
341                    let payload_matches = event.payload.as_value() == &expected_val;
342                    type_matches && payload_matches
343                })
344                .times(1)
345                .in_sequence(&mut seq)
346                .returning(|_| Ok(()));
347        }
348
349        #[cfg(feature = "dlq")]
350        let manager = OutboxManagerBuilder::new()
351            .storage(Arc::new(storage_mock))
352            .publisher(Arc::new(transport_mock))
353            .config(Arc::new(config))
354            .shutdown_rx(shutdown_rx)
355            .dlq_heap(Arc::new(dlq_heap_mock))
356            .build()
357            .unwrap();
358
359        #[cfg(not(feature = "dlq"))]
360        let manager = OutboxManagerBuilder::new()
361            .storage(Arc::new(storage_mock))
362            .publisher(Arc::new(transport_mock))
363            .config(Arc::new(config))
364            .shutdown_rx(shutdown_rx)
365            .build()
366            .unwrap();
367
368        let handle = tokio::spawn(async move {
369            manager.run().await.unwrap();
370        });
371
372        tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
373            .await
374            .expect("Manager did not stop in time")
375            .unwrap();
376    }
377
378    #[rstest]
379    #[tokio::test]
380    async fn test_recovery_after_storage_error() {
381        let mut storage_mock = MockOutboxStorage::<SomeDomainEvent>::new();
382        let (shutdown_tx, shutdown_rx) = watch::channel(false);
383
384        storage_mock
385            .expect_wait_for_notification()
386            .times(1)
387            .returning(|_| Err(OutboxError::InfrastructureError("Connection lost".into())));
388
389        storage_mock
390            .expect_wait_for_notification()
391            .times(1)
392            .returning(move |_| {
393                let _ = shutdown_tx.send(true);
394                Ok(())
395            });
396
397        storage_mock
398            .expect_fetch_next_to_process()
399            .returning(|_| Ok(vec![]));
400
401        storage_mock
402            .expect_delete_garbage()
403            .times(1)
404            .returning(|| Ok(()));
405
406        let transport_mock = MockTransport::<SomeDomainEvent>::new();
407
408        let config = OutboxConfig::<SomeDomainEvent> {
409            batch_size: 100,
410            retention_days: 7,
411            gc_interval_secs: 3600,
412            poll_interval_secs: 5,
413            lock_timeout_mins: 5,
414            idempotency_strategy: IdempotencyStrategy::None,
415            dlq_threshold: 10,
416            dlq_interval_secs: 1,
417        };
418
419        #[cfg(feature = "dlq")]
420        let manager = OutboxManagerBuilder::new()
421            .storage(Arc::new(storage_mock))
422            .publisher(Arc::new(transport_mock))
423            .config(Arc::new(config))
424            .dlq_heap(Arc::new(MockDlqHeap::new()))
425            .shutdown_rx(shutdown_rx)
426            .build()
427            .unwrap();
428
429        #[cfg(not(feature = "dlq"))]
430        let manager = OutboxManagerBuilder::new()
431            .storage(Arc::new(storage_mock))
432            .publisher(Arc::new(transport_mock))
433            .config(Arc::new(config))
434            .shutdown_rx(shutdown_rx)
435            .build()
436            .unwrap();
437
438        let result = manager.run().await;
439        assert!(result.is_ok());
440    }
441
442    #[rstest]
443    #[tokio::test]
444    async fn test_publish_failure() {
445        let mut storage_mock = MockOutboxStorage::<SomeDomainEvent>::new();
446        let (shutdown_tx, shutdown_rx) = watch::channel(false);
447
448        let e1 = Event::new(
449            EventType::new("1"),
450            Payload::new(SomeDomainEvent::SomeEvent("test1".to_string())),
451            None,
452        );
453        let e2 = Event::new(
454            EventType::new("2"),
455            Payload::new(SomeDomainEvent::SomeEvent("test2".to_string())),
456            None,
457        );
458        let e3 = Event::new(
459            EventType::new("3"),
460            Payload::new(SomeDomainEvent::SomeEvent("test3".to_string())),
461            None,
462        );
463        let e4 = Event::new(
464            EventType::new("4"),
465            Payload::new(SomeDomainEvent::SomeEvent("test4".to_string())),
466            None,
467        );
468
469        let id1 = e1.id.clone();
470        let id2 = e2.id.clone();
471        let id3 = e3.id.clone();
472        let id4 = e4.id.clone();
473
474        storage_mock
475            .expect_wait_for_notification()
476            .returning(|_| Ok(()));
477
478        storage_mock
479            .expect_fetch_next_to_process()
480            .times(1)
481            .returning(move |_| Ok(vec![e1.clone(), e2.clone(), e3.clone(), e4.clone()]));
482
483        storage_mock
484            .expect_fetch_next_to_process()
485            .returning(|_| Ok(vec![]));
486
487        storage_mock.expect_delete_garbage().returning(|| Ok(()));
488
489        storage_mock
490            .expect_update_status()
491            .withf(move |ids, status| {
492                if status != &EventStatus::Sent {
493                    return false;
494                }
495
496                let ids_set: std::collections::HashSet<_> = ids.iter().cloned().collect();
497
498                ids_set.len() == 3
499                    && ids_set.contains(&id1)
500                    && ids_set.contains(&id2)
501                    && ids_set.contains(&id4)
502                    && !ids_set.contains(&id3)
503            })
504            .returning(move |_, _| {
505                let _ = shutdown_tx.send(true);
506                Ok(())
507            });
508
509        let mut transport_mock = MockTransport::<SomeDomainEvent>::new();
510
511        let mut seq = Sequence::new();
512
513        transport_mock
514            .expect_publish()
515            .times(1)
516            .in_sequence(&mut seq)
517            .returning(|_| Ok(()));
518
519        transport_mock
520            .expect_publish()
521            .times(1)
522            .in_sequence(&mut seq)
523            .returning(|_| Ok(()));
524
525        transport_mock
526            .expect_publish()
527            .times(1)
528            .in_sequence(&mut seq)
529            .returning(|_| Err(OutboxError::InfrastructureError("Connection lost".into())));
530
531        transport_mock
532            .expect_publish()
533            .times(1)
534            .in_sequence(&mut seq)
535            .returning(|_| Ok(()));
536
537        let config = OutboxConfig::<SomeDomainEvent> {
538            batch_size: 100,
539            retention_days: 7,
540            gc_interval_secs: 3600,
541            poll_interval_secs: 5,
542            lock_timeout_mins: 5,
543            idempotency_strategy: IdempotencyStrategy::None,
544            dlq_threshold: 10,
545            dlq_interval_secs: 1,
546        };
547
548        #[cfg(feature = "dlq")]
549        let dlq_heap_mock = {
550            let mut m = MockDlqHeap::new();
551            m.expect_record_success().returning(|_| Ok(()));
552            m.expect_record_failure().returning(|_| Ok(()));
553            m
554        };
555
556        #[cfg(feature = "dlq")]
557        let manager = OutboxManagerBuilder::new()
558            .storage(Arc::new(storage_mock))
559            .publisher(Arc::new(transport_mock))
560            .config(Arc::new(config))
561            .dlq_heap(Arc::new(dlq_heap_mock))
562            .shutdown_rx(shutdown_rx)
563            .build()
564            .unwrap();
565
566        #[cfg(not(feature = "dlq"))]
567        let manager = OutboxManagerBuilder::new()
568            .storage(Arc::new(storage_mock))
569            .publisher(Arc::new(transport_mock))
570            .config(Arc::new(config))
571            .shutdown_rx(shutdown_rx)
572            .build()
573            .unwrap();
574
575        let result = manager.run().await;
576        assert!(result.is_ok());
577    }
578
579    fn default_config() -> OutboxConfig<SomeDomainEvent> {
580        OutboxConfig {
581            batch_size: 100,
582            retention_days: 7,
583            gc_interval_secs: 3600,
584            poll_interval_secs: 5,
585            lock_timeout_mins: 5,
586            idempotency_strategy: IdempotencyStrategy::None,
587            dlq_threshold: 10,
588            dlq_interval_secs: 1,
589        }
590    }
591
592    #[rstest]
593    #[tokio::test]
594    async fn shutdown_set_before_run_exits_without_fetch() {
595        let mut storage_mock = MockOutboxStorage::<SomeDomainEvent>::new();
596        let transport_mock = MockTransport::<SomeDomainEvent>::new();
597        let (shutdown_tx, shutdown_rx) = watch::channel(false);
598        let _ = shutdown_tx.send(true);
599
600        storage_mock
601            .expect_wait_for_notification()
602            .returning(|_| Ok(()));
603        storage_mock.expect_delete_garbage().returning(|| Ok(()));
604        storage_mock.expect_fetch_next_to_process().times(0);
605        storage_mock.expect_update_status().times(0);
606
607        #[cfg(feature = "dlq")]
608        let manager = OutboxManagerBuilder::new()
609            .storage(Arc::new(storage_mock))
610            .publisher(Arc::new(transport_mock))
611            .config(Arc::new(default_config()))
612            .dlq_heap(Arc::new(MockDlqHeap::new()))
613            .shutdown_rx(shutdown_rx)
614            .build()
615            .unwrap();
616        #[cfg(not(feature = "dlq"))]
617        let manager = OutboxManagerBuilder::new()
618            .storage(Arc::new(storage_mock))
619            .publisher(Arc::new(transport_mock))
620            .config(Arc::new(default_config()))
621            .shutdown_rx(shutdown_rx)
622            .build()
623            .unwrap();
624
625        let result = tokio::time::timeout(tokio::time::Duration::from_secs(1), manager.run())
626            .await
627            .expect("manager did not stop in time");
628        assert!(result.is_ok());
629        drop(shutdown_tx);
630    }
631
632    #[rstest]
633    #[tokio::test]
634    async fn inner_loop_drains_until_empty_batch() {
635        let mut storage_mock = MockOutboxStorage::<SomeDomainEvent>::new();
636        let mut transport_mock = MockTransport::<SomeDomainEvent>::new();
637        let (shutdown_tx, shutdown_rx) = watch::channel(false);
638
639        storage_mock
640            .expect_wait_for_notification()
641            .returning(|_| Ok(()));
642        storage_mock.expect_delete_garbage().returning(|| Ok(()));
643
644        let mut seq = Sequence::new();
645        storage_mock
646            .expect_fetch_next_to_process()
647            .times(1)
648            .in_sequence(&mut seq)
649            .returning(|_| {
650                Ok(vec![
651                    Event::new(
652                        EventType::new("a1"),
653                        Payload::new(SomeDomainEvent::SomeEvent("a1".into())),
654                        None,
655                    ),
656                    Event::new(
657                        EventType::new("a2"),
658                        Payload::new(SomeDomainEvent::SomeEvent("a2".into())),
659                        None,
660                    ),
661                ])
662            });
663        storage_mock
664            .expect_fetch_next_to_process()
665            .times(1)
666            .in_sequence(&mut seq)
667            .returning(|_| {
668                Ok(vec![Event::new(
669                    EventType::new("b1"),
670                    Payload::new(SomeDomainEvent::SomeEvent("b1".into())),
671                    None,
672                )])
673            });
674        storage_mock
675            .expect_fetch_next_to_process()
676            .times(1)
677            .in_sequence(&mut seq)
678            .returning(move |_| {
679                let _ = shutdown_tx.send(true);
680                Ok(vec![])
681            });
682        storage_mock
683            .expect_fetch_next_to_process()
684            .returning(|_| Ok(vec![]));
685
686        storage_mock
687            .expect_update_status()
688            .times(2)
689            .returning(|_, _| Ok(()));
690
691        transport_mock
692            .expect_publish()
693            .times(3)
694            .returning(|_| Ok(()));
695
696        #[cfg(feature = "dlq")]
697        let manager = {
698            let mut dlq = MockDlqHeap::new();
699            dlq.expect_record_success().returning(|_| Ok(()));
700            dlq.expect_record_failure().returning(|_| Ok(()));
701            OutboxManagerBuilder::new()
702                .storage(Arc::new(storage_mock))
703                .publisher(Arc::new(transport_mock))
704                .config(Arc::new(default_config()))
705                .dlq_heap(Arc::new(dlq))
706                .shutdown_rx(shutdown_rx)
707                .build()
708                .unwrap()
709        };
710        #[cfg(not(feature = "dlq"))]
711        let manager = OutboxManagerBuilder::new()
712            .storage(Arc::new(storage_mock))
713            .publisher(Arc::new(transport_mock))
714            .config(Arc::new(default_config()))
715            .shutdown_rx(shutdown_rx)
716            .build()
717            .unwrap();
718
719        let handle = tokio::spawn(async move { manager.run().await.unwrap() });
720        tokio::time::timeout(tokio::time::Duration::from_secs(1), handle)
721            .await
722            .expect("manager did not stop in time")
723            .unwrap();
724    }
725
726    #[rstest]
727    #[tokio::test(start_paused = true)]
728    async fn fetch_error_inside_loop_is_recoverable() {
729        let mut storage_mock = MockOutboxStorage::<SomeDomainEvent>::new();
730        let transport_mock = MockTransport::<SomeDomainEvent>::new();
731        let (shutdown_tx, shutdown_rx) = watch::channel(false);
732
733        storage_mock
734            .expect_wait_for_notification()
735            .returning(|_| Ok(()));
736        storage_mock.expect_delete_garbage().returning(|| Ok(()));
737
738        let mut seq = Sequence::new();
739        storage_mock
740            .expect_fetch_next_to_process()
741            .times(1)
742            .in_sequence(&mut seq)
743            .returning(|_| Err(OutboxError::DatabaseError("transient".into())));
744        storage_mock
745            .expect_fetch_next_to_process()
746            .in_sequence(&mut seq)
747            .returning(move |_| {
748                let _ = shutdown_tx.send(true);
749                Ok(vec![])
750            });
751        storage_mock
752            .expect_fetch_next_to_process()
753            .returning(|_| Ok(vec![]));
754
755        storage_mock.expect_update_status().times(0);
756
757        #[cfg(feature = "dlq")]
758        let manager = OutboxManagerBuilder::new()
759            .storage(Arc::new(storage_mock))
760            .publisher(Arc::new(transport_mock))
761            .config(Arc::new(default_config()))
762            .dlq_heap(Arc::new(MockDlqHeap::new()))
763            .shutdown_rx(shutdown_rx)
764            .build()
765            .unwrap();
766        #[cfg(not(feature = "dlq"))]
767        let manager = OutboxManagerBuilder::new()
768            .storage(Arc::new(storage_mock))
769            .publisher(Arc::new(transport_mock))
770            .config(Arc::new(default_config()))
771            .shutdown_rx(shutdown_rx)
772            .build()
773            .unwrap();
774
775        let result = tokio::time::timeout(tokio::time::Duration::from_secs(30), manager.run())
776            .await
777            .expect("manager did not stop in time");
778        assert!(result.is_ok());
779    }
780}