Skip to main content

outbox_core/
service.rs

1//! Producer-side entry point: writes new events into the outbox.
2//!
3//! [`OutboxService`] is what application code calls when a domain action
4//! needs to emit an event. It applies the configured
5//! [`IdempotencyStrategy`](crate::config::IdempotencyStrategy) to produce (or
6//! accept) a token, optionally reserves that token through an external
7//! [`IdempotencyStorageProvider`] to reject duplicates, and then persists the
8//! event via an [`OutboxWriter`]. The worker side
9//! ([`OutboxManager`](crate::manager::OutboxManager)) picks it up later.
10
11use crate::error::OutboxError;
12use crate::idempotency::storage::NoIdempotency;
13use crate::model::Event;
14use crate::object::{EventType, IdempotencyToken, Payload};
15use crate::prelude::{IdempotencyStorageProvider, OutboxConfig};
16use crate::storage::OutboxWriter;
17use serde::Serialize;
18use std::fmt::Debug;
19use std::sync::Arc;
20
21/// Producer-side facade for writing outbox events.
22///
23/// The service is generic over:
24///
25/// - `W` — [`OutboxWriter`] implementation (persists the event row)
26/// - `S` — [`IdempotencyStorageProvider`] implementation used to reserve
27///   tokens; set to [`NoIdempotency`] when no external reservation is needed
28/// - `P` — the user's domain event payload type (`Debug + Clone + Serialize`)
29///
30/// Construct with [`new`](Self::new) when events should be written without an
31/// external idempotency check, or with
32/// [`with_idempotency`](Self::with_idempotency) to wire a reservation backend.
33pub struct OutboxService<W, S, P>
34where
35    P: Debug + Clone + Serialize + Send + Sync,
36{
37    writer: Arc<W>,
38    config: Arc<OutboxConfig<P>>,
39    idempotency_storage: Option<Arc<S>>,
40}
41
42impl<W, P> OutboxService<W, NoIdempotency, P>
43where
44    W: OutboxWriter<P> + Send + Sync + 'static,
45    P: Debug + Clone + Serialize + Send + Sync,
46{
47    /// Creates a service without any external idempotency reservation.
48    ///
49    /// Tokens are still produced according to `config.idempotency_strategy`
50    /// and written alongside the event, so downstream consumers can deduplicate
51    /// on their side, but no pre-insert uniqueness check is performed here.
52    /// Use [`with_idempotency`](OutboxService::with_idempotency) to attach a
53    /// reservation store (for example Redis) when at-producer deduplication
54    /// is required.
55    pub fn new(writer: Arc<W>, config: Arc<OutboxConfig<P>>) -> Self {
56        Self {
57            writer,
58            config,
59            idempotency_storage: None,
60        }
61    }
62}
63
64impl<W, S, P> OutboxService<W, S, P>
65where
66    W: OutboxWriter<P> + Send + Sync + 'static,
67    S: IdempotencyStorageProvider + Send + Sync + 'static,
68    P: Debug + Clone + Serialize + Send + Sync,
69{
70    /// Creates a service wired to an external idempotency reservation store.
71    ///
72    /// Before inserting the event, the service calls
73    /// [`IdempotencyStorageProvider::try_reserve`] with the token produced by
74    /// the configured strategy. If the reservation returns `false`, the insert
75    /// is skipped and [`OutboxError::DuplicateEvent`] is propagated to the
76    /// caller.
77    pub fn with_idempotency(
78        writer: Arc<W>,
79        config: Arc<OutboxConfig<P>>,
80        idempotency_storage: Arc<S>,
81    ) -> Self {
82        Self {
83            writer,
84            idempotency_storage: Some(idempotency_storage),
85            config,
86        }
87    }
88    /// Adds a new event to the outbox storage with idempotency checks.
89    ///
90    /// The token is derived from
91    /// [`IdempotencyStrategy`](crate::config::IdempotencyStrategy) on the
92    /// configured [`OutboxConfig`]:
93    ///
94    /// - `Provided` — uses `provided_token` as-is (`None` skips reservation).
95    /// - `Uuid` — generates a fresh UUID v7; `provided_token` is ignored.
96    /// - `Custom(callback)` — passes the event about to be written to the
97    ///   callback and uses the returned `String` as the token.
98    /// - `None` — no token is produced and reservation is skipped.
99    ///
100    /// If an idempotency provider is configured and a token was produced, it
101    /// will first attempt to reserve the token to prevent duplicate processing.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`OutboxError::DuplicateEvent`] if the event token has already
106    /// been used. Returns any [`OutboxError`] variant propagated from the
107    /// reservation call or from the writer's `insert_event`.
108    ///
109    /// # Example
110    ///
111    /// ```ignore
112    /// use std::sync::Arc;
113    /// use outbox_core::prelude::*;
114    ///
115    /// # async fn emit(
116    /// #     service: OutboxService<impl OutboxWriter<MyEvent> + Send + Sync + 'static,
117    /// #                            NoIdempotency,
118    /// #                            MyEvent>,
119    /// #     payload: MyEvent,
120    /// # ) -> Result<(), OutboxError>
121    /// # where MyEvent: std::fmt::Debug + Clone + serde::Serialize + Send + Sync,
122    /// # {
123    /// service.add_event("order.created", payload, None).await?;
124    /// # Ok(()) }
125    /// ```
126    pub async fn add_event(
127        &self,
128        event_type: &str,
129        payload: P,
130        provided_token: Option<String>,
131    ) -> Result<(), OutboxError>
132    where
133        P: Debug + Clone + Serialize + Send + Sync,
134    {
135        let mut event = Event::new(EventType::new(event_type), Payload::new(payload), None);
136
137        let i_token = self
138            .config
139            .idempotency_strategy
140            .invoke(provided_token, &event)
141            .map(IdempotencyToken::new);
142
143        if let Some(i_provider) = &self.idempotency_storage
144            && let Some(ref token) = i_token
145            && !i_provider.try_reserve(token).await?
146        {
147            return Err(OutboxError::DuplicateEvent);
148        }
149
150        event.idempotency_token = i_token;
151        self.writer.insert_event(event).await
152    }
153}
154
155#[cfg(test)]
156#[allow(clippy::unwrap_used)]
157mod tests {
158    use super::*;
159    use crate::config::IdempotencyStrategy;
160    use crate::idempotency::storage::MockIdempotencyStorageProvider;
161    use crate::storage::MockOutboxWriter;
162    use rstest::rstest;
163    use serde::Deserialize;
164
165    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166    struct TestPayload {
167        kind: String,
168    }
169
170    fn payload() -> TestPayload {
171        TestPayload { kind: "k".into() }
172    }
173
174    fn config_with(strategy: IdempotencyStrategy<TestPayload>) -> Arc<OutboxConfig<TestPayload>> {
175        Arc::new(OutboxConfig {
176            batch_size: 100,
177            retention_days: 7,
178            gc_interval_secs: 3600,
179            poll_interval_secs: 10,
180            lock_timeout_mins: 5,
181            idempotency_strategy: strategy,
182            dlq_threshold: 10,
183            dlq_interval_secs: 1,
184        })
185    }
186
187    #[rstest]
188    #[tokio::test]
189    async fn none_strategy_without_idempotency_storage_inserts_event_without_token() {
190        let mut writer = MockOutboxWriter::<TestPayload>::new();
191        writer
192            .expect_insert_event()
193            .withf(|e| e.idempotency_token.is_none() && e.event_type.as_str() == "t")
194            .times(1)
195            .returning(|_| Ok(()));
196
197        let service = OutboxService::new(Arc::new(writer), config_with(IdempotencyStrategy::None));
198        let result = service.add_event("t", payload(), None).await;
199        assert!(result.is_ok());
200    }
201
202    #[rstest]
203    #[tokio::test]
204    async fn uuid_strategy_without_idempotency_storage_inserts_event_with_generated_token() {
205        let mut writer = MockOutboxWriter::<TestPayload>::new();
206        writer
207            .expect_insert_event()
208            .withf(|e| {
209                e.idempotency_token
210                    .as_ref()
211                    .is_some_and(|t| !t.as_str().is_empty())
212            })
213            .times(1)
214            .returning(|_| Ok(()));
215
216        let service = OutboxService::new(Arc::new(writer), config_with(IdempotencyStrategy::Uuid));
217        let result = service.add_event("t", payload(), None).await;
218        assert!(result.is_ok());
219    }
220
221    #[rstest]
222    #[tokio::test]
223    async fn uuid_strategy_with_storage_reserves_same_token_as_inserted() {
224        let mut writer = MockOutboxWriter::<TestPayload>::new();
225        let mut idem = MockIdempotencyStorageProvider::new();
226
227        // Захватим токен из reserve и убедимся, что тот же приедет в insert.
228        let reserved: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
229        let reserved_r = reserved.clone();
230        let reserved_w = reserved.clone();
231
232        idem.expect_try_reserve().times(1).returning(move |tok| {
233            *reserved_r.lock().unwrap() = Some(tok.as_str().to_owned());
234            Ok(true)
235        });
236
237        writer
238            .expect_insert_event()
239            .withf(move |e| {
240                let captured = reserved_w.lock().unwrap().clone();
241                match (&e.idempotency_token, captured) {
242                    (Some(t), Some(expected)) => t.as_str() == expected,
243                    _ => false,
244                }
245            })
246            .times(1)
247            .returning(|_| Ok(()));
248
249        let service = OutboxService::with_idempotency(
250            Arc::new(writer),
251            config_with(IdempotencyStrategy::Uuid),
252            Arc::new(idem),
253        );
254        let result = service.add_event("t", payload(), None).await;
255        assert!(result.is_ok());
256    }
257
258    #[rstest]
259    #[tokio::test]
260    async fn provided_some_passes_user_token_to_reserve_and_insert() {
261        let mut writer = MockOutboxWriter::<TestPayload>::new();
262        let mut idem = MockIdempotencyStorageProvider::new();
263
264        idem.expect_try_reserve()
265            .withf(|t| t.as_str() == "user-tok")
266            .times(1)
267            .returning(|_| Ok(true));
268
269        writer
270            .expect_insert_event()
271            .withf(|e| {
272                e.idempotency_token
273                    .as_ref()
274                    .is_some_and(|t| t.as_str() == "user-tok")
275            })
276            .times(1)
277            .returning(|_| Ok(()));
278
279        let service = OutboxService::with_idempotency(
280            Arc::new(writer),
281            config_with(IdempotencyStrategy::Provided),
282            Arc::new(idem),
283        );
284        let result = service
285            .add_event("t", payload(), Some("user-tok".to_string()))
286            .await;
287        assert!(result.is_ok());
288    }
289
290    #[rstest]
291    #[tokio::test]
292    async fn provided_none_skips_reserve_and_inserts_without_token() {
293        let mut writer = MockOutboxWriter::<TestPayload>::new();
294        let mut idem = MockIdempotencyStorageProvider::new();
295
296        idem.expect_try_reserve().times(0);
297
298        writer
299            .expect_insert_event()
300            .withf(|e| e.idempotency_token.is_none())
301            .times(1)
302            .returning(|_| Ok(()));
303
304        let service = OutboxService::with_idempotency(
305            Arc::new(writer),
306            config_with(IdempotencyStrategy::Provided),
307            Arc::new(idem),
308        );
309        let result = service.add_event("t", payload(), None).await;
310        assert!(result.is_ok());
311    }
312
313    #[rstest]
314    #[tokio::test]
315    async fn custom_strategy_uses_extractor_closure_for_token() {
316        fn derive(event: &Event<TestPayload>) -> String {
317            format!("derived:{}", event.payload.as_value().kind)
318        }
319
320        let mut writer = MockOutboxWriter::<TestPayload>::new();
321        let mut idem = MockIdempotencyStorageProvider::new();
322
323        idem.expect_try_reserve()
324            .withf(|t| t.as_str() == "derived:k")
325            .times(1)
326            .returning(|_| Ok(true));
327
328        writer
329            .expect_insert_event()
330            .withf(|e| {
331                e.idempotency_token
332                    .as_ref()
333                    .is_some_and(|t| t.as_str() == "derived:k")
334            })
335            .times(1)
336            .returning(|_| Ok(()));
337
338        let service = OutboxService::with_idempotency(
339            Arc::new(writer),
340            config_with(IdempotencyStrategy::Custom(Arc::new(derive))),
341            Arc::new(idem),
342        );
343        let result = service.add_event("t", payload(), None).await;
344        assert!(result.is_ok());
345    }
346
347    #[rstest]
348    #[tokio::test]
349    async fn duplicate_when_reserve_returns_false_and_insert_is_not_called() {
350        let mut writer = MockOutboxWriter::<TestPayload>::new();
351        let mut idem = MockIdempotencyStorageProvider::new();
352
353        idem.expect_try_reserve().times(1).returning(|_| Ok(false));
354        writer.expect_insert_event().times(0);
355
356        let service = OutboxService::with_idempotency(
357            Arc::new(writer),
358            config_with(IdempotencyStrategy::Provided),
359            Arc::new(idem),
360        );
361        let result = service.add_event("t", payload(), Some("dup".into())).await;
362        assert!(matches!(result, Err(OutboxError::DuplicateEvent)));
363    }
364
365    #[rstest]
366    #[tokio::test]
367    async fn reserve_error_propagates_and_insert_is_not_called() {
368        let mut writer = MockOutboxWriter::<TestPayload>::new();
369        let mut idem = MockIdempotencyStorageProvider::new();
370
371        idem.expect_try_reserve()
372            .times(1)
373            .returning(|_| Err(OutboxError::InfrastructureError("redis down".into())));
374        writer.expect_insert_event().times(0);
375
376        let service = OutboxService::with_idempotency(
377            Arc::new(writer),
378            config_with(IdempotencyStrategy::Uuid),
379            Arc::new(idem),
380        );
381        let result = service.add_event("t", payload(), None).await;
382        assert!(matches!(result, Err(OutboxError::InfrastructureError(_))));
383    }
384
385    #[rstest]
386    #[tokio::test]
387    async fn insert_error_propagates_after_successful_reserve() {
388        let mut writer = MockOutboxWriter::<TestPayload>::new();
389        let mut idem = MockIdempotencyStorageProvider::new();
390
391        idem.expect_try_reserve().times(1).returning(|_| Ok(true));
392        writer
393            .expect_insert_event()
394            .times(1)
395            .returning(|_| Err(OutboxError::DatabaseError("pk conflict".into())));
396
397        let service = OutboxService::with_idempotency(
398            Arc::new(writer),
399            config_with(IdempotencyStrategy::Uuid),
400            Arc::new(idem),
401        );
402        let result = service.add_event("t", payload(), None).await;
403        assert!(matches!(result, Err(OutboxError::DatabaseError(_))));
404    }
405}