Skip to main content

reliar_outbox/
settings.rs

1//! The outbox feature's settings, with an opt-in environment loader (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 std::env::VarError;
7use std::num::NonZeroU32;
8use std::time::Duration;
9
10use reliar_core::SettingsError;
11
12use crate::retry::ExponentialBackoff;
13use crate::worker::WorkerId;
14
15/// The one settings struct for the outbox feature. Env prefix `RELIAR_OUTBOX_` by convention;
16/// [`OutboxSettings::from_env`] takes whatever prefix the caller passes.
17///
18/// ```
19/// use reliar_outbox::{DispatcherSettings, OutboxSettings};
20///
21/// let settings = OutboxSettings::default()
22///     .dispatcher(DispatcherSettings::default().batch_size(50));
23/// assert_eq!(settings.dispatcher.batch_size, 50);
24/// ```
25#[derive(Clone, Debug, Default)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
28#[non_exhaustive]
29pub struct OutboxSettings {
30    /// Worker-loop tunables.
31    pub dispatcher: DispatcherSettings,
32
33    /// Purge tunables.
34    pub retention: RetentionSettings,
35}
36
37impl OutboxSettings {
38    /// Sets [`Self::dispatcher`].
39    ///
40    /// ```
41    /// use reliar_outbox::{DispatcherSettings, OutboxSettings};
42    /// let settings = OutboxSettings::default().dispatcher(DispatcherSettings::default());
43    /// assert_eq!(settings.dispatcher.batch_size, 100);
44    /// ```
45    #[must_use]
46    pub fn dispatcher(mut self, dispatcher: DispatcherSettings) -> Self {
47        self.dispatcher = dispatcher;
48
49        self
50    }
51
52    /// Sets [`Self::retention`].
53    ///
54    /// ```
55    /// use std::num::NonZeroU32;
56    /// use reliar_outbox::{OutboxSettings, RetentionSettings};
57    /// let batch_size = NonZeroU32::new(500).unwrap();
58    /// let settings = OutboxSettings::default()
59    ///     .retention(RetentionSettings::default().purge_batch_size(batch_size));
60    /// assert_eq!(settings.retention.purge_batch_size, batch_size);
61    /// ```
62    #[must_use]
63    pub fn retention(mut self, retention: RetentionSettings) -> Self {
64        self.retention = retention;
65
66        self
67    }
68}
69
70/// Worker-loop tunables — the struct the outbox defaults table and the drain rule refer to.
71///
72/// ```
73/// use reliar_outbox::DispatcherSettings;
74/// use std::time::Duration;
75///
76/// let settings = DispatcherSettings::default().batch_size(50).lease(Duration::from_secs(60));
77/// assert_eq!(settings.batch_size, 50);
78/// assert_eq!(settings.lease, Duration::from_secs(60));
79/// ```
80#[derive(Clone, Debug)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
82#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
83#[non_exhaustive]
84pub struct DispatcherSettings {
85    /// The maximum number of rows one `acquire` **statement** claims. `BATCH_SIZE`. Default
86    /// 100.
87    ///
88    /// **`max_in_flight` is the real ceiling on rows this worker holds leased at once —
89    /// `batch_size` only caps a single claim.** The dispatcher claims only while
90    /// `outstanding < max_in_flight`, asking for `min(batch_size, max_in_flight - outstanding)`,
91    /// so the largest claim this dispatcher will ever issue is `min(batch_size, max_in_flight)`
92    /// — the **effective batch**. With the accepted defaults (100 / 16) that is 16, so
93    /// `batch_size` does not bind. Raise it only alongside `max_in_flight` if a single worker is
94    /// meant to hold more rows leased at once.
95    pub batch_size: u32,
96
97    /// How long a claim holds the lease before it may be reclaimed. `LEASE_MS`. Default 30 s.
98    #[cfg_attr(
99        feature = "serde",
100        serde(rename = "lease_ms", with = "reliar_core::serde_millis::millis")
101    )]
102    pub lease: Duration,
103
104    /// The maximum number of publishes running concurrently — **the ceiling that actually
105    /// binds**: every claim asks for at most this many rows, and every startup check that
106    /// reasons about a claim's size (see [`Self::batch_size`]) is computed against it, not
107    /// against `batch_size` alone. `MAX_IN_FLIGHT`. Default 16.
108    pub max_in_flight: usize,
109
110    /// How long one publish is allowed to run before it counts as a timeout (classified
111    /// [`crate::FailureKind::Transient`]). `PUBLISH_TIMEOUT_MS`. Default 10 s. **Must be greater
112    /// than zero** ([`crate::ConfigError::ZeroPublishTimeout`]) — a zero timeout would fail every
113    /// publish before the publisher could ever run.
114    #[cfg_attr(
115        feature = "serde",
116        serde(
117            rename = "publish_timeout_ms",
118            with = "reliar_core::serde_millis::millis"
119        )
120    )]
121    pub publish_timeout: Duration,
122
123    /// How long the loop waits after a claim that came back **partially filled** — the store
124    /// had work, but less than this worker asked for (ADR 0039). A claim that fills its request
125    /// (`records.len() == want`) is followed **immediately** by the next one, with no wait at
126    /// all; an empty claim — including one made entirely of poisoned rows — waits
127    /// [`Self::idle_poll_interval`] instead. `POLL_INTERVAL_MS`. Default 500 ms. **Must be
128    /// greater than zero** ([`crate::ConfigError::ZeroPollInterval`]) — it also seeds the
129    /// outcome-write retry pacing (`outcome_retry_interval`), so a zero value would re-enable a
130    /// CPU-speed spin on both paths.
131    #[cfg_attr(
132        feature = "serde",
133        serde(
134            rename = "poll_interval_ms",
135            with = "reliar_core::serde_millis::millis"
136        )
137    )]
138    pub poll_interval: Duration,
139
140    /// How often the loop polls once it has seen an empty claim (ADR 0039) — the only pacing
141    /// left once a busy dispatcher no longer waits `poll_interval` between full claims. This
142    /// bounds the worst-case pickup latency for a row enqueued while the worker is idle;
143    /// lowering it is the supported way to cut that latency (this release has no
144    /// `LISTEN/NOTIFY`). `IDLE_POLL_INTERVAL_MS`. Default 5 s. **Must be greater than zero**
145    /// ([`crate::ConfigError::ZeroPollInterval`]) — a zero value would poll an idle store at CPU
146    /// speed instead of backing off.
147    #[cfg_attr(
148        feature = "serde",
149        serde(
150            rename = "idle_poll_interval_ms",
151            with = "reliar_core::serde_millis::millis"
152        )
153    )]
154    pub idle_poll_interval: Duration,
155
156    /// The maximum time `run()` spends draining in-flight publishes after cancellation.
157    /// `DRAIN_TIMEOUT_MS`. Default 30 s.
158    ///
159    /// With the defaults, worst-case shutdown is roughly **`drain_timeout + store_timeout`**,
160    /// not just `drain_timeout`: the drain loop itself is bounded by `drain_timeout`, and the one
161    /// best-effort outcome-write attempt made right after it is separately bounded by
162    /// `store_timeout` — the two budgets are not nested, they are sequential.
163    #[cfg_attr(
164        feature = "serde",
165        serde(
166            rename = "drain_timeout_ms",
167            with = "reliar_core::serde_millis::millis"
168        )
169    )]
170    pub drain_timeout: Duration,
171
172    /// A client-side bound on **every** `OutboxStore` call `run` makes — without it a hung
173    /// statement (a lost connection with no server-side `statement_timeout`, a saturated pool)
174    /// makes `drain_timeout` unenforceable. A timeout is treated as a transient store error.
175    ///
176    /// **Must be greater than zero** ([`crate::ConfigError::ZeroStoreTimeout`]) — a zero timeout
177    /// would fail every claim, outcome write, lease renewal and stats poll immediately.
178    ///
179    /// **Must be shorter than half the lease** (`store_timeout < lease / 2`,
180    /// [`crate::ConfigError::StoreTimeoutTooLong`]): `run`'s outcome-write retry races the
181    /// lease-renewal tick inside the same `select!`, so a `store_timeout` any longer could let
182    /// one hung `complete`/`fail` attempt occupy an entire tick gap and starve renewal.
183    /// `STORE_TIMEOUT_MS`. Default 10 s (comfortably under half the default 30 s lease's 15 s).
184    #[cfg_attr(
185        feature = "serde",
186        serde(
187            rename = "store_timeout_ms",
188            with = "reliar_core::serde_millis::millis"
189        )
190    )]
191    pub store_timeout: Duration,
192
193    /// How often `stats()` is polled for the lag/dead-count gauges. `STATS_INTERVAL_MS`.
194    /// Default 15 s.
195    ///
196    /// # Cost
197    ///
198    /// The poll is `O(claimable backlog)`, not `O(table)` — a host that lets the backlog reach
199    /// millions of rows should raise this interval, or set it to [`Duration::ZERO`] to disable
200    /// the tick entirely (opt-in gauges) and call [`crate::OutboxStore::stats`] on its own
201    /// schedule instead.
202    #[cfg_attr(
203        feature = "serde",
204        serde(
205            rename = "stats_interval_ms",
206            with = "reliar_core::serde_millis::millis"
207        )
208    )]
209    pub stats_interval: Duration,
210
211    /// The retry/backoff policy. `RETRY_BASE_MS`, `RETRY_MAX_DELAY_MS`,
212    /// `RETRY_MAX_ATTEMPTS`, `RETRY_JITTER`.
213    pub retry: ExponentialBackoff,
214
215    /// Overrides the generated [`WorkerId`]. `WORKER_ID`. Default: generated.
216    pub worker_id: Option<WorkerId>,
217}
218
219impl Default for DispatcherSettings {
220    fn default() -> Self {
221        Self {
222            batch_size: 100,
223            lease: Duration::from_secs(30),
224            max_in_flight: 16,
225            publish_timeout: Duration::from_secs(10),
226            poll_interval: Duration::from_millis(500),
227            idle_poll_interval: Duration::from_secs(5),
228            drain_timeout: Duration::from_secs(30),
229            store_timeout: Duration::from_secs(10),
230            stats_interval: Duration::from_secs(15),
231            retry: ExponentialBackoff::default(),
232            worker_id: None,
233        }
234    }
235}
236
237impl DispatcherSettings {
238    /// Sets [`Self::batch_size`].
239    ///
240    /// ```
241    /// use reliar_outbox::DispatcherSettings;
242    /// assert_eq!(DispatcherSettings::default().batch_size(50).batch_size, 50);
243    /// ```
244    #[must_use]
245    pub const fn batch_size(mut self, batch_size: u32) -> Self {
246        self.batch_size = batch_size;
247
248        self
249    }
250
251    /// Sets [`Self::lease`].
252    ///
253    /// ```
254    /// use reliar_outbox::DispatcherSettings;
255    /// use std::time::Duration;
256    /// let lease = Duration::from_secs(60);
257    /// assert_eq!(DispatcherSettings::default().lease(lease).lease, lease);
258    /// ```
259    #[must_use]
260    pub const fn lease(mut self, lease: Duration) -> Self {
261        self.lease = lease;
262
263        self
264    }
265
266    /// Sets [`Self::max_in_flight`].
267    ///
268    /// ```
269    /// use reliar_outbox::DispatcherSettings;
270    /// assert_eq!(DispatcherSettings::default().max_in_flight(32).max_in_flight, 32);
271    /// ```
272    #[must_use]
273    pub const fn max_in_flight(mut self, max_in_flight: usize) -> Self {
274        self.max_in_flight = max_in_flight;
275
276        self
277    }
278
279    /// Sets [`Self::publish_timeout`].
280    ///
281    /// ```
282    /// use reliar_outbox::DispatcherSettings;
283    /// use std::time::Duration;
284    /// let timeout = Duration::from_secs(5);
285    /// assert_eq!(DispatcherSettings::default().publish_timeout(timeout).publish_timeout, timeout);
286    /// ```
287    #[must_use]
288    pub const fn publish_timeout(mut self, publish_timeout: Duration) -> Self {
289        self.publish_timeout = publish_timeout;
290
291        self
292    }
293
294    /// Sets [`Self::poll_interval`].
295    ///
296    /// ```
297    /// use reliar_outbox::DispatcherSettings;
298    /// use std::time::Duration;
299    /// let interval = Duration::from_millis(200);
300    /// assert_eq!(DispatcherSettings::default().poll_interval(interval).poll_interval, interval);
301    /// ```
302    #[must_use]
303    pub const fn poll_interval(mut self, poll_interval: Duration) -> Self {
304        self.poll_interval = poll_interval;
305
306        self
307    }
308
309    /// Sets [`Self::idle_poll_interval`].
310    ///
311    /// ```
312    /// use reliar_outbox::DispatcherSettings;
313    /// use std::time::Duration;
314    /// let interval = Duration::from_secs(2);
315    /// assert_eq!(
316    ///     DispatcherSettings::default().idle_poll_interval(interval).idle_poll_interval,
317    ///     interval
318    /// );
319    /// ```
320    #[must_use]
321    pub const fn idle_poll_interval(mut self, idle_poll_interval: Duration) -> Self {
322        self.idle_poll_interval = idle_poll_interval;
323
324        self
325    }
326
327    /// Sets [`Self::drain_timeout`].
328    ///
329    /// ```
330    /// use reliar_outbox::DispatcherSettings;
331    /// use std::time::Duration;
332    /// let timeout = Duration::from_secs(15);
333    /// assert_eq!(DispatcherSettings::default().drain_timeout(timeout).drain_timeout, timeout);
334    /// ```
335    #[must_use]
336    pub const fn drain_timeout(mut self, drain_timeout: Duration) -> Self {
337        self.drain_timeout = drain_timeout;
338
339        self
340    }
341
342    /// Sets [`Self::store_timeout`].
343    ///
344    /// ```
345    /// use reliar_outbox::DispatcherSettings;
346    /// use std::time::Duration;
347    /// let timeout = Duration::from_secs(3);
348    /// assert_eq!(DispatcherSettings::default().store_timeout(timeout).store_timeout, timeout);
349    /// ```
350    #[must_use]
351    pub const fn store_timeout(mut self, store_timeout: Duration) -> Self {
352        self.store_timeout = store_timeout;
353
354        self
355    }
356
357    /// Sets [`Self::stats_interval`].
358    ///
359    /// ```
360    /// use reliar_outbox::DispatcherSettings;
361    /// use std::time::Duration;
362    /// assert_eq!(
363    ///     DispatcherSettings::default().stats_interval(Duration::ZERO).stats_interval,
364    ///     Duration::ZERO
365    /// );
366    /// ```
367    #[must_use]
368    pub const fn stats_interval(mut self, stats_interval: Duration) -> Self {
369        self.stats_interval = stats_interval;
370
371        self
372    }
373
374    /// Sets [`Self::retry`].
375    ///
376    /// ```
377    /// use reliar_outbox::{DispatcherSettings, ExponentialBackoff};
378    /// let retry = ExponentialBackoff::default().max_attempts(3);
379    /// let settings = DispatcherSettings::default().retry(retry);
380    /// assert_eq!(settings.retry.max_attempts, 3);
381    /// ```
382    #[must_use]
383    pub const fn retry(mut self, retry: ExponentialBackoff) -> Self {
384        self.retry = retry;
385
386        self
387    }
388
389    /// Sets [`Self::worker_id`].
390    ///
391    /// ```
392    /// use reliar_outbox::{DispatcherSettings, WorkerId};
393    /// let worker = WorkerId::parse("worker-1").unwrap();
394    /// let settings = DispatcherSettings::default().worker_id(worker.clone());
395    /// assert_eq!(settings.worker_id, Some(worker));
396    /// ```
397    #[must_use]
398    pub fn worker_id(mut self, worker_id: WorkerId) -> Self {
399        self.worker_id = Some(worker_id);
400
401        self
402    }
403}
404
405/// Purge tunables.
406///
407/// ```
408/// use reliar_outbox::RetentionSettings;
409/// use std::time::Duration;
410///
411/// let settings = RetentionSettings::default().dead_retention(Some(Duration::from_secs(30 * 24 * 60 * 60)));
412/// assert_eq!(settings.dead_retention, Some(Duration::from_secs(30 * 24 * 60 * 60)));
413/// ```
414#[derive(Clone, Debug)]
415#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
416#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
417#[non_exhaustive]
418pub struct RetentionSettings {
419    /// How long a published row is kept before `purge` deletes it. `PUBLISHED_RETENTION_MS`.
420    /// Default 7 days.
421    #[cfg_attr(
422        feature = "serde",
423        serde(
424            rename = "published_retention_ms",
425            with = "reliar_core::serde_millis::millis"
426        )
427    )]
428    pub published_retention: Duration,
429
430    /// How long a dead row is kept before `purge` deletes it. `None` keeps dead rows until an
431    /// explicit purge. `DEAD_RETENTION_MS`. Default `None`.
432    ///
433    /// # Warning
434    ///
435    /// The default `None` means [`crate::PurgeRequest::from`] (built from these settings) also
436    /// leaves `dead_retention` at `None` — a host that wants dead rows collected must set a
437    /// value explicitly; deleting one is always a deliberate act.
438    #[cfg_attr(
439        feature = "serde",
440        serde(
441            rename = "dead_retention_ms",
442            with = "reliar_core::serde_millis::optional_millis"
443        )
444    )]
445    pub dead_retention: Option<Duration>,
446
447    /// The maximum number of rows one purge pass deletes, per pass. `PURGE_BATCH_SIZE`.
448    /// Default 1000. A `LIMIT 0` purge would delete nothing and could never make progress, so the
449    /// type itself rules it out (ADR 0058 §2) — `NonZeroU32`'s own `Deserialize` rejects `0` with
450    /// *invalid value: integer `0`, expected a nonzero u32*, and the JSON shape (a plain integer)
451    /// is unchanged.
452    pub purge_batch_size: NonZeroU32,
453}
454
455/// The default [`RetentionSettings::purge_batch_size`] (ADR 0058 §2).
456const DEFAULT_PURGE_BATCH_SIZE: NonZeroU32 = NonZeroU32::new(1_000).unwrap();
457
458impl Default for RetentionSettings {
459    fn default() -> Self {
460        Self {
461            published_retention: Duration::from_secs(7 * 24 * 60 * 60),
462            dead_retention: None,
463            purge_batch_size: DEFAULT_PURGE_BATCH_SIZE,
464        }
465    }
466}
467
468impl RetentionSettings {
469    /// Sets [`Self::published_retention`].
470    ///
471    /// ```
472    /// use reliar_outbox::RetentionSettings;
473    /// use std::time::Duration;
474    /// let retention = Duration::from_secs(24 * 60 * 60);
475    /// let settings = RetentionSettings::default().published_retention(retention);
476    /// assert_eq!(settings.published_retention, retention);
477    /// ```
478    #[must_use]
479    pub const fn published_retention(mut self, retention: Duration) -> Self {
480        self.published_retention = retention;
481
482        self
483    }
484
485    /// Sets [`Self::dead_retention`].
486    ///
487    /// ```
488    /// use reliar_outbox::RetentionSettings;
489    /// use std::time::Duration;
490    /// let settings = RetentionSettings::default().dead_retention(Some(Duration::from_secs(60)));
491    /// assert_eq!(settings.dead_retention, Some(Duration::from_secs(60)));
492    /// ```
493    #[must_use]
494    pub const fn dead_retention(mut self, retention: Option<Duration>) -> Self {
495        self.dead_retention = retention;
496
497        self
498    }
499
500    /// Sets [`Self::purge_batch_size`].
501    ///
502    /// ```
503    /// use std::num::NonZeroU32;
504    /// use reliar_outbox::RetentionSettings;
505    /// let batch_size = NonZeroU32::new(200).unwrap();
506    /// assert_eq!(
507    ///     RetentionSettings::default().purge_batch_size(batch_size).purge_batch_size,
508    ///     batch_size
509    /// );
510    /// ```
511    #[must_use]
512    pub const fn purge_batch_size(mut self, purge_batch_size: NonZeroU32) -> Self {
513        self.purge_batch_size = purge_batch_size;
514
515        self
516    }
517}
518
519impl OutboxSettings {
520    /// Opt-in. Starts from [`Self::default`], overrides **only** the variables present under
521    /// `prefix`, and returns `Err` for a present-but-unparseable or out-of-range value — never
522    /// a silent fallback to the default. Env variable names are flat under `prefix` (e.g.
523    /// `{prefix}LEASE_MS`), regardless of which nested settings struct they populate.
524    ///
525    /// # Errors
526    ///
527    /// Returns [`SettingsError::Parse`] for a present variable that cannot be parsed as its
528    /// declared type (a bad UTF-8 value counts as unparseable), or
529    /// [`SettingsError::OutOfRange`] for one that parses but violates a documented bound (a
530    /// `RETRY_JITTER` outside `[0.0, 1.0)`, a `WORKER_ID` over its maximum length).
531    ///
532    /// ```
533    /// use reliar_outbox::OutboxSettings;
534    ///
535    /// // A prefix with nothing set in the environment falls back to every documented default —
536    /// // `from_env` never invents a value, it only overrides what is present.
537    /// let settings = OutboxSettings::from_env("RELIAR_OUTBOX_DOCTEST_UNSET_").unwrap();
538    /// assert_eq!(settings.dispatcher.batch_size, 100);
539    /// ```
540    pub fn from_env(prefix: &str) -> Result<Self, SettingsError> {
541        let mut dispatcher = DispatcherSettings::default();
542        let mut retention = RetentionSettings::default();
543
544        if let Some(v) = env_u32(prefix, "BATCH_SIZE")? {
545            dispatcher.batch_size = v;
546        }
547
548        if let Some(v) = env_duration_ms(prefix, "LEASE_MS")? {
549            dispatcher.lease = v;
550        }
551
552        if let Some(v) = env_usize(prefix, "MAX_IN_FLIGHT")? {
553            dispatcher.max_in_flight = v;
554        }
555
556        if let Some(v) = env_duration_ms(prefix, "PUBLISH_TIMEOUT_MS")? {
557            dispatcher.publish_timeout = v;
558        }
559
560        if let Some(v) = env_duration_ms(prefix, "POLL_INTERVAL_MS")? {
561            dispatcher.poll_interval = v;
562        }
563
564        if let Some(v) = env_duration_ms(prefix, "IDLE_POLL_INTERVAL_MS")? {
565            dispatcher.idle_poll_interval = v;
566        }
567
568        if let Some(v) = env_duration_ms(prefix, "DRAIN_TIMEOUT_MS")? {
569            dispatcher.drain_timeout = v;
570        }
571
572        if let Some(v) = env_duration_ms(prefix, "STORE_TIMEOUT_MS")? {
573            dispatcher.store_timeout = v;
574        }
575
576        if let Some(v) = env_duration_ms(prefix, "STATS_INTERVAL_MS")? {
577            dispatcher.stats_interval = v;
578        }
579
580        if let Some(v) = env_duration_ms(prefix, "RETRY_BASE_MS")? {
581            dispatcher.retry.base = v;
582        }
583
584        if let Some(v) = env_duration_ms(prefix, "RETRY_MAX_DELAY_MS")? {
585            dispatcher.retry.max_delay = v;
586        }
587
588        if let Some(v) = env_u32(prefix, "RETRY_MAX_ATTEMPTS")? {
589            dispatcher.retry.max_attempts = v;
590        }
591
592        if let Some(v) = env_jitter(prefix, "RETRY_JITTER")? {
593            dispatcher.retry.jitter = v;
594        }
595
596        if let Some(v) = env_worker_id(prefix, "WORKER_ID")? {
597            dispatcher.worker_id = Some(v);
598        }
599
600        if let Some(v) = env_duration_ms(prefix, "PUBLISHED_RETENTION_MS")? {
601            retention.published_retention = v;
602        }
603
604        if let Some(v) = env_duration_ms(prefix, "DEAD_RETENTION_MS")? {
605            retention.dead_retention = Some(v);
606        }
607
608        if let Some(v) = env_u32(prefix, "PURGE_BATCH_SIZE")? {
609            retention.purge_batch_size = NonZeroU32::new(v).ok_or_else(|| {
610                SettingsError::out_of_range(
611                    format!("{prefix}PURGE_BATCH_SIZE"),
612                    "purge_batch_size must be greater than zero",
613                )
614            })?;
615        }
616
617        // The retired `{prefix}ENABLED` / `_ALLOWED_TYPES` / `_DISALLOWED_TYPES` keys are neither
618        // read nor rejected here: an environment is an open namespace, so a retired-key deny-list
619        // would be a permanent tax that could collide with a host's own variable (ADR 0036 §7).
620
621        Ok(Self {
622            dispatcher,
623            retention,
624        })
625    }
626}
627
628/// Reads one raw environment variable under `prefix`. `Ok(None)` when absent; a present but
629/// non-UTF-8 value is treated as unparseable rather than panicking or silently skipping it.
630fn env_raw(prefix: &str, suffix: &str) -> Result<Option<String>, SettingsError> {
631    let key = format!("{prefix}{suffix}");
632
633    match std::env::var(&key) {
634        Ok(value) => Ok(Some(value)),
635        Err(VarError::NotPresent) => Ok(None),
636        Err(VarError::NotUnicode(_)) => Err(SettingsError::parse(key, "a UTF-8 string")),
637    }
638}
639
640fn env_u32(prefix: &str, suffix: &str) -> Result<Option<u32>, SettingsError> {
641    let Some(raw) = env_raw(prefix, suffix)? else {
642        return Ok(None);
643    };
644
645    raw.trim()
646        .parse::<u32>()
647        .map(Some)
648        .map_err(|_| SettingsError::parse(format!("{prefix}{suffix}"), "u32"))
649}
650
651fn env_usize(prefix: &str, suffix: &str) -> Result<Option<usize>, SettingsError> {
652    let Some(raw) = env_raw(prefix, suffix)? else {
653        return Ok(None);
654    };
655
656    raw.trim()
657        .parse::<usize>()
658        .map(Some)
659        .map_err(|_| SettingsError::parse(format!("{prefix}{suffix}"), "usize"))
660}
661
662fn env_duration_ms(prefix: &str, suffix: &str) -> Result<Option<Duration>, SettingsError> {
663    let Some(raw) = env_raw(prefix, suffix)? else {
664        return Ok(None);
665    };
666    let ms = raw
667        .trim()
668        .parse::<u64>()
669        .map_err(|_| SettingsError::parse(format!("{prefix}{suffix}"), "milliseconds"))?;
670
671    Ok(Some(Duration::from_millis(ms)))
672}
673
674fn env_jitter(prefix: &str, suffix: &str) -> Result<Option<f64>, SettingsError> {
675    let Some(raw) = env_raw(prefix, suffix)? else {
676        return Ok(None);
677    };
678    let key = format!("{prefix}{suffix}");
679    let value = raw
680        .trim()
681        .parse::<f64>()
682        .map_err(|_| SettingsError::parse(key.clone(), "f64"))?;
683
684    if !(0.0..1.0).contains(&value) {
685        return Err(SettingsError::out_of_range(
686            key,
687            "jitter must be in the range [0.0, 1.0)",
688        ));
689    }
690
691    Ok(Some(value))
692}
693
694fn env_worker_id(prefix: &str, suffix: &str) -> Result<Option<WorkerId>, SettingsError> {
695    let Some(raw) = env_raw(prefix, suffix)? else {
696        return Ok(None);
697    };
698    let key = format!("{prefix}{suffix}");
699
700    WorkerId::parse(raw).map(Some).map_err(|err| match err {
701        reliar_core::IdError::TooLong { .. } => {
702            SettingsError::out_of_range(key, "worker id exceeds the maximum length")
703        }
704        _ => SettingsError::parse(key, "worker id"),
705    })
706}