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::PostgresOutboxError::NotMigrated`] or, if a same-named table exists elsewhere on
40 /// the path, [`crate::PostgresOutboxError::SchemaNotOnSearchPath`]. `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
158/// What is provider-specific about the inbox (inbox contract §3). A **separate** settings type
159/// from [`PostgresOutboxSettings`] — the inbox has no lease/ordering/retention knobs. It does
160/// share the outbox's [`Self::statement_timeout`] knob, applied to the same kind of call: every
161/// statement the inbox issues on its **own pool** (`fail`, `find`, `purge`) rather than the
162/// caller's transaction (`claim`/`complete`, which stay the caller's to bound).
163///
164/// Built from [`Self::default`] plus builder methods, never a struct literal
165/// (`#[non_exhaustive]` so a new field never breaks a caller outside this crate):
166///
167/// ```
168/// use reliar_store_postgres::PostgresInboxSettings;
169/// use std::time::Duration;
170///
171/// let settings = PostgresInboxSettings::default()
172/// .schema("orders")
173/// .claim_sets_search_path(true)
174/// .statement_timeout(Duration::from_secs(2))
175/// .max_attempts(5);
176///
177/// assert_eq!(settings.schema, "orders");
178/// assert!(settings.claim_sets_search_path);
179/// assert_eq!(settings.statement_timeout, Duration::from_secs(2));
180/// assert_eq!(settings.max_attempts, 5);
181/// ```
182#[derive(Clone, Debug)]
183#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
184#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
185#[non_exhaustive]
186pub struct PostgresInboxSettings {
187 /// The schema [`crate::PostgresInboxStore::connect`] verifies `inbox` resolves to. SHALL
188 /// agree with whatever schema `migrate()` was given — the inbox shares the outbox's single
189 /// `migrate()`, schema and `_migrations` table (inbox contract §1). `SCHEMA`. Default
190 /// `"reliar"`.
191 pub schema: String,
192
193 /// When `true`, [`crate::PostgresInboxStore`]'s `claim` and `complete` wrap their statements
194 /// in a transaction-local `set_config('search_path', …, true)` and restore the caller's
195 /// previous value afterward — for a caller that overrides `search_path` inside its own
196 /// transaction (a host that could change neither the connection URL nor the role would
197 /// already fail `connect`'s own `search_path` verification, so this is not that case).
198 /// Mirrors [`PostgresOutboxSettings::enqueue_sets_search_path`]; defaults to `false` for the
199 /// same reason (it costs extra statements on the caller's own transaction).
200 /// `CLAIM_SETS_SEARCH_PATH`.
201 pub claim_sets_search_path: bool,
202
203 /// Applied as `SET LOCAL statement_timeout` inside the short transaction Reliar opens for
204 /// every statement it issues on its **own pool** — `fail`, `find`, each of `purge`'s three
205 /// deletes (completed, incomplete, dead), and every `InboxDeadLetters` call
206 /// (`list_dead`/`retry_dead`/`purge_dead`) — **never** the caller's `claim`/`complete`
207 /// transaction, which is the caller's own to bound. Exists chiefly for `fail`'s
208 /// `INSERT … ON CONFLICT DO UPDATE`, which otherwise blocks unbounded behind a concurrent
209 /// claimer's still-open transaction on the same `(scope, message_id)` — a canceled statement
210 /// classifies `FailureKind::Transient`, so leaving this at `Duration::ZERO` lets `fail` block
211 /// for as long as the concurrent handler's own transaction runs. `Duration::ZERO` (the
212 /// default) issues nothing and inherits the server/role setting; a non-zero value costs a
213 /// `BEGIN`/`SET LOCAL`/statement/`COMMIT` round trip on every call. `STATEMENT_TIMEOUT_MS`.
214 #[cfg_attr(
215 feature = "serde",
216 serde(rename = "statement_timeout_ms", with = "crate::duration_serde")
217 )]
218 pub statement_timeout: Duration,
219
220 /// The bound [`crate::PostgresInboxStore`]'s `InboxStore::fail` applies to **recorded**
221 /// failures before setting `dead_at` (ADR 0042 A.2.4). Lives here, in the provider, rather
222 /// than in `reliar-inbox`, because the transition must be computed atomically with the
223 /// increment — `fail`'s single `INSERT … ON CONFLICT DO UPDATE` decides
224 /// `attempts + 1 >= max_attempts` in SQL.
225 ///
226 /// `0` is a configuration error, rejected by `Self::validate` and therefore by
227 /// [`crate::PostgresInboxStore::connect`] — `0` reads as "no retries" and does the opposite.
228 /// `u32::MAX` spells "unbounded" explicitly. Default 10. `MAX_ATTEMPTS`.
229 pub max_attempts: u32,
230}
231
232/// The default [`PostgresInboxSettings::max_attempts`] — restated here rather than imported from
233/// `reliar-inbox`, which names no such constant (it depends on no storage engine and defines no
234/// default retry bound of its own).
235const DEFAULT_MAX_ATTEMPTS: u32 = 10;
236
237impl Default for PostgresInboxSettings {
238 fn default() -> Self {
239 Self {
240 schema: "reliar".to_owned(),
241 claim_sets_search_path: false,
242 statement_timeout: Duration::ZERO,
243 max_attempts: DEFAULT_MAX_ATTEMPTS,
244 }
245 }
246}
247
248impl PostgresInboxSettings {
249 /// Sets [`Self::schema`].
250 ///
251 /// ```
252 /// use reliar_store_postgres::PostgresInboxSettings;
253 /// let settings = PostgresInboxSettings::default().schema("orders");
254 /// assert_eq!(settings.schema, "orders");
255 /// ```
256 #[must_use]
257 pub fn schema(mut self, schema: impl Into<String>) -> Self {
258 self.schema = schema.into();
259
260 self
261 }
262
263 /// Sets [`Self::claim_sets_search_path`].
264 ///
265 /// ```
266 /// use reliar_store_postgres::PostgresInboxSettings;
267 /// let settings = PostgresInboxSettings::default().claim_sets_search_path(true);
268 /// assert!(settings.claim_sets_search_path);
269 /// ```
270 #[must_use]
271 pub const fn claim_sets_search_path(mut self, enabled: bool) -> Self {
272 self.claim_sets_search_path = enabled;
273
274 self
275 }
276
277 /// Sets [`Self::statement_timeout`].
278 ///
279 /// ```
280 /// use reliar_store_postgres::PostgresInboxSettings;
281 /// use std::time::Duration;
282 ///
283 /// let settings = PostgresInboxSettings::default()
284 /// .statement_timeout(Duration::from_millis(500));
285 /// assert_eq!(settings.statement_timeout, Duration::from_millis(500));
286 /// ```
287 #[must_use]
288 pub const fn statement_timeout(mut self, timeout: Duration) -> Self {
289 self.statement_timeout = timeout;
290
291 self
292 }
293
294 /// Sets [`Self::max_attempts`].
295 ///
296 /// ```
297 /// use reliar_store_postgres::PostgresInboxSettings;
298 /// let settings = PostgresInboxSettings::default().max_attempts(3);
299 /// assert_eq!(settings.max_attempts, 3);
300 /// ```
301 #[must_use]
302 pub const fn max_attempts(mut self, max_attempts: u32) -> Self {
303 self.max_attempts = max_attempts;
304
305 self
306 }
307
308 /// Opt-in, mirroring [`PostgresOutboxSettings::from_env`]. Starts from [`Self::default`],
309 /// overrides **only** the variables present under `prefix`.
310 ///
311 /// # Errors
312 ///
313 /// Returns [`SettingsError::Parse`] for a present-but-unparseable value.
314 pub fn from_env(prefix: &str) -> Result<Self, SettingsError> {
315 let mut settings = Self::default();
316
317 if let Some(v) = env_raw(prefix, "SCHEMA")? {
318 settings.schema = v;
319 }
320
321 if let Some(v) = env_bool(prefix, "CLAIM_SETS_SEARCH_PATH")? {
322 settings.claim_sets_search_path = v;
323 }
324
325 if let Some(v) = env_duration_ms(prefix, "STATEMENT_TIMEOUT_MS")? {
326 settings.statement_timeout = v;
327 }
328
329 if let Some(v) = env_u32(prefix, "MAX_ATTEMPTS")? {
330 settings.max_attempts = v;
331 }
332
333 Ok(settings)
334 }
335
336 /// Rejects a configuration this crate can never honour: `max_attempts == 0` (see
337 /// [`Self::max_attempts`]). Called by [`crate::PostgresInboxStore::connect`], not implicitly.
338 ///
339 /// # Errors
340 ///
341 /// [`crate::PostgresInboxError::InvalidSettings`].
342 pub(crate) fn validate(&self) -> Result<(), crate::PostgresInboxError> {
343 if self.max_attempts == 0 {
344 return Err(crate::PostgresInboxError::InvalidSettings {
345 message: "max_attempts must not be 0 (reads as \"no retries\"; use u32::MAX for \
346 unbounded)"
347 .to_owned(),
348 });
349 }
350
351 Ok(())
352 }
353}
354
355fn env_raw(prefix: &str, suffix: &str) -> Result<Option<String>, SettingsError> {
356 let key = format!("{prefix}{suffix}");
357
358 match std::env::var(&key) {
359 Ok(value) => Ok(Some(value)),
360 Err(VarError::NotPresent) => Ok(None),
361 Err(VarError::NotUnicode(_)) => Err(SettingsError::parse(key, "a UTF-8 string")),
362 }
363}
364
365fn env_bool(prefix: &str, suffix: &str) -> Result<Option<bool>, SettingsError> {
366 let Some(raw) = env_raw(prefix, suffix)? else {
367 return Ok(None);
368 };
369
370 match raw.trim().to_ascii_lowercase().as_str() {
371 "true" | "1" => Ok(Some(true)),
372 "false" | "0" => Ok(Some(false)),
373 _ => Err(SettingsError::parse(
374 format!("{prefix}{suffix}"),
375 "bool (\"true\"/\"false\"/\"1\"/\"0\")",
376 )),
377 }
378}
379
380fn env_duration_ms(prefix: &str, suffix: &str) -> Result<Option<Duration>, SettingsError> {
381 let Some(raw) = env_raw(prefix, suffix)? else {
382 return Ok(None);
383 };
384 let ms = raw
385 .trim()
386 .parse::<u64>()
387 .map_err(|_| SettingsError::parse(format!("{prefix}{suffix}"), "milliseconds"))?;
388
389 Ok(Some(Duration::from_millis(ms)))
390}
391
392fn env_u32(prefix: &str, suffix: &str) -> Result<Option<u32>, SettingsError> {
393 let Some(raw) = env_raw(prefix, suffix)? else {
394 return Ok(None);
395 };
396
397 raw.trim()
398 .parse::<u32>()
399 .map(Some)
400 .map_err(|_| SettingsError::parse(format!("{prefix}{suffix}"), "u32"))
401}