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}