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().statement_timeout(Duration::from_secs(2));
23///
24/// assert_eq!(settings.statement_timeout, Duration::from_secs(2));
25/// ```
26#[derive(Clone, Debug)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
29#[non_exhaustive]
30pub struct PostgresOutboxSettings {
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 statement_timeout: Duration::ZERO,
49 }
50 }
51}
52
53impl PostgresOutboxSettings {
54 /// Sets [`Self::statement_timeout`].
55 ///
56 /// ```
57 /// use reliar_store_postgres::PostgresOutboxSettings;
58 /// use std::time::Duration;
59 ///
60 /// let settings = PostgresOutboxSettings::default()
61 /// .statement_timeout(Duration::from_millis(500));
62 /// assert_eq!(settings.statement_timeout, Duration::from_millis(500));
63 /// ```
64 #[must_use]
65 pub const fn statement_timeout(mut self, timeout: Duration) -> Self {
66 self.statement_timeout = timeout;
67
68 self
69 }
70
71 /// Opt-in. Starts from [`Self::default`], overrides **only** the variables present under
72 /// `prefix`, and returns `Err` for a present-but-unparseable or out-of-range value — never
73 /// a silent fallback to the default.
74 ///
75 /// ```
76 /// use reliar_store_postgres::PostgresOutboxSettings;
77 /// use std::time::Duration;
78 ///
79 /// // SAFETY: doctests run single-threaded per binary; no other code reads this var.
80 /// unsafe { std::env::set_var("EXAMPLE_OUTBOX_STATEMENT_TIMEOUT_MS", "500") };
81 /// let settings = PostgresOutboxSettings::from_env("EXAMPLE_OUTBOX_")?;
82 /// assert_eq!(settings.statement_timeout, Duration::from_millis(500));
83 /// # unsafe { std::env::remove_var("EXAMPLE_OUTBOX_STATEMENT_TIMEOUT_MS") };
84 /// # Ok::<(), reliar_core::SettingsError>(())
85 /// ```
86 ///
87 /// # Errors
88 ///
89 /// Returns [`SettingsError::Parse`] for a present variable that cannot be parsed as its
90 /// declared type.
91 pub fn from_env(prefix: &str) -> Result<Self, SettingsError> {
92 let mut settings = Self::default();
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
102/// What is provider-specific about the inbox (inbox contract §3). A **separate** settings type
103/// from [`PostgresOutboxSettings`] — the inbox has no lease/ordering/retention knobs. It does
104/// share the outbox's [`Self::statement_timeout`] knob, applied to the same kind of call: every
105/// statement the inbox issues on its **own pool** (`fail`, `find`, `purge`) rather than the
106/// caller's transaction (`claim`/`complete`, which stay the caller's to bound).
107///
108/// Built from [`Self::default`] plus builder methods, never a struct literal
109/// (`#[non_exhaustive]` so a new field never breaks a caller outside this crate):
110///
111/// ```
112/// use reliar_store_postgres::PostgresInboxSettings;
113/// use std::time::Duration;
114///
115/// let settings = PostgresInboxSettings::default()
116/// .statement_timeout(Duration::from_secs(2))
117/// .max_attempts(5);
118///
119/// assert_eq!(settings.statement_timeout, Duration::from_secs(2));
120/// assert_eq!(settings.max_attempts, 5);
121/// ```
122#[derive(Clone, Debug)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
124#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
125#[non_exhaustive]
126pub struct PostgresInboxSettings {
127 /// Applied as `SET LOCAL statement_timeout` inside the short transaction Reliar opens for
128 /// every statement it issues on its **own pool** — `fail`, `find`, each of `purge`'s three
129 /// deletes (completed, incomplete, dead), and every `InboxDeadLetters` call
130 /// (`list_dead`/`retry_dead`/`purge_dead`) — **never** the caller's `claim`/`complete`
131 /// transaction, which is the caller's own to bound. Exists chiefly for `fail`'s
132 /// `INSERT … ON CONFLICT DO UPDATE`, which otherwise blocks unbounded behind a concurrent
133 /// claimer's still-open transaction on the same `(scope, message_id)` — a canceled statement
134 /// classifies `FailureKind::Transient`, so leaving this at `Duration::ZERO` lets `fail` block
135 /// for as long as the concurrent handler's own transaction runs. `Duration::ZERO` (the
136 /// default) issues nothing and inherits the server/role setting; a non-zero value costs a
137 /// `BEGIN`/`SET LOCAL`/statement/`COMMIT` round trip on every call. `STATEMENT_TIMEOUT_MS`.
138 #[cfg_attr(
139 feature = "serde",
140 serde(rename = "statement_timeout_ms", with = "crate::duration_serde")
141 )]
142 pub statement_timeout: Duration,
143
144 /// The bound [`crate::PostgresInboxStore`]'s `InboxStore::fail` applies to **recorded**
145 /// failures before setting `dead_at` (ADR 0042 A.2.4). Lives here, in the provider, rather
146 /// than in `reliar-inbox`, because the transition must be computed atomically with the
147 /// increment — `fail`'s single `INSERT … ON CONFLICT DO UPDATE` decides
148 /// `attempts + 1 >= max_attempts` in SQL.
149 ///
150 /// `0` is a configuration error, rejected by `Self::validate` and therefore by
151 /// [`crate::PostgresInboxStore::with_settings`] — `0` reads as "no retries" and does the
152 /// opposite. `u32::MAX` spells "unbounded" explicitly. Default 10. `MAX_ATTEMPTS`.
153 pub max_attempts: u32,
154}
155
156/// The default [`PostgresInboxSettings::max_attempts`] — restated here rather than imported from
157/// `reliar-inbox`, which names no such constant (it depends on no storage engine and defines no
158/// default retry bound of its own).
159const DEFAULT_MAX_ATTEMPTS: u32 = 10;
160
161impl Default for PostgresInboxSettings {
162 fn default() -> Self {
163 Self {
164 statement_timeout: Duration::ZERO,
165 max_attempts: DEFAULT_MAX_ATTEMPTS,
166 }
167 }
168}
169
170impl PostgresInboxSettings {
171 /// Sets [`Self::statement_timeout`].
172 ///
173 /// ```
174 /// use reliar_store_postgres::PostgresInboxSettings;
175 /// use std::time::Duration;
176 ///
177 /// let settings = PostgresInboxSettings::default()
178 /// .statement_timeout(Duration::from_millis(500));
179 /// assert_eq!(settings.statement_timeout, Duration::from_millis(500));
180 /// ```
181 #[must_use]
182 pub const fn statement_timeout(mut self, timeout: Duration) -> Self {
183 self.statement_timeout = timeout;
184
185 self
186 }
187
188 /// Sets [`Self::max_attempts`].
189 ///
190 /// ```
191 /// use reliar_store_postgres::PostgresInboxSettings;
192 /// let settings = PostgresInboxSettings::default().max_attempts(3);
193 /// assert_eq!(settings.max_attempts, 3);
194 /// ```
195 #[must_use]
196 pub const fn max_attempts(mut self, max_attempts: u32) -> Self {
197 self.max_attempts = max_attempts;
198
199 self
200 }
201
202 /// Opt-in, mirroring [`PostgresOutboxSettings::from_env`]. Starts from [`Self::default`],
203 /// overrides **only** the variables present under `prefix`.
204 ///
205 /// # Errors
206 ///
207 /// Returns [`SettingsError::Parse`] for a present-but-unparseable value.
208 pub fn from_env(prefix: &str) -> Result<Self, SettingsError> {
209 let mut settings = Self::default();
210
211 if let Some(v) = env_duration_ms(prefix, "STATEMENT_TIMEOUT_MS")? {
212 settings.statement_timeout = v;
213 }
214
215 if let Some(v) = env_u32(prefix, "MAX_ATTEMPTS")? {
216 settings.max_attempts = v;
217 }
218
219 Ok(settings)
220 }
221
222 /// Rejects a configuration this crate can never honour: `max_attempts == 0` (see
223 /// [`Self::max_attempts`]). Called by [`crate::PostgresInboxStore::with_settings`], not
224 /// implicitly.
225 ///
226 /// # Errors
227 ///
228 /// [`crate::PostgresInboxError::InvalidSettings`].
229 pub(crate) fn validate(&self) -> Result<(), crate::PostgresInboxError> {
230 if self.max_attempts == 0 {
231 return Err(crate::PostgresInboxError::InvalidSettings {
232 message: "max_attempts must not be 0 (reads as \"no retries\"; use u32::MAX for \
233 unbounded)"
234 .to_owned(),
235 });
236 }
237
238 Ok(())
239 }
240}
241
242fn env_duration_ms(prefix: &str, suffix: &str) -> Result<Option<Duration>, SettingsError> {
243 let key = format!("{prefix}{suffix}");
244 let raw = match std::env::var(&key) {
245 Ok(value) => value,
246 Err(VarError::NotPresent) => return Ok(None),
247 Err(VarError::NotUnicode(_)) => return Err(SettingsError::parse(key, "a UTF-8 string")),
248 };
249 let ms = raw
250 .trim()
251 .parse::<u64>()
252 .map_err(|_| SettingsError::parse(key, "milliseconds"))?;
253
254 Ok(Some(Duration::from_millis(ms)))
255}
256
257fn env_u32(prefix: &str, suffix: &str) -> Result<Option<u32>, SettingsError> {
258 let key = format!("{prefix}{suffix}");
259 let raw = match std::env::var(&key) {
260 Ok(value) => value,
261 Err(VarError::NotPresent) => return Ok(None),
262 Err(VarError::NotUnicode(_)) => return Err(SettingsError::parse(key, "a UTF-8 string")),
263 };
264
265 raw.trim()
266 .parse::<u32>()
267 .map(Some)
268 .map_err(|_| SettingsError::parse(key, "u32"))
269}