1use crate::config::OutboxConfig;
10use crate::error::OutboxError;
11use crate::model::Event;
12use crate::model::EventStatus::Sent;
13use crate::object::EventId;
14use crate::publisher::Transport;
15use crate::storage::OutboxStorage;
16use serde::Serialize;
17use std::fmt::Debug;
18use std::sync::Arc;
19use tracing::error;
20
21pub struct OutboxProcessor<S, T, P>
26where
27 P: Debug + Clone + Serialize,
28{
29 storage: Arc<S>,
30 publisher: Arc<T>,
31 config: Arc<OutboxConfig<P>>,
32}
33
34impl<S, T, P> OutboxProcessor<S, T, P>
35where
36 S: OutboxStorage<P> + 'static,
37 T: Transport<P> + 'static,
38 P: Debug + Clone + Serialize + Send + Sync,
39{
40 pub fn new(storage: Arc<S>, publisher: Arc<T>, config: Arc<OutboxConfig<P>>) -> Self {
43 Self {
44 storage,
45 publisher,
46 config,
47 }
48 }
49
50 pub async fn process_pending_events(
75 &self,
76 #[cfg(feature = "dlq")] dlq_heap: Arc<dyn crate::dlq::storage::DlqHeap>,
77 ) -> Result<usize, OutboxError> {
78 let events: Vec<Event<P>> = self
79 .storage
80 .fetch_next_to_process(self.config.batch_size)
81 .await?;
82
83 if events.is_empty() {
84 return Ok(0);
85 }
86 let count = events.len();
87
88 #[cfg(feature = "dlq")]
89 self.event_publish(events, dlq_heap).await?;
90 #[cfg(not(feature = "dlq"))]
91 self.event_publish(events).await?;
92
93 Ok(count)
94 }
95
96 async fn event_publish(
97 &self,
98 events: Vec<Event<P>>,
99 #[cfg(feature = "dlq")] dlq_heap: Arc<dyn crate::dlq::storage::DlqHeap>,
100 ) -> Result<(), OutboxError> {
101 let mut success_ids = Vec::<EventId>::new();
102 for event in events {
103 let id = event.id;
104
105 #[cfg(feature = "metrics")]
106 let start = std::time::Instant::now();
107
108 #[cfg(feature = "metrics")]
109 let event_type = event.event_type.to_string();
110
111 match self.publisher.publish(event).await {
112 Ok(()) => {
113 success_ids.push(id);
114 #[cfg(feature = "dlq")]
115 dlq_heap.record_success(id).await?;
116 #[cfg(feature = "metrics")]
117 {
118 let delta = start.elapsed().as_secs_f64();
119
120 metrics::counter!("outbox.events_total",
121 "status" => "success",
122 "event_type" => event_type.clone()
123 )
124 .increment(1);
125
126 metrics::histogram!(
127 "outbox.publish_duration_seconds",
128 "event_type" => event_type.clone()
129 )
130 .record(delta);
131 }
132 }
133 Err(e) => {
134 error!("Failed to publish event {:?}: {:?}", id, e);
135 #[cfg(feature = "dlq")]
136 dlq_heap.record_failure(id).await?;
137
138 #[cfg(feature = "metrics")]
139 {
140 let delta = start.elapsed().as_secs_f64();
141
142 metrics::counter!("outbox.events_total",
143 "status" => "error",
144 "event_type" => event_type.clone()
145 )
146 .increment(1);
147
148 metrics::histogram!(
149 "outbox.publish_duration_seconds",
150 "status" => "error",
151 "event_type" => event_type
152 )
153 .record(delta);
154 }
155 }
156 }
157 }
158 if !success_ids.is_empty() {
159 self.storage.update_status(&success_ids, Sent).await?;
160 }
161 Ok(())
162 }
163}
164
165#[cfg(test)]
166#[allow(clippy::unwrap_used)]
167mod tests {
168 use super::*;
169 use crate::config::{IdempotencyStrategy, OutboxConfig};
170 #[cfg(feature = "dlq")]
171 use crate::dlq::storage::MockDlqHeap;
172 use crate::model::EventStatus;
173 use crate::object::EventType;
174 use crate::prelude::Payload;
175 use crate::publisher::MockTransport;
176 use crate::storage::MockOutboxStorage;
177 use mockall::Sequence;
178 use rstest::rstest;
179 use serde::{Deserialize, Serialize};
180 use std::collections::HashSet;
181 use std::sync::Arc;
182
183 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184 enum TestEvent {
185 A(String),
186 }
187
188 fn config() -> Arc<OutboxConfig<TestEvent>> {
189 Arc::new(OutboxConfig {
190 batch_size: 100,
191 retention_days: 7,
192 gc_interval_secs: 3600,
193 poll_interval_secs: 5,
194 lock_timeout_mins: 5,
195 idempotency_strategy: IdempotencyStrategy::None,
196 dlq_threshold: 10,
197 dlq_interval_secs: 1,
198 })
199 }
200
201 fn make_event(n: u32) -> Event<TestEvent> {
202 Event::new(
203 EventType::new(&format!("t{n}")),
204 Payload::new(TestEvent::A(format!("v{n}"))),
205 None,
206 )
207 }
208
209 #[rstest]
210 #[tokio::test]
211 async fn empty_batch_returns_zero_and_skips_status_update() {
212 let mut storage = MockOutboxStorage::<TestEvent>::new();
213 let mut transport = MockTransport::<TestEvent>::new();
214
215 storage
216 .expect_fetch_next_to_process()
217 .withf(|limit| *limit == 100)
218 .times(1)
219 .returning(|_| Ok(vec![]));
220
221 storage.expect_update_status().times(0);
222 transport.expect_publish().times(0);
223
224 let processor = OutboxProcessor::new(Arc::new(storage), Arc::new(transport), config());
225
226 #[cfg(not(feature = "dlq"))]
227 let result = processor.process_pending_events().await;
228
229 #[cfg(feature = "dlq")]
230 let result = {
231 let mut dlq = MockDlqHeap::new();
232 dlq.expect_record_success().times(0);
233 dlq.expect_record_failure().times(0);
234 dlq.expect_drain_exceeded().times(0);
235 processor.process_pending_events(Arc::new(dlq)).await
236 };
237
238 assert!(matches!(result, Ok(0)));
239 }
240
241 #[rstest]
242 #[tokio::test]
243 async fn all_publishes_succeed_updates_status_with_all_ids() {
244 let mut storage = MockOutboxStorage::<TestEvent>::new();
245 let mut transport = MockTransport::<TestEvent>::new();
246
247 let events = vec![make_event(1), make_event(2), make_event(3)];
248 let expected_ids: HashSet<EventId> = events.iter().map(|e| e.id).collect();
249
250 storage
251 .expect_fetch_next_to_process()
252 .times(1)
253 .returning(move |_| Ok(events.clone()));
254
255 storage
256 .expect_update_status()
257 .withf(move |ids, status| {
258 let got: HashSet<EventId> = ids.iter().copied().collect();
259 got == expected_ids && *status == EventStatus::Sent
260 })
261 .times(1)
262 .returning(|_, _| Ok(()));
263
264 transport.expect_publish().times(3).returning(|_| Ok(()));
265
266 let processor = OutboxProcessor::new(Arc::new(storage), Arc::new(transport), config());
267
268 #[cfg(not(feature = "dlq"))]
269 let result = processor.process_pending_events().await;
270
271 #[cfg(feature = "dlq")]
272 let result = {
273 let mut dlq = MockDlqHeap::new();
274 dlq.expect_record_success().times(3).returning(|_| Ok(()));
275 dlq.expect_record_failure().times(0);
276 processor.process_pending_events(Arc::new(dlq)).await
277 };
278
279 assert!(matches!(result, Ok(3)));
280 }
281
282 #[rstest]
283 #[tokio::test]
284 async fn partial_publish_failure_updates_only_successful() {
285 let mut storage = MockOutboxStorage::<TestEvent>::new();
286 let mut transport = MockTransport::<TestEvent>::new();
287
288 let e1 = make_event(1);
289 let e2 = make_event(2);
290 let e3 = make_event(3);
291 let id1 = e1.id;
292 let id2 = e2.id;
293 let id3 = e3.id;
294
295 storage
296 .expect_fetch_next_to_process()
297 .times(1)
298 .returning(move |_| Ok(vec![e1.clone(), e2.clone(), e3.clone()]));
299
300 storage
301 .expect_update_status()
302 .withf(move |ids, status| {
303 let got: HashSet<EventId> = ids.iter().copied().collect();
304 got.len() == 2
305 && got.contains(&id1)
306 && got.contains(&id3)
307 && !got.contains(&id2)
308 && *status == EventStatus::Sent
309 })
310 .times(1)
311 .returning(|_, _| Ok(()));
312
313 let mut seq = Sequence::new();
314 transport
315 .expect_publish()
316 .times(1)
317 .in_sequence(&mut seq)
318 .returning(|_| Ok(()));
319 transport
320 .expect_publish()
321 .times(1)
322 .in_sequence(&mut seq)
323 .returning(|_| Err(OutboxError::BrokerError("boom".into())));
324 transport
325 .expect_publish()
326 .times(1)
327 .in_sequence(&mut seq)
328 .returning(|_| Ok(()));
329
330 let processor = OutboxProcessor::new(Arc::new(storage), Arc::new(transport), config());
331
332 #[cfg(not(feature = "dlq"))]
333 let result = processor.process_pending_events().await;
334
335 #[cfg(feature = "dlq")]
336 let result = {
337 let mut dlq = MockDlqHeap::new();
338 dlq.expect_record_success()
339 .withf(move |id| *id == id1 || *id == id3)
340 .times(2)
341 .returning(|_| Ok(()));
342 dlq.expect_record_failure()
343 .withf(move |id| *id == id2)
344 .times(1)
345 .returning(|_| Ok(()));
346 processor.process_pending_events(Arc::new(dlq)).await
347 };
348
349 assert!(matches!(result, Ok(3)));
350 }
351
352 #[rstest]
353 #[tokio::test]
354 async fn all_publishes_fail_skips_status_update() {
355 let mut storage = MockOutboxStorage::<TestEvent>::new();
356 let mut transport = MockTransport::<TestEvent>::new();
357
358 let events = vec![make_event(1), make_event(2)];
359
360 storage
361 .expect_fetch_next_to_process()
362 .times(1)
363 .returning(move |_| Ok(events.clone()));
364 storage.expect_update_status().times(0);
365
366 transport
367 .expect_publish()
368 .times(2)
369 .returning(|_| Err(OutboxError::BrokerError("x".into())));
370
371 let processor = OutboxProcessor::new(Arc::new(storage), Arc::new(transport), config());
372
373 #[cfg(not(feature = "dlq"))]
374 let result = processor.process_pending_events().await;
375
376 #[cfg(feature = "dlq")]
377 let result = {
378 let mut dlq = MockDlqHeap::new();
379 dlq.expect_record_success().times(0);
380 dlq.expect_record_failure().times(2).returning(|_| Ok(()));
381 processor.process_pending_events(Arc::new(dlq)).await
382 };
383
384 assert!(matches!(result, Ok(2)));
385 }
386
387 #[rstest]
388 #[tokio::test]
389 async fn fetch_error_propagates_without_publishing() {
390 let mut storage = MockOutboxStorage::<TestEvent>::new();
391 let mut transport = MockTransport::<TestEvent>::new();
392
393 storage
394 .expect_fetch_next_to_process()
395 .times(1)
396 .returning(|_| Err(OutboxError::DatabaseError("boom".into())));
397 storage.expect_update_status().times(0);
398 transport.expect_publish().times(0);
399
400 let processor = OutboxProcessor::new(Arc::new(storage), Arc::new(transport), config());
401
402 #[cfg(not(feature = "dlq"))]
403 let result = processor.process_pending_events().await;
404
405 #[cfg(feature = "dlq")]
406 let result = {
407 let mut dlq = MockDlqHeap::new();
408 dlq.expect_record_success().times(0);
409 dlq.expect_record_failure().times(0);
410 processor.process_pending_events(Arc::new(dlq)).await
411 };
412
413 assert!(matches!(result, Err(OutboxError::DatabaseError(_))));
414 }
415
416 #[rstest]
417 #[tokio::test]
418 async fn update_status_error_propagates_after_publish() {
419 let mut storage = MockOutboxStorage::<TestEvent>::new();
420 let mut transport = MockTransport::<TestEvent>::new();
421
422 storage
423 .expect_fetch_next_to_process()
424 .times(1)
425 .returning(move |_| Ok(vec![make_event(1)]));
426 storage
427 .expect_update_status()
428 .times(1)
429 .returning(|_, _| Err(OutboxError::DatabaseError("boom".into())));
430
431 transport.expect_publish().times(1).returning(|_| Ok(()));
432
433 let processor = OutboxProcessor::new(Arc::new(storage), Arc::new(transport), config());
434
435 #[cfg(not(feature = "dlq"))]
436 let result = processor.process_pending_events().await;
437
438 #[cfg(feature = "dlq")]
439 let result = {
440 let mut dlq = MockDlqHeap::new();
441 dlq.expect_record_success().times(1).returning(|_| Ok(()));
442 processor.process_pending_events(Arc::new(dlq)).await
443 };
444
445 assert!(matches!(result, Err(OutboxError::DatabaseError(_))));
446 }
447}