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}