1use 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
23pub 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 #[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 #[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 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}