outbox_core/config.rs
1//! Runtime configuration for the outbox crate.
2//!
3//! [`OutboxConfig`] carries the tunables that both the producer-side
4//! [`OutboxService`](crate::service::OutboxService) and the worker-side
5//! [`OutboxManager`](crate::manager::OutboxManager) read — batch size, timer
6//! intervals, lock timeout, and which [`IdempotencyStrategy`] to apply when
7//! new events are written.
8
9use crate::model::Event;
10use std::sync::Arc;
11
12/// Runtime configuration shared by the producer and worker sides.
13///
14/// Generic over the user's domain event payload type `P` because the
15/// [`Custom`](IdempotencyStrategy::Custom) strategy variant holds a callback
16/// that derives an idempotency token from `Event<P>`.
17///
18/// Fields are public so callers can override individual values, but the
19/// struct is `#[non_exhaustive]` — start from [`default`](Self::default) and
20/// mutate the fields you care about rather than building a struct literal.
21/// New fields can then be added in a minor release without breaking call
22/// sites.
23///
24/// # Example
25///
26/// ```
27/// use outbox_core::OutboxConfig;
28///
29/// # #[derive(Debug, Clone, serde::Serialize)]
30/// # struct MyEvent;
31/// let mut cfg: OutboxConfig<MyEvent> = OutboxConfig::default();
32/// cfg.batch_size = 200;
33/// cfg.poll_interval_secs = 2;
34///
35/// assert_eq!(cfg.batch_size, 200);
36/// assert_eq!(cfg.retention_days, 7); // inherited from default
37/// ```
38#[derive(Clone)]
39#[non_exhaustive]
40pub struct OutboxConfig<P> {
41 /// Maximum number of events fetched per processing iteration.
42 pub batch_size: u32,
43 /// How long sent events are kept before the garbage collector deletes
44 /// them, measured in days.
45 pub retention_days: i64,
46 /// Interval between garbage-collection passes, in seconds.
47 pub gc_interval_secs: u64,
48 /// Fallback polling interval for the worker loop, in seconds. Used
49 /// alongside database `LISTEN`/notify to guarantee progress even when
50 /// notifications are missed or unsupported.
51 pub poll_interval_secs: u64,
52 /// Duration a row stays locked while a worker processes it, in minutes.
53 /// Once this timeout elapses, the row becomes eligible to be picked up
54 /// again (recovering from a crashed or stuck worker).
55 pub lock_timeout_mins: i64,
56
57 /// How idempotency tokens are produced for newly written events. See
58 /// [`IdempotencyStrategy`] for the available variants.
59 pub idempotency_strategy: IdempotencyStrategy<P>,
60 /// Failure count at which an event becomes eligible for quarantine. Events
61 /// whose failure counter reaches this value are returned by
62 /// [`DlqHeap::drain_exceeded`](crate::dlq::storage::DlqHeap::drain_exceeded)
63 /// on the next reaper pass.
64 ///
65 /// Only consulted when the `dlq` feature is enabled.
66 pub dlq_threshold: u32,
67 /// Interval between dead-letter reaper passes, in seconds. Each pass drains
68 /// events that have crossed [`dlq_threshold`](Self::dlq_threshold) and hands
69 /// them off for quarantine.
70 ///
71 /// Only consulted when the `dlq` feature is enabled.
72 pub dlq_interval_secs: u64,
73}
74
75impl<P> Default for OutboxConfig<P> {
76 /// Returns a configuration suitable as a starting point.
77 ///
78 /// The defaults are:
79 ///
80 /// | Field | Value |
81 /// |---|---|
82 /// | `batch_size` | 100 |
83 /// | `retention_days` | 7 |
84 /// | `gc_interval_secs` | 3600 |
85 /// | `poll_interval_secs` | 10 |
86 /// | `lock_timeout_mins` | 5 |
87 /// | `idempotency_strategy` | [`IdempotencyStrategy::None`] |
88 /// | `dlq_threshold` | 10 |
89 /// | `dlq_interval_secs` | 300 |
90 ///
91 /// These values are part of the public contract — tuning them is a
92 /// deliberate behaviour change.
93 fn default() -> Self {
94 Self {
95 batch_size: 100,
96 retention_days: 7,
97 gc_interval_secs: 3600,
98 poll_interval_secs: 10,
99 lock_timeout_mins: 5,
100 idempotency_strategy: IdempotencyStrategy::None,
101 dlq_threshold: 10,
102 dlq_interval_secs: 300,
103 }
104 }
105}
106
107/// Shared callback used by [`IdempotencyStrategy::Custom`] to derive an
108/// idempotency token from the event about to be written.
109///
110/// Wrapped in [`Arc`] so the strategy stays [`Clone`] without forcing the
111/// callback itself to be `Clone`, and shared safely across threads.
112pub type IdempotencyDeriver<P> = Arc<dyn Fn(&Event<P>) -> String + Send + Sync + 'static>;
113
114/// How an idempotency token is produced when a new event is written.
115///
116/// The variant is evaluated inside
117/// [`OutboxService::add_event`](crate::service::OutboxService::add_event)
118/// before the event is persisted. When an
119/// [`IdempotencyStorageProvider`](crate::idempotency::storage::IdempotencyStorageProvider)
120/// is wired, the produced token is also used to reserve uniqueness up front.
121#[derive(Clone)]
122pub enum IdempotencyStrategy<P> {
123 /// Uses the caller-supplied token as-is. Passing `None` at call site means
124 /// the event is stored without a token and no reservation is attempted.
125 Provided,
126 /// Derives the token by applying the given callback to the event about to
127 /// be written. See [`IdempotencyDeriver`].
128 ///
129 /// Prefer constructing this variant via
130 /// [`IdempotencyStrategy::custom`] so the `Arc` wrapping stays an
131 /// implementation detail.
132 Custom(IdempotencyDeriver<P>),
133 /// Generates a fresh UUID v7 token at write time. Any caller-supplied
134 /// token is ignored.
135 Uuid,
136 //TODO:
137 //HashPayload, //BLAKE3
138 /// Disables idempotency — no token is produced and no reservation is
139 /// attempted. This is the default.
140 None,
141}
142
143impl<P> IdempotencyStrategy<P> {
144 /// Builds an [`IdempotencyStrategy::Custom`] from a callback without
145 /// requiring the caller to wrap it in [`Arc`].
146 ///
147 /// # Example
148 ///
149 /// ```
150 /// use outbox_core::prelude::*;
151 ///
152 /// # #[derive(Debug, Clone, serde::Serialize)]
153 /// # struct MyEvent;
154 /// let strategy: IdempotencyStrategy<MyEvent> =
155 /// IdempotencyStrategy::custom(|event| event.id.as_uuid().to_string());
156 /// # let _ = strategy;
157 /// ```
158 pub fn custom<F>(f: F) -> Self
159 where
160 F: Fn(&Event<P>) -> String + Send + Sync + 'static,
161 {
162 Self::Custom(Arc::new(f))
163 }
164}
165
166#[cfg(test)]
167#[allow(clippy::unwrap_used)]
168mod tests {
169 use super::*;
170 use rstest::rstest;
171 use serde::{Deserialize, Serialize};
172
173 #[derive(Debug, Clone, Serialize, Deserialize)]
174 struct TestPayload;
175
176 fn default_cfg() -> OutboxConfig<TestPayload> {
177 OutboxConfig::default()
178 }
179
180 #[rstest]
181 fn default_batch_size_is_100() {
182 assert_eq!(default_cfg().batch_size, 100);
183 }
184
185 #[rstest]
186 fn default_retention_days_is_7() {
187 assert_eq!(default_cfg().retention_days, 7);
188 }
189
190 #[rstest]
191 fn default_gc_interval_secs_is_3600() {
192 assert_eq!(default_cfg().gc_interval_secs, 3600);
193 }
194
195 #[rstest]
196 fn default_poll_interval_secs_is_10() {
197 assert_eq!(default_cfg().poll_interval_secs, 10);
198 }
199
200 #[rstest]
201 fn default_lock_timeout_mins_is_5() {
202 assert_eq!(default_cfg().lock_timeout_mins, 5);
203 }
204
205 #[rstest]
206 fn default_idempotency_strategy_is_none() {
207 assert!(matches!(
208 default_cfg().idempotency_strategy,
209 IdempotencyStrategy::None
210 ));
211 }
212
213 // ------------------------------- Clone -------------------------------
214
215 #[rstest]
216 fn clone_preserves_scalar_fields() {
217 let cfg = OutboxConfig::<TestPayload> {
218 batch_size: 42,
219 retention_days: 3,
220 gc_interval_secs: 99,
221 poll_interval_secs: 1,
222 lock_timeout_mins: 2,
223 idempotency_strategy: IdempotencyStrategy::Uuid,
224 dlq_threshold: 10,
225 dlq_interval_secs: 1,
226 };
227 let cloned = cfg.clone();
228 assert_eq!(cloned.batch_size, 42);
229 assert_eq!(cloned.retention_days, 3);
230 assert_eq!(cloned.gc_interval_secs, 99);
231 assert_eq!(cloned.poll_interval_secs, 1);
232 assert_eq!(cloned.lock_timeout_mins, 2);
233 assert_eq!(cloned.dlq_threshold, 10);
234 assert_eq!(cloned.dlq_interval_secs, 1);
235 assert!(matches!(
236 cloned.idempotency_strategy,
237 IdempotencyStrategy::Uuid
238 ));
239 }
240
241 #[rstest]
242 fn clone_preserves_custom_strategy_callback() {
243 let cfg = OutboxConfig::<TestPayload> {
244 batch_size: 1,
245 retention_days: 1,
246 gc_interval_secs: 1,
247 poll_interval_secs: 1,
248 lock_timeout_mins: 1,
249 idempotency_strategy: IdempotencyStrategy::custom(|_| "fp".to_string()),
250 dlq_threshold: 10,
251 dlq_interval_secs: 1,
252 };
253 let cloned = cfg.clone();
254 match cloned.idempotency_strategy {
255 IdempotencyStrategy::Custom(f) => {
256 let e = Event::new(
257 crate::object::EventType::new("t"),
258 crate::object::Payload::new(TestPayload),
259 None,
260 );
261 assert_eq!(f(&e), "fp");
262 }
263 _ => panic!("expected Custom variant after clone"),
264 }
265 }
266
267 #[rstest]
268 fn custom_constructor_wraps_callback_into_arc() {
269 let prefix = "tenant-x".to_string();
270 let strategy: IdempotencyStrategy<TestPayload> =
271 IdempotencyStrategy::custom(move |_| format!("{prefix}:tok"));
272 let IdempotencyStrategy::Custom(f) = strategy else {
273 panic!("expected Custom variant");
274 };
275 let e = Event::new(
276 crate::object::EventType::new("t"),
277 crate::object::Payload::new(TestPayload),
278 None,
279 );
280 assert_eq!(f(&e), "tenant-x:tok");
281 }
282}