Skip to main content

reliar_store_postgres/
settings.rs

1//! `PostgresOutboxSettings`, with an opt-in environment loader (SRS §7.2, §24, ADR 0017).
2//!
3//! **The library never reads the environment implicitly.** No constructor, `Default` or
4//! builder method touches [`std::env`] — only [`PostgresOutboxSettings::from_env`] does, and
5//! only when called (ADR 0019).
6
7use std::env::VarError;
8use std::time::Duration;
9
10use reliar_core::SettingsError;
11
12/// What is provider-specific about the outbox (contract §4). Everything portable lives in
13/// `reliar_outbox::OutboxSettings`.
14#[derive(Clone, Debug)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
17#[non_exhaustive]
18pub struct PostgresOutboxSettings {
19    /// The schema `PostgresOutboxStore::new`/`connect` verifies `outbox` resolves to, and the
20    /// same default [`crate::MigrateOptions::schema`] uses. The two SHALL agree — if `migrate()`
21    /// used a different schema, `outbox` is absent here and construction fails with
22    /// [`crate::PostgresStoreError::NotMigrated`] or, if a same-named table exists elsewhere on
23    /// the path, [`crate::PostgresStoreError::SchemaResolution`]. `SCHEMA`. Default `"reliar"`.
24    pub schema: String,
25    /// When `true`, `enqueue` wraps its `INSERT` in a transaction-local
26    /// `set_config('search_path', …, true)` and restores the caller's previous value
27    /// afterward — for hosts that can change neither the connection URL nor the role. Costs
28    /// three extra statements per `enqueue`, which is why it defaults to `false`.
29    /// `ENQUEUE_SETS_SEARCH_PATH`.
30    pub enqueue_sets_search_path: bool,
31    /// Applied as `SET LOCAL statement_timeout` inside the short transaction Reliar opens for
32    /// **every** statement it issues on its own pool — `acquire`, `complete`, `fail`, `release`,
33    /// `extend_lease`, `stats`, `purge` (each of its three statements), `list_dead`,
34    /// `retry_dead`, `purge_dead` — **never** the caller's `enqueue` transaction, which is the
35    /// caller's own to bound. `Duration::ZERO` (the default) issues nothing and inherits the
36    /// server/role setting; a non-zero value costs a `BEGIN`/`SET LOCAL`/statement(s)/`COMMIT`
37    /// round trip on every call. `STATEMENT_TIMEOUT_MS`.
38    #[cfg_attr(
39        feature = "serde",
40        serde(rename = "statement_timeout_ms", with = "crate::duration_serde")
41    )]
42    pub statement_timeout: Duration,
43}
44
45impl Default for PostgresOutboxSettings {
46    fn default() -> Self {
47        Self {
48            schema: "reliar".to_owned(),
49            enqueue_sets_search_path: false,
50            statement_timeout: Duration::ZERO,
51        }
52    }
53}
54
55impl PostgresOutboxSettings {
56    /// Sets [`Self::schema`].
57    #[must_use]
58    pub fn schema(mut self, schema: impl Into<String>) -> Self {
59        self.schema = schema.into();
60        self
61    }
62
63    /// Sets [`Self::enqueue_sets_search_path`].
64    #[must_use]
65    pub const fn enqueue_sets_search_path(mut self, enabled: bool) -> Self {
66        self.enqueue_sets_search_path = enabled;
67        self
68    }
69
70    /// Sets [`Self::statement_timeout`].
71    #[must_use]
72    pub const fn statement_timeout(mut self, timeout: Duration) -> Self {
73        self.statement_timeout = timeout;
74        self
75    }
76
77    /// Opt-in. Starts from [`Self::default`], overrides **only** the variables present under
78    /// `prefix`, and returns `Err` for a present-but-unparseable or out-of-range value — never
79    /// a silent fallback to the default.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`SettingsError::Parse`] for a present variable that cannot be parsed as its
84    /// declared type.
85    pub fn from_env(prefix: &str) -> Result<Self, SettingsError> {
86        let mut settings = Self::default();
87
88        if let Some(v) = env_raw(prefix, "SCHEMA")? {
89            settings.schema = v;
90        }
91        if let Some(v) = env_bool(prefix, "ENQUEUE_SETS_SEARCH_PATH")? {
92            settings.enqueue_sets_search_path = v;
93        }
94        if let Some(v) = env_duration_ms(prefix, "STATEMENT_TIMEOUT_MS")? {
95            settings.statement_timeout = v;
96        }
97
98        Ok(settings)
99    }
100}
101
102fn env_raw(prefix: &str, suffix: &str) -> Result<Option<String>, SettingsError> {
103    let key = format!("{prefix}{suffix}");
104    match std::env::var(&key) {
105        Ok(value) => Ok(Some(value)),
106        Err(VarError::NotPresent) => Ok(None),
107        Err(VarError::NotUnicode(_)) => Err(SettingsError::parse(key, "a UTF-8 string")),
108    }
109}
110
111fn env_bool(prefix: &str, suffix: &str) -> Result<Option<bool>, SettingsError> {
112    let Some(raw) = env_raw(prefix, suffix)? else {
113        return Ok(None);
114    };
115    match raw.trim().to_ascii_lowercase().as_str() {
116        "true" | "1" => Ok(Some(true)),
117        "false" | "0" => Ok(Some(false)),
118        _ => Err(SettingsError::parse(
119            format!("{prefix}{suffix}"),
120            "bool (\"true\"/\"false\"/\"1\"/\"0\")",
121        )),
122    }
123}
124
125fn env_duration_ms(prefix: &str, suffix: &str) -> Result<Option<Duration>, SettingsError> {
126    let Some(raw) = env_raw(prefix, suffix)? else {
127        return Ok(None);
128    };
129    let ms = raw
130        .trim()
131        .parse::<u64>()
132        .map_err(|_| SettingsError::parse(format!("{prefix}{suffix}"), "milliseconds"))?;
133    Ok(Some(Duration::from_millis(ms)))
134}