Skip to main content

outbox_core/
processor.rs

1//! One iteration of the worker loop: fetch a batch of pending events,
2//! publish each of them, and record the outcome.
3//!
4//! [`OutboxProcessor`] is the unit [`OutboxManager`](crate::manager::OutboxManager)
5//! drives on every wake-up. It keeps the orchestration inside the manager
6//! simple (drain until a fetch returns an empty batch) and leaves the
7//! per-event work — publishing and status bookkeeping — encapsulated here.
8
9use 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
21/// Processes one batch of pending outbox events per invocation.
22///
23/// Holds handles to storage, transport, and configuration; the manager
24/// constructs a single processor at startup and reuses it across iterations.
25pub 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    /// Creates a processor wired to the supplied storage, transport, and
41    /// configuration.
42    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    /// Processes one batch of pending events.
51    ///
52    /// Fetches up to `config.batch_size` rows via
53    /// [`OutboxStorage::fetch_next_to_process`], publishes each through the
54    /// [`Transport`], and then marks every successfully published row as
55    /// [`Sent`](crate::model::EventStatus::Sent) in a single
56    /// [`update_status`](OutboxStorage::update_status) call. Rows whose
57    /// `publish` call failed are left in `Processing`; their lock will expire
58    /// and make them eligible for retry.
59    ///
60    /// Returns the number of events fetched in the batch — `0` signals to the
61    /// caller (typically the manager's drain loop) that there is nothing left
62    /// to do right now and it can go back to waiting.
63    ///
64    /// When the `dlq` feature is enabled, takes a shared
65    /// [`DlqHeap`](crate::dlq::storage::DlqHeap) and records success or
66    /// failure per event so the heap can track repeat-offender rows.
67    ///
68    /// # Errors
69    ///
70    /// Returns a [`DatabaseError`](OutboxError::DatabaseError) propagated from
71    /// `fetch_next_to_process` or `update_status`. Per-event publish failures
72    /// are *not* propagated — they are logged via `tracing::error!` and the
73    /// rest of the batch continues.
74    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}