Skip to main content

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}