Skip to main content

reliar_outbox/
settings.rs

1//! The outbox feature's settings, with an opt-in environment loader (SRS §7.2, §23.1, ADR 0019).
2//!
3//! **The library never reads the environment implicitly.** No constructor, `Default` or builder
4//! method touches [`std::env`] — only [`OutboxSettings::from_env`] does, and only when called.
5
6use core::fmt;
7use std::env::VarError;
8use std::time::Duration;
9
10use crate::ordering::Ordering;
11use crate::retry::ExponentialBackoff;
12use crate::worker::WorkerId;
13
14/// The one settings struct for the outbox feature. Env prefix `RELIAR_OUTBOX_` by convention;
15/// [`OutboxSettings::from_env`] takes whatever prefix the caller passes.
16#[derive(Clone, Debug, Default)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
19#[non_exhaustive]
20pub struct OutboxSettings {
21    /// Worker-loop tunables.
22    pub dispatcher: DispatcherSettings,
23    /// Purge tunables.
24    pub retention: RetentionSettings,
25}
26
27impl OutboxSettings {
28    /// Sets [`Self::dispatcher`].
29    #[must_use]
30    pub fn dispatcher(mut self, dispatcher: DispatcherSettings) -> Self {
31        self.dispatcher = dispatcher;
32        self
33    }
34
35    /// Sets [`Self::retention`].
36    #[must_use]
37    pub fn retention(mut self, retention: RetentionSettings) -> Self {
38        self.retention = retention;
39        self
40    }
41}
42
43/// Worker-loop tunables — the struct the §23.1 defaults table and the §26.1 drain rule refer
44/// to.
45#[derive(Clone, Debug)]
46#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
47#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
48#[non_exhaustive]
49pub struct DispatcherSettings {
50    /// The maximum number of rows one `acquire` **statement** claims. `BATCH_SIZE`. Default
51    /// 100.
52    ///
53    /// **`max_in_flight` is the real ceiling on rows this worker holds leased at once —
54    /// `batch_size` only caps a single claim.** The dispatcher claims only while
55    /// `outstanding < max_in_flight`, asking for `min(batch_size, max_in_flight - outstanding)`
56    /// (S4 review 2); with the accepted defaults (100 / 16) the claim never asks for more than
57    /// 16, so `batch_size` does not bind. Raise it only alongside `max_in_flight` if a single
58    /// worker is meant to hold more rows leased at once.
59    pub batch_size: u32,
60    /// How long a claim holds the lease before it may be reclaimed. `LEASE_MS`. Default 30 s.
61    #[cfg_attr(
62        feature = "serde",
63        serde(rename = "lease_ms", with = "crate::duration_serde::millis")
64    )]
65    pub lease: Duration,
66    /// The maximum number of publishes running concurrently. `MAX_IN_FLIGHT`. Default 16.
67    pub max_in_flight: usize,
68    /// How long one publish is allowed to run before it counts as a timeout (classified
69    /// [`crate::FailureKind::Transient`]). `PUBLISH_TIMEOUT_MS`. Default 10 s.
70    #[cfg_attr(
71        feature = "serde",
72        serde(rename = "publish_timeout_ms", with = "crate::duration_serde::millis")
73    )]
74    pub publish_timeout: Duration,
75    /// How often the loop polls for work when the previous claim was non-empty. `POLL_INTERVAL_MS`.
76    /// Default 500 ms. **Must be greater than zero**
77    /// ([`crate::ConfigError::ZeroPollInterval`], S4 review 7) — it also seeds the outcome-write
78    /// retry pacing (`outcome_retry_interval`), so a zero value would re-enable a CPU-speed spin
79    /// on both paths.
80    #[cfg_attr(
81        feature = "serde",
82        serde(rename = "poll_interval_ms", with = "crate::duration_serde::millis")
83    )]
84    pub poll_interval: Duration,
85    /// How often the loop polls once it has seen an empty claim. `IDLE_POLL_INTERVAL_MS`.
86    /// Default 5 s. **Must be greater than zero**
87    /// ([`crate::ConfigError::ZeroPollInterval`], S4 review 7) — a zero value would poll an idle
88    /// store at CPU speed instead of backing off.
89    #[cfg_attr(
90        feature = "serde",
91        serde(
92            rename = "idle_poll_interval_ms",
93            with = "crate::duration_serde::millis"
94        )
95    )]
96    pub idle_poll_interval: Duration,
97    /// The maximum time `run()` spends draining in-flight publishes after cancellation (§26.1).
98    /// `DRAIN_TIMEOUT_MS`. Default 30 s.
99    ///
100    /// With the defaults, worst-case shutdown is roughly **`drain_timeout + store_timeout`**,
101    /// not just `drain_timeout`: the drain loop itself is bounded by `drain_timeout`, and the one
102    /// best-effort outcome-write attempt made right after it is separately bounded by
103    /// `store_timeout` — the two budgets are not nested, they are sequential (S4 review 3,
104    /// minor).
105    #[cfg_attr(
106        feature = "serde",
107        serde(rename = "drain_timeout_ms", with = "crate::duration_serde::millis")
108    )]
109    pub drain_timeout: Duration,
110    /// A client-side bound on **every** `OutboxStore` call `run` makes — without it a hung
111    /// statement (a lost connection with no server-side `statement_timeout`, a saturated pool)
112    /// makes `drain_timeout` unenforceable (S4 review). A timeout is treated as a transient
113    /// store error.
114    ///
115    /// **Must be shorter than half the lease** (`store_timeout < lease / 2`,
116    /// [`crate::ConfigError::StoreTimeoutTooLong`], S4 review 4): `run`'s outcome-write retry
117    /// races the lease-renewal tick inside the same `select!`, so a `store_timeout` any longer
118    /// could let one hung `complete`/`fail` attempt occupy an entire tick gap and starve
119    /// renewal. `STORE_TIMEOUT_MS`. Default 10 s (comfortably under half the default 30 s
120    /// lease's 15 s).
121    #[cfg_attr(
122        feature = "serde",
123        serde(rename = "store_timeout_ms", with = "crate::duration_serde::millis")
124    )]
125    pub store_timeout: Duration,
126    /// How often `stats()` is polled for the lag/dead-count gauges. `STATS_INTERVAL_MS`.
127    /// Default 15 s.
128    #[cfg_attr(
129        feature = "serde",
130        serde(rename = "stats_interval_ms", with = "crate::duration_serde::millis")
131    )]
132    pub stats_interval: Duration,
133    /// The publication ordering strategy. `ORDERING`. Default [`Ordering::Unordered`].
134    pub ordering: Ordering,
135    /// The retry/backoff policy. `RETRY_BASE_MS`, `RETRY_MAX_DELAY_MS`,
136    /// `RETRY_MAX_ATTEMPTS`, `RETRY_JITTER`.
137    pub retry: ExponentialBackoff,
138    /// Overrides the generated [`WorkerId`]. `WORKER_ID`. Default: generated.
139    pub worker_id: Option<WorkerId>,
140}
141
142impl Default for DispatcherSettings {
143    fn default() -> Self {
144        Self {
145            batch_size: 100,
146            lease: Duration::from_secs(30),
147            max_in_flight: 16,
148            publish_timeout: Duration::from_secs(10),
149            poll_interval: Duration::from_millis(500),
150            idle_poll_interval: Duration::from_secs(5),
151            drain_timeout: Duration::from_secs(30),
152            store_timeout: Duration::from_secs(10),
153            stats_interval: Duration::from_secs(15),
154            ordering: Ordering::default(),
155            retry: ExponentialBackoff::default(),
156            worker_id: None,
157        }
158    }
159}
160
161impl DispatcherSettings {
162    /// Sets [`Self::batch_size`].
163    #[must_use]
164    pub const fn batch_size(mut self, batch_size: u32) -> Self {
165        self.batch_size = batch_size;
166        self
167    }
168
169    /// Sets [`Self::lease`].
170    #[must_use]
171    pub const fn lease(mut self, lease: Duration) -> Self {
172        self.lease = lease;
173        self
174    }
175
176    /// Sets [`Self::max_in_flight`].
177    #[must_use]
178    pub const fn max_in_flight(mut self, max_in_flight: usize) -> Self {
179        self.max_in_flight = max_in_flight;
180        self
181    }
182
183    /// Sets [`Self::publish_timeout`].
184    #[must_use]
185    pub const fn publish_timeout(mut self, publish_timeout: Duration) -> Self {
186        self.publish_timeout = publish_timeout;
187        self
188    }
189
190    /// Sets [`Self::poll_interval`].
191    #[must_use]
192    pub const fn poll_interval(mut self, poll_interval: Duration) -> Self {
193        self.poll_interval = poll_interval;
194        self
195    }
196
197    /// Sets [`Self::idle_poll_interval`].
198    #[must_use]
199    pub const fn idle_poll_interval(mut self, idle_poll_interval: Duration) -> Self {
200        self.idle_poll_interval = idle_poll_interval;
201        self
202    }
203
204    /// Sets [`Self::drain_timeout`].
205    #[must_use]
206    pub const fn drain_timeout(mut self, drain_timeout: Duration) -> Self {
207        self.drain_timeout = drain_timeout;
208        self
209    }
210
211    /// Sets [`Self::store_timeout`].
212    #[must_use]
213    pub const fn store_timeout(mut self, store_timeout: Duration) -> Self {
214        self.store_timeout = store_timeout;
215        self
216    }
217
218    /// Sets [`Self::stats_interval`].
219    #[must_use]
220    pub const fn stats_interval(mut self, stats_interval: Duration) -> Self {
221        self.stats_interval = stats_interval;
222        self
223    }
224
225    /// Sets [`Self::ordering`].
226    #[must_use]
227    pub const fn ordering(mut self, ordering: Ordering) -> Self {
228        self.ordering = ordering;
229        self
230    }
231
232    /// Sets [`Self::retry`].
233    #[must_use]
234    pub const fn retry(mut self, retry: ExponentialBackoff) -> Self {
235        self.retry = retry;
236        self
237    }
238
239    /// Sets [`Self::worker_id`].
240    #[must_use]
241    pub fn worker_id(mut self, worker_id: WorkerId) -> Self {
242        self.worker_id = Some(worker_id);
243        self
244    }
245}
246
247/// Purge tunables.
248#[derive(Clone, Debug)]
249#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
250#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
251#[non_exhaustive]
252pub struct RetentionSettings {
253    /// How long a published row is kept before `purge` deletes it. `PUBLISHED_RETENTION_MS`.
254    /// Default 7 days.
255    #[cfg_attr(
256        feature = "serde",
257        serde(
258            rename = "published_retention_ms",
259            with = "crate::duration_serde::millis"
260        )
261    )]
262    pub published_retention: Duration,
263    /// How long a dead row is kept before `purge` deletes it. `None` keeps dead rows until an
264    /// explicit purge. `DEAD_RETENTION_MS`. Default `None`.
265    #[cfg_attr(
266        feature = "serde",
267        serde(
268            rename = "dead_retention_ms",
269            with = "crate::duration_serde::optional_millis"
270        )
271    )]
272    pub dead_retention: Option<Duration>,
273    /// The maximum number of rows one purge pass deletes, per pass. `PURGE_BATCH_SIZE`.
274    /// Default 1000.
275    pub purge_batch_size: u32,
276}
277
278impl Default for RetentionSettings {
279    fn default() -> Self {
280        Self {
281            published_retention: Duration::from_secs(7 * 24 * 60 * 60),
282            dead_retention: None,
283            purge_batch_size: 1_000,
284        }
285    }
286}
287
288impl RetentionSettings {
289    /// Sets [`Self::published_retention`].
290    #[must_use]
291    pub const fn published_retention(mut self, retention: Duration) -> Self {
292        self.published_retention = retention;
293        self
294    }
295
296    /// Sets [`Self::dead_retention`].
297    #[must_use]
298    pub const fn dead_retention(mut self, retention: Option<Duration>) -> Self {
299        self.dead_retention = retention;
300        self
301    }
302
303    /// Sets [`Self::purge_batch_size`].
304    #[must_use]
305    pub const fn purge_batch_size(mut self, purge_batch_size: u32) -> Self {
306        self.purge_batch_size = purge_batch_size;
307        self
308    }
309}
310
311impl OutboxSettings {
312    /// Opt-in. Starts from [`Self::default`], overrides **only** the variables present under
313    /// `prefix`, and returns `Err` for a present-but-unparseable or out-of-range value — never
314    /// a silent fallback to the default. Env variable names are flat under `prefix` (e.g.
315    /// `{prefix}LEASE_MS`), regardless of which nested settings struct they populate.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`SettingsError::Parse`] for a present variable that cannot be parsed as its
320    /// declared type (a bad UTF-8 value counts as unparseable), or
321    /// [`SettingsError::OutOfRange`] for one that parses but violates a documented bound (a
322    /// `RETRY_JITTER` outside `[0.0, 1.0)`, a `WORKER_ID` over its maximum length).
323    pub fn from_env(prefix: &str) -> Result<Self, SettingsError> {
324        let mut dispatcher = DispatcherSettings::default();
325        let mut retention = RetentionSettings::default();
326
327        if let Some(v) = env_u32(prefix, "BATCH_SIZE")? {
328            dispatcher.batch_size = v;
329        }
330        if let Some(v) = env_duration_ms(prefix, "LEASE_MS")? {
331            dispatcher.lease = v;
332        }
333        if let Some(v) = env_usize(prefix, "MAX_IN_FLIGHT")? {
334            dispatcher.max_in_flight = v;
335        }
336        if let Some(v) = env_duration_ms(prefix, "PUBLISH_TIMEOUT_MS")? {
337            dispatcher.publish_timeout = v;
338        }
339        if let Some(v) = env_duration_ms(prefix, "POLL_INTERVAL_MS")? {
340            dispatcher.poll_interval = v;
341        }
342        if let Some(v) = env_duration_ms(prefix, "IDLE_POLL_INTERVAL_MS")? {
343            dispatcher.idle_poll_interval = v;
344        }
345        if let Some(v) = env_duration_ms(prefix, "DRAIN_TIMEOUT_MS")? {
346            dispatcher.drain_timeout = v;
347        }
348        if let Some(v) = env_duration_ms(prefix, "STORE_TIMEOUT_MS")? {
349            dispatcher.store_timeout = v;
350        }
351        if let Some(v) = env_duration_ms(prefix, "STATS_INTERVAL_MS")? {
352            dispatcher.stats_interval = v;
353        }
354        if let Some(v) = env_ordering(prefix, "ORDERING")? {
355            dispatcher.ordering = v;
356        }
357        if let Some(v) = env_duration_ms(prefix, "RETRY_BASE_MS")? {
358            dispatcher.retry.base = v;
359        }
360        if let Some(v) = env_duration_ms(prefix, "RETRY_MAX_DELAY_MS")? {
361            dispatcher.retry.max_delay = v;
362        }
363        if let Some(v) = env_u32(prefix, "RETRY_MAX_ATTEMPTS")? {
364            dispatcher.retry.max_attempts = v;
365        }
366        if let Some(v) = env_jitter(prefix, "RETRY_JITTER")? {
367            dispatcher.retry.jitter = v;
368        }
369        if let Some(v) = env_worker_id(prefix, "WORKER_ID")? {
370            dispatcher.worker_id = Some(v);
371        }
372
373        if let Some(v) = env_duration_ms(prefix, "PUBLISHED_RETENTION_MS")? {
374            retention.published_retention = v;
375        }
376        if let Some(v) = env_duration_ms(prefix, "DEAD_RETENTION_MS")? {
377            retention.dead_retention = Some(v);
378        }
379        if let Some(v) = env_u32(prefix, "PURGE_BATCH_SIZE")? {
380            retention.purge_batch_size = v;
381        }
382
383        Ok(Self {
384            dispatcher,
385            retention,
386        })
387    }
388}
389
390/// Why [`OutboxSettings::from_env`] failed.
391#[derive(Clone, Debug, PartialEq)]
392#[non_exhaustive]
393pub enum SettingsError {
394    /// A present variable could not be parsed as its declared type. The value is **never
395    /// echoed** — it may carry an operator's typo of something sensitive.
396    Parse {
397        /// The full environment variable name, including the prefix.
398        key: String,
399        /// The type or shape that was expected, e.g. `"u32"`, `"milliseconds"`.
400        value_kind: &'static str,
401    },
402    /// A present variable parsed but violated a documented bound.
403    OutOfRange {
404        /// The full environment variable name, including the prefix.
405        key: String,
406        /// The bound that was violated.
407        message: &'static str,
408    },
409}
410
411/// **Public constructors, because every provider's `from_env` returns this type** (contract §7
412/// I3). `SettingsError` is `#[non_exhaustive]`, so a crate other than `reliar-outbox` — e.g.
413/// `reliar-store-postgres` — cannot build a variant with struct-literal syntax; without these a
414/// provider is forced into a parallel, unrelated error type, and a host wiring two `from_env`
415/// calls ends up handling two different errors for the same class of failure (ADR 0019).
416impl SettingsError {
417    /// The variable was present but did not parse. `value_kind` names the expected shape
418    /// (`"u32"`, `"milliseconds"`); the offending **value is never carried**.
419    #[must_use]
420    pub fn parse(key: impl Into<String>, value_kind: &'static str) -> Self {
421        Self::Parse {
422            key: key.into(),
423            value_kind,
424        }
425    }
426
427    /// The variable parsed but is outside the range the setting accepts.
428    #[must_use]
429    pub fn out_of_range(key: impl Into<String>, message: &'static str) -> Self {
430        Self::OutOfRange {
431            key: key.into(),
432            message,
433        }
434    }
435
436    /// The full environment-variable name, prefix included — what an operator has to go fix.
437    #[must_use]
438    pub fn key(&self) -> &str {
439        match self {
440            Self::Parse { key, .. } | Self::OutOfRange { key, .. } => key,
441        }
442    }
443}
444
445impl fmt::Display for SettingsError {
446    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
447        match self {
448            Self::Parse { key, value_kind } => {
449                write!(f, "{key} could not be parsed as {value_kind}")
450            }
451            Self::OutOfRange { key, message } => {
452                write!(f, "{key} is out of range: {message}")
453            }
454        }
455    }
456}
457
458impl std::error::Error for SettingsError {}
459
460/// Reads one raw environment variable under `prefix`. `Ok(None)` when absent; a present but
461/// non-UTF-8 value is treated as unparseable rather than panicking or silently skipping it.
462fn env_raw(prefix: &str, suffix: &str) -> Result<Option<String>, SettingsError> {
463    let key = format!("{prefix}{suffix}");
464    match std::env::var(&key) {
465        Ok(value) => Ok(Some(value)),
466        Err(VarError::NotPresent) => Ok(None),
467        Err(VarError::NotUnicode(_)) => Err(SettingsError::parse(key, "a UTF-8 string")),
468    }
469}
470
471fn env_u32(prefix: &str, suffix: &str) -> Result<Option<u32>, SettingsError> {
472    let Some(raw) = env_raw(prefix, suffix)? else {
473        return Ok(None);
474    };
475    raw.trim()
476        .parse::<u32>()
477        .map(Some)
478        .map_err(|_| SettingsError::parse(format!("{prefix}{suffix}"), "u32"))
479}
480
481fn env_usize(prefix: &str, suffix: &str) -> Result<Option<usize>, SettingsError> {
482    let Some(raw) = env_raw(prefix, suffix)? else {
483        return Ok(None);
484    };
485    raw.trim()
486        .parse::<usize>()
487        .map(Some)
488        .map_err(|_| SettingsError::parse(format!("{prefix}{suffix}"), "usize"))
489}
490
491fn env_duration_ms(prefix: &str, suffix: &str) -> Result<Option<Duration>, SettingsError> {
492    let Some(raw) = env_raw(prefix, suffix)? else {
493        return Ok(None);
494    };
495    let ms = raw
496        .trim()
497        .parse::<u64>()
498        .map_err(|_| SettingsError::parse(format!("{prefix}{suffix}"), "milliseconds"))?;
499    Ok(Some(Duration::from_millis(ms)))
500}
501
502fn env_jitter(prefix: &str, suffix: &str) -> Result<Option<f64>, SettingsError> {
503    let Some(raw) = env_raw(prefix, suffix)? else {
504        return Ok(None);
505    };
506    let key = format!("{prefix}{suffix}");
507    let value = raw
508        .trim()
509        .parse::<f64>()
510        .map_err(|_| SettingsError::parse(key.clone(), "f64"))?;
511    if !(0.0..1.0).contains(&value) {
512        return Err(SettingsError::out_of_range(
513            key,
514            "jitter must be in the range [0.0, 1.0)",
515        ));
516    }
517    Ok(Some(value))
518}
519
520fn env_ordering(prefix: &str, suffix: &str) -> Result<Option<Ordering>, SettingsError> {
521    let Some(raw) = env_raw(prefix, suffix)? else {
522        return Ok(None);
523    };
524    match raw.trim().to_ascii_lowercase().as_str() {
525        "unordered" => Ok(Some(Ordering::Unordered)),
526        "per_key" | "perkey" | "per-key" => Ok(Some(Ordering::PerKey)),
527        _ => Err(SettingsError::parse(
528            format!("{prefix}{suffix}"),
529            "ordering (\"unordered\" or \"per_key\")",
530        )),
531    }
532}
533
534fn env_worker_id(prefix: &str, suffix: &str) -> Result<Option<WorkerId>, SettingsError> {
535    let Some(raw) = env_raw(prefix, suffix)? else {
536        return Ok(None);
537    };
538    let key = format!("{prefix}{suffix}");
539    WorkerId::parse(raw).map(Some).map_err(|err| match err {
540        reliar_core::IdError::TooLong { .. } => {
541            SettingsError::out_of_range(key, "worker id exceeds the maximum length")
542        }
543        _ => SettingsError::parse(key, "worker id"),
544    })
545}