Skip to main content

reliar_store_postgres/
settings.rs

1//! `PostgresOutboxSettings`, with an opt-in environment loader (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. Everything portable lives in
13/// `reliar_outbox::OutboxSettings`.
14///
15/// Built from [`Self::default`] plus builder methods, never a struct literal (`#[non_exhaustive]`
16/// so a new field never breaks a caller outside this crate):
17///
18/// ```
19/// use reliar_store_postgres::PostgresOutboxSettings;
20/// use std::time::Duration;
21///
22/// let settings = PostgresOutboxSettings::default()
23///     .schema("orders")
24///     .enqueue_sets_search_path(true)
25///     .statement_timeout(Duration::from_secs(2));
26///
27/// assert_eq!(settings.schema, "orders");
28/// assert!(settings.enqueue_sets_search_path);
29/// assert_eq!(settings.statement_timeout, Duration::from_secs(2));
30/// ```
31#[derive(Clone, Debug)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
34#[non_exhaustive]
35pub struct PostgresOutboxSettings {
36    /// The schema `PostgresOutboxStore::new`/`connect` verifies `outbox` resolves to, and the
37    /// same default [`crate::MigrateOptions::schema`] uses. The two SHALL agree — if `migrate()`
38    /// used a different schema, `outbox` is absent here and construction fails with
39    /// [`crate::PostgresStoreError::NotMigrated`] or, if a same-named table exists elsewhere on
40    /// the path, [`crate::PostgresStoreError::SchemaResolution`]. `SCHEMA`. Default `"reliar"`.
41    pub schema: String,
42
43    /// When `true`, `enqueue` wraps its `INSERT` in a transaction-local
44    /// `set_config('search_path', …, true)` and restores the caller's previous value
45    /// afterward — for hosts that can change neither the connection URL nor the role. Costs
46    /// three extra statements per `enqueue`, which is why it defaults to `false`.
47    /// `ENQUEUE_SETS_SEARCH_PATH`.
48    pub enqueue_sets_search_path: bool,
49
50    /// Applied as `SET LOCAL statement_timeout` inside the short transaction Reliar opens for
51    /// **every** statement it issues on its own pool — `acquire`, `complete`, `fail`, `release`,
52    /// `extend_lease`, `stats`, `purge` (each of its three statements), `list_dead`,
53    /// `retry_dead`, `purge_dead` — **never** the caller's `enqueue` transaction, which is the
54    /// caller's own to bound. `Duration::ZERO` (the default) issues nothing and inherits the
55    /// server/role setting; a non-zero value costs a `BEGIN`/`SET LOCAL`/statement(s)/`COMMIT`
56    /// round trip on every call. `STATEMENT_TIMEOUT_MS`.
57    #[cfg_attr(
58        feature = "serde",
59        serde(rename = "statement_timeout_ms", with = "crate::duration_serde")
60    )]
61    pub statement_timeout: Duration,
62}
63
64impl Default for PostgresOutboxSettings {
65    fn default() -> Self {
66        Self {
67            schema: "reliar".to_owned(),
68            enqueue_sets_search_path: false,
69            statement_timeout: Duration::ZERO,
70        }
71    }
72}
73
74impl PostgresOutboxSettings {
75    /// Sets [`Self::schema`].
76    ///
77    /// ```
78    /// use reliar_store_postgres::PostgresOutboxSettings;
79    /// let settings = PostgresOutboxSettings::default().schema("orders");
80    /// assert_eq!(settings.schema, "orders");
81    /// ```
82    #[must_use]
83    pub fn schema(mut self, schema: impl Into<String>) -> Self {
84        self.schema = schema.into();
85
86        self
87    }
88
89    /// Sets [`Self::enqueue_sets_search_path`].
90    ///
91    /// ```
92    /// use reliar_store_postgres::PostgresOutboxSettings;
93    /// let settings = PostgresOutboxSettings::default().enqueue_sets_search_path(true);
94    /// assert!(settings.enqueue_sets_search_path);
95    /// ```
96    #[must_use]
97    pub const fn enqueue_sets_search_path(mut self, enabled: bool) -> Self {
98        self.enqueue_sets_search_path = enabled;
99
100        self
101    }
102
103    /// Sets [`Self::statement_timeout`].
104    ///
105    /// ```
106    /// use reliar_store_postgres::PostgresOutboxSettings;
107    /// use std::time::Duration;
108    ///
109    /// let settings = PostgresOutboxSettings::default()
110    ///     .statement_timeout(Duration::from_millis(500));
111    /// assert_eq!(settings.statement_timeout, Duration::from_millis(500));
112    /// ```
113    #[must_use]
114    pub const fn statement_timeout(mut self, timeout: Duration) -> Self {
115        self.statement_timeout = timeout;
116
117        self
118    }
119
120    /// Opt-in. Starts from [`Self::default`], overrides **only** the variables present under
121    /// `prefix`, and returns `Err` for a present-but-unparseable or out-of-range value — never
122    /// a silent fallback to the default.
123    ///
124    /// ```
125    /// use reliar_store_postgres::PostgresOutboxSettings;
126    ///
127    /// // SAFETY: doctests run single-threaded per binary; no other code reads this var.
128    /// unsafe { std::env::set_var("EXAMPLE_OUTBOX_SCHEMA", "orders") };
129    /// let settings = PostgresOutboxSettings::from_env("EXAMPLE_OUTBOX_")?;
130    /// assert_eq!(settings.schema, "orders");
131    /// # unsafe { std::env::remove_var("EXAMPLE_OUTBOX_SCHEMA") };
132    /// # Ok::<(), reliar_core::SettingsError>(())
133    /// ```
134    ///
135    /// # Errors
136    ///
137    /// Returns [`SettingsError::Parse`] for a present variable that cannot be parsed as its
138    /// declared type.
139    pub fn from_env(prefix: &str) -> Result<Self, SettingsError> {
140        let mut settings = Self::default();
141
142        if let Some(v) = env_raw(prefix, "SCHEMA")? {
143            settings.schema = v;
144        }
145
146        if let Some(v) = env_bool(prefix, "ENQUEUE_SETS_SEARCH_PATH")? {
147            settings.enqueue_sets_search_path = v;
148        }
149
150        if let Some(v) = env_duration_ms(prefix, "STATEMENT_TIMEOUT_MS")? {
151            settings.statement_timeout = v;
152        }
153
154        Ok(settings)
155    }
156}
157
158fn env_raw(prefix: &str, suffix: &str) -> Result<Option<String>, SettingsError> {
159    let key = format!("{prefix}{suffix}");
160
161    match std::env::var(&key) {
162        Ok(value) => Ok(Some(value)),
163        Err(VarError::NotPresent) => Ok(None),
164        Err(VarError::NotUnicode(_)) => Err(SettingsError::parse(key, "a UTF-8 string")),
165    }
166}
167
168fn env_bool(prefix: &str, suffix: &str) -> Result<Option<bool>, SettingsError> {
169    let Some(raw) = env_raw(prefix, suffix)? else {
170        return Ok(None);
171    };
172
173    match raw.trim().to_ascii_lowercase().as_str() {
174        "true" | "1" => Ok(Some(true)),
175        "false" | "0" => Ok(Some(false)),
176        _ => Err(SettingsError::parse(
177            format!("{prefix}{suffix}"),
178            "bool (\"true\"/\"false\"/\"1\"/\"0\")",
179        )),
180    }
181}
182
183fn env_duration_ms(prefix: &str, suffix: &str) -> Result<Option<Duration>, SettingsError> {
184    let Some(raw) = env_raw(prefix, suffix)? else {
185        return Ok(None);
186    };
187    let ms = raw
188        .trim()
189        .parse::<u64>()
190        .map_err(|_| SettingsError::parse(format!("{prefix}{suffix}"), "milliseconds"))?;
191
192    Ok(Some(Duration::from_millis(ms)))
193}