1use 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
21pub 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 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 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 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 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}