autumn_web/db.rs
1//! Database connection pool and extractor.
2//!
3//! This module provides async Postgres connectivity via `diesel-async` with
4//! the `deadpool` connection pool. The pool is created at startup by
5//! [`AppBuilder::run`](crate::app::AppBuilder::run) and stored in
6//! [`crate::state::AppState`].
7//!
8//! When no `database.primary_url` or legacy `database.url` is configured,
9//! [`create_pool`] returns `Ok(None)` and the application runs without a
10//! database -- useful for static-site or API-gateway use cases.
11//!
12//! # The [`Db`] extractor
13//!
14//! Declare `db: Db` in your handler signature to get a pooled connection.
15//! The connection is automatically returned to the pool when `Db` is dropped
16//! at the end of the request.
17//!
18//! ```rust,no_run
19//! use autumn_web::prelude::*;
20//!
21//! #[get("/hello")]
22//! async fn hello(db: Db) -> AutumnResult<String> {
23//! // Use `db` with Diesel queries...
24//! Ok("hello from db".to_string())
25//! }
26//! ```
27
28use axum::extract::FromRequestParts;
29use diesel;
30// Named by the default Postgres pool builder/TLS connector and by the
31// Postgres migration connection (`MigrationConnection`), which stays compiled
32// under the `sqlite` feature (diesel's `postgres` backend is still in the graph
33// via `db`), so this import is used on both builds.
34use diesel_async::AsyncPgConnection;
35use diesel_async::pooled_connection::AsyncDieselConnectionManager;
36use diesel_async::pooled_connection::deadpool::Pool;
37use futures::FutureExt as _;
38use std::any::Any;
39use std::future::Future;
40use std::panic::AssertUnwindSafe;
41use std::pin::Pin;
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::{Arc, Mutex};
44use std::time::Duration;
45use tracing::Instrument as _;
46
47use crate::config::DatabaseConfig;
48use crate::error::AutumnError;
49
50/// The database connection type the runtime is built against.
51///
52/// Defaults to Postgres (`AsyncPgConnection`). Enabling the crate's `sqlite`
53/// feature flips it to a `SQLite` connection. Threading this alias (instead of a
54/// hard-coded `AsyncPgConnection`) through the connection-typed surface — the
55/// pool, the pooled connection, the transaction/query helpers, and the
56/// `#[repository]`/`#[model]` generated code — is what lets the same codebase
57/// target either backend.
58///
59/// FEATURE-UNIFICATION HAZARD: the `sqlite` feature must ONLY be enabled by an
60/// end application (or an explicit `--features sqlite` build). If any workspace
61/// crate or dev-dependency enables it, cargo feature unification flips this
62/// alias for every consumer in the graph and breaks the Postgres default. See
63/// the doc comment above `sqlite = []` in `autumn/Cargo.toml`.
64#[cfg(not(feature = "sqlite"))]
65pub type RuntimeConnection = diesel_async::AsyncPgConnection;
66/// See [`RuntimeConnection`] (Postgres variant) for the full contract. Under
67/// the `sqlite` feature the runtime is built against a `SQLite` connection.
68#[cfg(feature = "sqlite")]
69pub type RuntimeConnection =
70 diesel_async::sync_connection_wrapper::SyncConnectionWrapper<diesel::SqliteConnection>;
71
72/// The diesel query backend the runtime is built against — the companion of
73/// [`RuntimeConnection`].
74///
75/// Defaults to Postgres (`diesel::pg::Pg`); the `sqlite` feature flips it to
76/// `diesel::sqlite::Sqlite`. Generated `#[repository]`/`#[model]` CRUD names
77/// this alias (as `::autumn_web::RuntimeBackend`) wherever a diesel
78/// `QueryFragment<_>` / `SelectableHelper<_>` bound must resolve to the *active*
79/// backend rather than a hard-coded `Pg`. Threading it (instead of `Pg`) through
80/// the always-emitted upsert set-clause and boxed-query types is what lets the
81/// same generated code type-check on either backend. Genuinely Postgres-only
82/// query fragments (advisory-lock upserts, FTS `searchable`) keep an explicit
83/// `Pg` bound — they are not portable and are cfg-gated off under `sqlite`.
84#[cfg(not(feature = "sqlite"))]
85pub type RuntimeBackend = diesel::pg::Pg;
86/// See [`RuntimeBackend`] (Postgres variant). Under the `sqlite` feature the
87/// runtime query backend is `diesel::sqlite::Sqlite`.
88#[cfg(feature = "sqlite")]
89pub type RuntimeBackend = diesel::sqlite::Sqlite;
90
91// ── After-commit callback infrastructure ─────────────────────────────────────
92
93/// A boxed async callback registered for post-transaction execution.
94///
95/// Stored in [`AFTER_COMMIT_REGISTRY`] during an active [`Db::tx`] block.
96/// The registry is drained and each callback is awaited after the transaction
97/// commits successfully. On rollback or panic the callbacks are dropped
98/// without being called.
99pub type CommitCallback = Box<
100 dyn FnOnce() -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'static>>
101 + Send
102 + 'static,
103>;
104
105tokio::task_local! {
106 /// Task-local registry used by [`Db::tx`] to accumulate after-commit
107 /// callbacks. Only set while the [`Db::tx`] future is being polled;
108 /// absent outside a transaction block.
109 pub static AFTER_COMMIT_REGISTRY: Arc<Mutex<Vec<CommitCallback>>>;
110}
111
112/// Per-request accumulator for database query timing, used by the
113/// `Server-Timing` middleware to surface `db;dur=…;desc="N queries"`.
114///
115/// Populated by the [`RequestQueryTimer`] connection instrumentation, which
116/// [`Db::checkout`] installs only on connections checked out while this
117/// task-local is scoped (i.e. while the `ServerTimingLayer` has scoped the
118/// request — see [`request_db_timing_active`]). The timer only *records* when
119/// the task-local is scoped; a stale one left on a reused connection has an
120/// `on_start` that is a cheap bool-probe no-op formatting and recording nothing. It
121/// fires on every diesel-async query (including the raw `.load()`/`.execute()`
122/// calls generated repositories run). The connection instrumentation is the
123/// sole writer during a request — helpers like [`run_instrumented`]
124/// deliberately do *not* record here, to avoid double-counting the same
125/// statement. When the middleware is disabled the scope is absent, so opted-out
126/// requests pay only that per-query bool probe (no `DebugQuery` formatting, no
127/// allocation).
128#[derive(Default, Debug)]
129pub(crate) struct RequestDbTimings {
130 /// Total elapsed time across all DB queries for this request, in
131 /// microseconds. Microseconds keep the header format (`f64` ms rounded
132 /// to three decimals) consistent with the access-log clock without an
133 /// f64 atomic.
134 pub(crate) total_us: AtomicU64,
135 /// Number of instrumented DB queries this request performed.
136 pub(crate) query_count: std::sync::atomic::AtomicUsize,
137}
138
139tokio::task_local! {
140 /// Task-local accumulator populated by DB instrumentation and read by
141 /// the `Server-Timing` middleware. See [`RequestDbTimings`].
142 pub(crate) static REQUEST_DB_TIMINGS: Arc<RequestDbTimings>;
143}
144
145tokio::task_local! {
146 /// Task-local sink for request-wide SQL query capture, independent of the
147 /// `Server-Timing` timing accumulator ([`REQUEST_DB_TIMINGS`]).
148 ///
149 /// Scoped by the test harness (`RequestBuilder::send`) around the whole
150 /// request so every instrumented statement is retained as a
151 /// [`crate::inspector::QueryRecord`] for `TestResponse` query-count / N+1
152 /// assertions. Kept as a separate task-local lane — not folded into
153 /// `RequestDbTimings` — so query capture is unaffected by how the
154 /// `Server-Timing` middleware scopes (and nests) its per-scope timing
155 /// accumulators. Absent in production, where the capture lane is never
156 /// scoped and nothing is retained.
157 #[cfg(feature = "db")]
158 pub(crate) static REQUEST_QUERY_CAPTURE: Arc<Mutex<Vec<crate::inspector::QueryRecord>>>;
159}
160
161/// Strip the trailing `-- binds: [...]` annotation that diesel's `DebugQuery`
162/// `Display` appends to a query's SQL text.
163///
164/// diesel-async feeds each real query into the `StartQuery` instrumentation
165/// event as a `DebugQuery` (see `diesel::debug_query` /
166/// `AsyncPgConnection::with_prepared_statement`), whose `Display` renders
167/// `"{sql} -- binds: {binds:?}"` — the format lives in
168/// `diesel::query_builder::debug_query::display`
169/// (`write!(f, "{query} -- binds: {debug_binds:?}")`). So the SQL text we
170/// materialise from `query.to_string()` is e.g.
171/// `SELECT * FROM books WHERE author_id = $1 -- binds: [1]`.
172///
173/// The per-row executions of an N+1 pattern share the same parameterised
174/// statement (`… WHERE author_id = $1`) and differ only in their bind values
175/// (`-- binds: [1]`, `-- binds: [2]`, …). If the capture path retained that
176/// annotation, [`crate::inspector::normalize_sql`] (which only collapses
177/// whitespace and lower-cases) would treat each per-row execution as a distinct
178/// template, so [`crate::inspector::detect_n_plus_one`] would never see the
179/// repetition and `assert_no_n_plus_one()` would miss the N+1 it exists to
180/// catch. Truncating at the `-- binds` marker leaves the parameterised
181/// statement — `$N` placeholders intact — so repeated per-row queries collapse
182/// to one template.
183///
184/// Robust to input without the marker (transaction-control SQL diesel runs via
185/// `batch_execute`, or synthetic test input): returns the input unchanged. Uses
186/// the last occurrence, since diesel always appends the annotation after the
187/// full statement.
188#[cfg(feature = "db")]
189fn strip_bind_annotation(sql: &str) -> &str {
190 sql.rfind("-- binds:")
191 .map_or(sql, |idx| sql[..idx].trim_end())
192}
193
194/// Record one instrumented DB query into the current request's
195/// [`REQUEST_DB_TIMINGS`], if any. No-op when the task-local is unset —
196/// which is the case whenever the `Server-Timing` middleware is disabled
197/// or the query runs outside a request (e.g. background job).
198pub(crate) fn record_request_db_query(elapsed: Duration, sql: Option<&str>) {
199 // Lane 1: the `Server-Timing` timing accumulator (count + cumulative time).
200 let _ = REQUEST_DB_TIMINGS.try_with(|t| {
201 let micros = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX);
202 t.total_us.fetch_add(micros, Ordering::Relaxed);
203 t.query_count
204 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
205 });
206
207 // Lane 2: request-wide SQL capture, independent of the timing lane. Only
208 // active when a test scoped `REQUEST_QUERY_CAPTURE`; a no-op otherwise
209 // (production, background jobs). The two `try_with` calls are independent —
210 // either lane may be active without the other — and no lock is held across
211 // an await.
212 #[cfg(feature = "db")]
213 if let Some(sql) = sql {
214 let _ = REQUEST_QUERY_CAPTURE.try_with(|sink| {
215 if let Ok(mut list) = sink.lock() {
216 list.push(crate::inspector::QueryRecord {
217 // Store the parameterised statement WITHOUT diesel's per-row
218 // `-- binds: [...]` annotation (the `$N` placeholders stay), so
219 // repeated per-row queries normalise to the same template and
220 // `detect_n_plus_one` can see the repetition. See
221 // [`strip_bind_annotation`].
222 sql: strip_bind_annotation(sql).to_owned(),
223 params: Vec::new(),
224 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
225 location: String::new(),
226 });
227 }
228 });
229 }
230}
231
232/// Whether the current task is inside a [`REQUEST_DB_TIMINGS`] scope — i.e.
233/// whether the `ServerTimingLayer` (only active when `[observability]
234/// server_timing` is enabled) has wrapped this request's handler future.
235///
236/// Cheap: probes the task-local without cloning the `Arc`, allocating, or
237/// formatting anything. Probed by [`RequestQueryTimer::on_start`] on every
238/// query: when no scope is active (the production default, `server_timing`
239/// unset), the timer records nothing and skips the `DebugQuery` formatting, so
240/// an always-installed timer costs opted-out requests only this bool probe per
241/// query — no per-statement allocation.
242#[cfg(feature = "db")]
243pub(crate) fn request_db_timing_active() -> bool {
244 REQUEST_DB_TIMINGS.try_with(|_| ()).is_ok()
245}
246
247/// Whether the current task is inside a [`REQUEST_QUERY_CAPTURE`] scope — i.e.
248/// whether the test harness (`RequestBuilder::send`) has scoped a capture sink
249/// around this request.
250///
251/// Mirrors [`request_db_timing_active`]: a cheap task-local probe that neither
252/// clones the `Arc` nor allocates. [`Db::checkout`] installs the
253/// [`RequestQueryTimer`] instrumentation when EITHER this lane or the timing
254/// lane is active, so query capture works even when `server_timing` is off (the
255/// common test case) and no `REQUEST_DB_TIMINGS` scope exists.
256#[cfg(feature = "db")]
257pub(crate) fn request_query_capture_active() -> bool {
258 REQUEST_QUERY_CAPTURE.try_with(|_| ()).is_ok()
259}
260
261/// diesel-async [`Instrumentation`](diesel::connection::Instrumentation)
262/// that feeds every executed statement into the per-request
263/// [`REQUEST_DB_TIMINGS`] accumulator for the `Server-Timing` `db` metric.
264///
265/// Installed on a pooled connection at [`Db::checkout`] only when a
266/// [`REQUEST_DB_TIMINGS`] scope is active (see [`request_db_timing_active`]),
267/// i.e. only for requests the `ServerTimingLayer` is measuring — so opted-out
268/// requests never install it and pay no per-query overhead. Because generated
269/// repositories run raw `diesel-async` `.load()`/`.execute()` (they do not go
270/// through [`run_instrumented`]), the connection-level instrumentation is the
271/// only thing that observes those queries. It brackets each statement between
272/// the `StartQuery`/`FinishQuery` events the connection emits and records the
273/// elapsed wall time via [`record_request_db_query`], which is a no-op when no
274/// request has scoped the task-local (background jobs, or the middleware being
275/// disabled) — so it never panics off-request.
276///
277/// Only genuine application statements are timed. The dedicated
278/// connection-establish, prepared-statement-cache, and
279/// `BeginTransaction`/`CommitTransaction`/`RollbackTransaction` events are
280/// ignored outright. Crucially, diesel-async runs the transaction-control SQL
281/// itself (`BEGIN`, `COMMIT`, `ROLLBACK`, and the `SAVEPOINT`/`RELEASE`
282/// variants for nested transactions) through `batch_execute`, which emits a
283/// `StartQuery`/`FinishQuery` pair with that SQL — exactly like a real query.
284/// Left unfiltered, a transaction wrapping a single `SELECT` would report
285/// `desc="3 queries"` (BEGIN + SELECT + COMMIT) and fold begin/commit latency
286/// into `db;dur`. Likewise [`Db::checkout`] issues a `SET statement_timeout`
287/// housekeeping statement on every checkout, which would otherwise add a bogus
288/// `+1 query` to every request before any app SQL runs. So at `StartQuery` we
289/// inspect the statement text and skip timing when it is a housekeeping /
290/// transaction-control command (see
291/// [`RequestQueryTimer::is_uncounted_statement`]). Queries on a single
292/// connection are strictly sequential (`&mut conn`), so a single
293/// `Option<Instant>` slot is sufficient — there is no overlap between a start
294/// and its finish, and skipping a start leaves the slot empty so the matching
295/// finish is a no-op.
296///
297/// The timer is installed at [`Db::checkout`] (before the housekeeping `SET`)
298/// **only when a `REQUEST_DB_TIMINGS` scope is active** — i.e. only for requests
299/// the `ServerTimingLayer` is measuring. This gate matters because
300/// `set_instrumentation` wholesale replaces the connection's instrumentation:
301/// installing unconditionally would clobber any global default an application
302/// registered via `diesel::connection::set_default_instrumentation` (query
303/// logging, tracing, metrics), even when `server_timing` is disabled. Gating on
304/// the scope leaves an opted-out app's instrumentation fully intact. When the
305/// scope *is* active, installing a fresh timer per checkout also clears any
306/// stale timer a pooled connection carried from a prior timed request. A stale
307/// timer left on a connection later reused by an opted-out request is a cheap
308/// no-op: `on_start` probes [`request_db_timing_active`] before doing any work,
309/// so it never formats SQL or allocates off-scope.
310///
311/// Autumn does not currently *compose* with an app-registered
312/// `set_default_instrumentation` — while `server_timing` is enabled its timer
313/// replaces the app's on measured checkouts. This is a documented limitation
314/// (`server_timing` is a dev/off-by-default feature); apps needing both should
315/// keep it disabled in that environment.
316#[cfg(feature = "db")]
317#[derive(Default, Debug)]
318pub(crate) struct RequestQueryTimer {
319 /// The currently in-flight statement (start instant + SQL text), if any.
320 pending: Option<PendingQuery>,
321}
322
323/// A statement whose `StartQuery` has fired but whose `FinishQuery` has not.
324///
325/// Holds the start instant (for latency) and the materialised SQL text (so a
326/// capturing accumulator can retain it). Queries on a single connection are
327/// strictly sequential, so a single slot suffices.
328#[cfg(feature = "db")]
329#[derive(Debug)]
330struct PendingQuery {
331 started_at: std::time::Instant,
332 sql: String,
333}
334
335#[cfg(feature = "db")]
336impl RequestQueryTimer {
337 /// Whether `sql` is a statement that must **not** be counted as an
338 /// application query in the `Server-Timing` `db` metric. Two kinds reach
339 /// the connection instrumentation but are not application work:
340 ///
341 /// * **Transaction control** that diesel-async runs via `batch_execute`
342 /// (which emits a `StartQuery`/`FinishQuery` pair just like a real
343 /// query): `BEGIN`, `COMMIT`, `ROLLBACK`, the `SAVEPOINT`/`RELEASE`
344 /// nested-transaction variants, and the two-word `START TRANSACTION` form.
345 /// * **Session/config housekeeping** — any leading-token `SET`. This covers
346 /// the `SET statement_timeout` that [`Db::checkout`] issues on every
347 /// checkout before handing the connection to the request, as well as
348 /// `SET TRANSACTION`. None of these are application queries.
349 ///
350 /// Matches a case-insensitive leading token in
351 /// `{BEGIN, COMMIT, ROLLBACK, SAVEPOINT, RELEASE, SET}`, plus the two-word
352 /// `START TRANSACTION` form. See the type-level docs.
353 fn is_uncounted_statement(sql: &str) -> bool {
354 let mut tokens = sql.split_whitespace();
355 let Some(first) = tokens.next() else {
356 return false;
357 };
358 match first.to_ascii_uppercase().as_str() {
359 "BEGIN" | "COMMIT" | "ROLLBACK" | "SAVEPOINT" | "RELEASE" | "SET" => true,
360 "START" => tokens
361 .next()
362 .is_some_and(|second| second.eq_ignore_ascii_case("TRANSACTION")),
363 _ => false,
364 }
365 }
366
367 /// Record the start of a statement.
368 ///
369 /// Probes both [`request_db_timing_active`] and
370 /// [`request_query_capture_active`] first: when NEITHER the timing
371 /// accumulator nor the capture sink is scoped (the opted-out / off-request
372 /// path) the timer does nothing and — crucially — never invokes `sql`, so
373 /// the caller's `DebugQuery` `to_string()` allocation is skipped entirely.
374 /// This is what makes it safe to leave a `RequestQueryTimer` installed on a
375 /// pooled connection that a later opted-out request reuses: the stale timer
376 /// is a cheap bool-probe no-op, not a per-query allocator.
377 ///
378 /// When either lane *is* active, the SQL text is materialised and inspected:
379 /// housekeeping / transaction-control statements (see
380 /// [`Self::is_uncounted_statement`], e.g. the checkout `SET
381 /// statement_timeout`) leave the slot empty so they are excluded from the
382 /// accumulator; genuine application statements record their start instant.
383 /// Clearing the slot keeps the matching `FinishQuery` a no-op.
384 ///
385 /// `sql` is a closure so the (allocating) formatting is deferred until we
386 /// know the statement will be counted. Extracted from the event handler so
387 /// the start/finish accounting is unit-testable without constructing a
388 /// (non-exhaustive, unstable-to-build) `InstrumentationEvent`.
389 fn on_start(&mut self, now: std::time::Instant, sql: impl FnOnce() -> String) {
390 // Probe BOTH lanes: the timing accumulator (`server_timing`) and the
391 // query-capture sink (test harness). Either being active means the
392 // upcoming statement must be observed. When neither is scoped (the
393 // opted-out / off-request path) the timer does nothing and never
394 // invokes `sql`, so the caller's `DebugQuery` `to_string()` allocation
395 // is skipped — keeping a stale installed timer a cheap bool-probe no-op.
396 if !request_db_timing_active() && !request_query_capture_active() {
397 self.pending = None;
398 return;
399 }
400 // Materialise the SQL once: it is needed both for the housekeeping
401 // filter and (when a test opted into capture) to retain the statement
402 // text at `on_finish`.
403 let sql = sql();
404 self.pending = if Self::is_uncounted_statement(&sql) {
405 None
406 } else {
407 Some(PendingQuery {
408 started_at: now,
409 sql,
410 })
411 };
412 }
413
414 /// Record the completion of the in-flight statement, accumulating its
415 /// elapsed time into the per-request accumulator. A `FinishQuery` without
416 /// a matching `StartQuery` is ignored.
417 fn on_finish(&mut self, now: std::time::Instant) {
418 if let Some(p) = self.pending.take() {
419 record_request_db_query(now.saturating_duration_since(p.started_at), Some(&p.sql));
420 }
421 }
422}
423
424#[cfg(feature = "db")]
425impl diesel::connection::Instrumentation for RequestQueryTimer {
426 fn on_connection_event(&mut self, event: diesel::connection::InstrumentationEvent<'_>) {
427 use diesel::connection::InstrumentationEvent;
428 match event {
429 InstrumentationEvent::StartQuery { query, .. } => {
430 // `query` is an opaque `&dyn DebugQuery`; its `Display` impl
431 // renders the SQL text, which we inspect to skip housekeeping /
432 // transaction-control statements (see the type-level docs). The
433 // `to_string()` is deferred behind a closure so an installed but
434 // opted-out timer never pays the allocation — see `on_start`.
435 self.on_start(std::time::Instant::now(), || query.to_string());
436 }
437 InstrumentationEvent::FinishQuery { .. } => self.on_finish(std::time::Instant::now()),
438 // Ignore connection-establish, prepared-statement cache, and the
439 // dedicated Begin/Commit/RollbackTransaction events — see the
440 // type-level docs.
441 _ => {}
442 }
443 }
444}
445
446/// Total count of after-commit callback errors since process start.
447///
448/// Incremented each time a callback registered via [`register_after_commit`]
449/// or [`Db::tx`] returns an error **after** the transaction has already
450/// committed. The underlying transaction is unaffected; this counter surfaces
451/// failures for alerting and dashboards.
452///
453/// Exposed by the `/actuator/health` endpoint as the top-level
454/// `autumn_after_commit_failures_total` field.
455pub static AFTER_COMMIT_FAILURES_TOTAL: AtomicU64 = AtomicU64::new(0);
456
457pub(crate) fn record_after_commit_failure() -> u64 {
458 AFTER_COMMIT_FAILURES_TOTAL.fetch_add(1, Ordering::Relaxed) + 1
459}
460
461/// Total number of transaction retries triggered by a transient serialization
462/// failure (`40001`) or deadlock (`40P01`) since process start.
463///
464/// Incremented by [`Db::tx_with`] each time a retryable transaction error is
465/// re-run under a stronger isolation level. Surfaces contention on the existing
466/// metrics surface (`autumn_tx_retries_total`).
467pub static TX_RETRIES_TOTAL: AtomicU64 = AtomicU64::new(0);
468
469/// Total number of transactions that exhausted their retry budget without
470/// succeeding, since process start. Exposed as `autumn_tx_retry_exhausted_total`.
471pub static TX_RETRY_EXHAUSTED_TOTAL: AtomicU64 = AtomicU64::new(0);
472
473// The transaction serialization-failure retry loop is Postgres-only (SQLite's
474// `tx_with` runs a single plain transaction), so these retry-metric helpers are
475// unused in a `--features sqlite` library build. They are still exercised by the
476// crate's unit tests, so keep them compiled and only silence the dead-code
477// warning under the feature.
478#[cfg_attr(feature = "sqlite", allow(dead_code))]
479pub(crate) fn record_tx_retry() -> u64 {
480 TX_RETRIES_TOTAL.fetch_add(1, Ordering::Relaxed) + 1
481}
482
483#[cfg_attr(feature = "sqlite", allow(dead_code))]
484pub(crate) fn record_tx_retry_exhausted() -> u64 {
485 TX_RETRY_EXHAUSTED_TOTAL.fetch_add(1, Ordering::Relaxed) + 1
486}
487
488/// Guidance appended to the nested-`tx` rejection, naming the supported
489/// alternative for a same-connection nested transaction.
490const NESTED_TX_MESSAGE: &str = "Nested Db::tx calls are not supported; use \
491 autumn_web::db::savepoint(conn, ..) inside the closure for a same-connection savepoint";
492
493pub(crate) fn reject_ambient_after_commit_registry_for_tx() -> Result<(), AutumnError> {
494 if AFTER_COMMIT_REGISTRY.try_with(|_| ()).is_ok() {
495 return Err(AutumnError::bad_request_msg(NESTED_TX_MESSAGE));
496 }
497 Ok(())
498}
499
500pub(crate) fn spawn_committed_after_commit_callbacks(
501 callbacks: Vec<CommitCallback>,
502) -> Option<tokio::task::JoinHandle<()>> {
503 if callbacks.is_empty() {
504 return None;
505 }
506
507 Some(tokio::task::spawn(async move {
508 for cb in callbacks {
509 let result = match std::panic::catch_unwind(AssertUnwindSafe(cb)) {
510 Ok(callback) => AssertUnwindSafe(callback).catch_unwind().await,
511 Err(panic) => Err(panic),
512 };
513
514 match result {
515 Ok(Ok(())) => {}
516 Ok(Err(e)) => {
517 let failures_total = record_after_commit_failure();
518 tracing::error!(
519 autumn.after_commit.failures_total = failures_total,
520 "after_commit callback failed (tx already committed): {e}"
521 );
522 }
523 Err(panic) => {
524 let failures_total = record_after_commit_failure();
525 let panic = after_commit_panic_message(&*panic);
526 tracing::error!(
527 autumn.after_commit.failures_total = failures_total,
528 "after_commit callback panicked (tx already committed): {panic}"
529 );
530 }
531 }
532 }
533 }))
534}
535
536fn after_commit_panic_message(payload: &(dyn Any + Send)) -> String {
537 match (
538 payload.downcast_ref::<&'static str>(),
539 payload.downcast_ref::<String>(),
540 ) {
541 (Some(message), _) => (*message).to_owned(),
542 (_, Some(message)) => message.clone(),
543 (None, None) => "non-string panic payload".to_owned(),
544 }
545}
546
547/// Register a callback to run after the current database transaction commits.
548///
549/// If called inside a [`Db::tx`] block, the callback is deferred until the
550/// transaction commits successfully. On rollback the callback is dropped
551/// without being called.
552///
553/// The deferred callback is process-local work spawned after commit. It avoids
554/// side effects for rolled-back transactions, but it is not a crash-safe
555/// delivery mechanism. For side effects that must survive process exit, write a
556/// durable outbox or queue row inside the same database transaction and use
557/// this callback only as an optional wake-up hint.
558///
559/// If called **outside** any active transaction, the callback runs immediately
560/// (eager execution) with a `debug`-level log note.
561///
562/// # Panics
563///
564/// Panics if the internal registry mutex is poisoned (only possible if a
565/// previous thread holding the lock panicked, which should not occur in normal
566/// operation).
567///
568/// # Example
569///
570/// ```rust,ignore
571/// db.tx(move |conn| {
572/// scoped_boxed(async move {
573/// diesel::insert_into(users::table).values(&new_user).execute(conn).await?;
574/// autumn_web::db::register_after_commit(|| async {
575/// welcome_email_job.enqueue("user_id", user_id).await
576/// }).await;
577/// Ok(())
578/// })
579/// }).await?;
580/// ```
581pub async fn register_after_commit<F, Fut>(f: F)
582where
583 F: FnOnce() -> Fut + Send + 'static,
584 Fut: Future<Output = crate::AutumnResult<()>> + Send + 'static,
585{
586 let mut f_opt = Some(f);
587 AFTER_COMMIT_REGISTRY
588 .try_with(|registry| {
589 let f = f_opt.take().expect("closure only entered once");
590 let boxed: CommitCallback = Box::new(move || Box::pin(f()));
591 registry.lock().expect("registry lock").push(boxed);
592 })
593 .ok();
594
595 // If still Some, the task-local wasn't set — we're outside a tx; run eagerly.
596 if let Some(f) = f_opt {
597 tracing::debug!("register_after_commit: no active transaction; running callback eagerly");
598 if let Err(e) = f().await {
599 let failures_total = record_after_commit_failure();
600 tracing::error!(
601 autumn.after_commit.failures_total = failures_total,
602 "register_after_commit eager callback failed: {e}"
603 );
604 }
605 }
606}
607
608/// Trait to abstract the state requirement for the `Db` extractor.
609/// This breaks the circular dependency between the database extractor
610/// and the central `AppState`.
611pub trait DbState {
612 /// Returns the database connection pool, if configured.
613 fn pool(&self) -> Option<&Pool<RuntimeConnection>>;
614
615 /// Returns the metrics collector, if configured.
616 fn metrics(&self) -> Option<&crate::middleware::MetricsCollector> {
617 None
618 }
619
620 /// Returns the read/replica connection pool, if configured.
621 fn replica_pool(&self) -> Option<&Pool<RuntimeConnection>> {
622 None
623 }
624
625 /// Returns the pool used for read-only work.
626 ///
627 /// Defaults to the replica role when present, otherwise the primary role.
628 fn read_pool(&self) -> Option<&Pool<RuntimeConnection>> {
629 self.replica_pool().or_else(|| self.pool())
630 }
631
632 /// Returns the configured shard set, when `[[database.shards]]`
633 /// entries exist. Defaults to `None` so unsharded states need no
634 /// changes.
635 fn shards(&self) -> Option<&crate::sharding::ShardSet> {
636 None
637 }
638
639 /// Returns any registered database connection checkout interceptors.
640 fn db_interceptors(
641 &self,
642 ) -> Vec<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>> {
643 Vec::new()
644 }
645 /// Returns the global statement timeout, if configured.
646 fn statement_timeout(&self) -> Option<std::time::Duration> {
647 None
648 }
649
650 /// Returns the slow query threshold.
651 fn slow_query_threshold(&self) -> std::time::Duration {
652 std::time::Duration::from_millis(500)
653 }
654}
655
656// ── SQL telemetry helpers ─────────────────────────────────────────────────────
657
658/// Scrub a SQL string to remove literal parameter values.
659///
660/// Replaces values with `?` placeholders to prevent PII leakage in
661/// slow-query logs while still surfacing the query shape for performance
662/// analysis.
663///
664/// Rules:
665/// - Single-quoted string literals `'...'` → `'?'`
666/// - Unquoted integer/float literals → `?`
667/// - Postgres `$N` positional parameters are left untouched
668///
669/// # Examples
670///
671/// ```
672/// use autumn_web::db::scrub_sql;
673///
674/// assert_eq!(scrub_sql("SELECT * FROM users WHERE name = 'Alice'"),
675/// "SELECT * FROM users WHERE name = '?'");
676/// assert_eq!(scrub_sql("SELECT * FROM orders WHERE id = 42"),
677/// "SELECT * FROM orders WHERE id = ?");
678/// assert_eq!(scrub_sql("SELECT * FROM t WHERE x = $1"),
679/// "SELECT * FROM t WHERE x = $1");
680/// ```
681/// Consumes the body of an E-string escape literal and its closing `'`.
682///
683/// Called after the opening `'` has already been consumed. Handles
684/// `\'` backslash-escaped quotes so they do not prematurely close the string.
685fn consume_estring_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
686 loop {
687 match chars.next() {
688 None => break,
689 Some('\'') => {
690 if chars.peek() == Some(&'\'') {
691 chars.next(); // consume the doubled quote
692 } else {
693 break;
694 }
695 }
696 Some('\\') => {
697 chars.next(); // skip the character after the backslash
698 }
699 Some(_) => {}
700 }
701 }
702}
703
704/// Consumes the body of a dollar-quoted string and its closing `$tag$`.
705///
706/// Called after the opening `$tag$` delimiter has already been consumed.
707/// Uses a simple sliding-window match — sufficient for valid SQL.
708fn consume_dollar_quoted_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, tag: &str) {
709 let closing: Vec<char> = format!("${tag}$").chars().collect();
710 let clen = closing.len();
711 let mut match_count = 0usize;
712 for sc in chars.by_ref() {
713 if sc == closing[match_count] {
714 match_count += 1;
715 if match_count == clen {
716 break; // Found the closing delimiter.
717 }
718 } else {
719 match_count = 0;
720 // The current char may start a new partial match.
721 if sc == closing[0] {
722 match_count = 1;
723 }
724 }
725 }
726}
727
728/// Returns true for every char that can legally precede a bare numeric
729/// literal in SQL — whitespace, comparison, arithmetic, and structural chars.
730#[inline]
731const fn is_separator(c: char) -> bool {
732 matches!(
733 c,
734 ' ' | '\t' | '\n' // whitespace
735 | '=' | '<' | '>' // comparison
736 | '!' | '+' | '-' // arithmetic / negation (signed literals)
737 | '*' | '/' | '%' // arithmetic operators
738 | '(' | ',' // structure
739 )
740}
741
742#[must_use]
743pub fn scrub_sql(sql: &str) -> String {
744 let mut out = String::with_capacity(sql.len());
745 // Tracks whether the last character written was a separator, so a digit
746 // at the current position starts a standalone literal rather than being
747 // part of an identifier like `table1` or `col2`.
748 let mut prev_is_sep = true; // treat start-of-input as a separator boundary
749
750 let mut chars = sql.chars().peekable();
751
752 while let Some(c) = chars.next() {
753 // ── E-string literal E'...' / e'...' (backslash-escape aware) ──
754 // Must be checked before the single-quote handler so we consume the
755 // `E` prefix and don't leave it in the fingerprint.
756 if (c == 'E' || c == 'e') && chars.peek() == Some(&'\'') {
757 chars.next(); // consume the opening '
758 out.push_str("'?'");
759 prev_is_sep = false;
760 consume_estring_body(&mut chars);
761 continue;
762 }
763
764 // ── Single-quoted string literal ─────────────────────────────────
765 if c == '\'' {
766 out.push_str("'?'");
767 prev_is_sep = false;
768 loop {
769 match chars.next() {
770 None => break,
771 Some('\'') => {
772 if chars.peek() == Some(&'\'') {
773 // Escaped quote ('') — consume both, stay inside string
774 chars.next();
775 } else {
776 // Closing quote
777 break;
778 }
779 }
780 Some(_) => {}
781 }
782 }
783 continue;
784 }
785
786 // ── Dollar sign: positional parameter or dollar-quoted string ─────
787 if c == '$' {
788 let next_ch = chars.peek().copied();
789
790 // Positional parameter $N — pass through verbatim.
791 if next_ch.is_some_and(|nc| nc.is_ascii_digit()) {
792 out.push('$');
793 prev_is_sep = false;
794 while chars.peek().is_some_and(char::is_ascii_digit) {
795 if let Some(d) = chars.next() {
796 out.push(d);
797 }
798 }
799 continue;
800 }
801
802 // Dollar-quoted string: $$ (anonymous) or $tag$ (tagged).
803 // Collect the optional tag, looking for the second `$`.
804 let mut tag = String::new();
805 let mut found_closing_dollar = false;
806
807 if next_ch == Some('$') {
808 // Anonymous $$: consume the second `$`.
809 chars.next();
810 found_closing_dollar = true;
811 } else if next_ch.is_some_and(|nc| nc.is_alphabetic() || nc == '_') {
812 // Accumulate tag chars until we hit `$` or a non-identifier char.
813 while let Some(&tc) = chars.peek() {
814 if tc == '$' {
815 chars.next(); // consume the closing `$` of the opening tag
816 found_closing_dollar = true;
817 break;
818 } else if tc.is_alphanumeric() || tc == '_' {
819 tag.push(tc);
820 chars.next();
821 } else {
822 // Not a valid tag character — not a dollar-quoted string.
823 break;
824 }
825 }
826 }
827
828 if found_closing_dollar {
829 out.push_str("'?'");
830 prev_is_sep = false;
831 consume_dollar_quoted_body(&mut chars, &tag);
832 } else {
833 // Not a recognisable dollar form — emit $ and any partial tag.
834 out.push('$');
835 out.push_str(&tag);
836 prev_is_sep = false;
837 }
838 continue;
839 }
840
841 // ── Unquoted numeric literal ──────────────────────────────────────
842 // Only scrub when preceded by a separator to avoid stomping on
843 // identifiers like `table1`, `col2`, or `alias99`.
844 let is_leading_dot =
845 c == '.' && prev_is_sep && chars.peek().is_some_and(char::is_ascii_digit);
846 if (c.is_ascii_digit() && prev_is_sep) || is_leading_dot {
847 out.push('?');
848 if is_leading_dot {
849 chars.next(); // consume the leading dot
850 }
851 // Consume integer/decimal digits, underscores, and dots.
852 while chars
853 .peek()
854 .is_some_and(|d| d.is_ascii_digit() || *d == '.' || *d == '_')
855 {
856 chars.next();
857 }
858 // Consume optional scientific-notation exponent: e/E [+/-] <digits>.
859 if chars.peek().is_some_and(|e| *e == 'e' || *e == 'E') {
860 chars.next(); // consume 'e'/'E'
861 if chars.peek().is_some_and(|s| *s == '+' || *s == '-') {
862 chars.next(); // consume optional sign
863 }
864 while chars.peek().is_some_and(char::is_ascii_digit) {
865 chars.next();
866 }
867 }
868 prev_is_sep = false;
869 continue;
870 }
871
872 // ── Regular character ─────────────────────────────────────────────
873 out.push(c);
874 prev_is_sep = is_separator(c);
875 }
876
877 out
878}
879
880/// Instrument a database query: time it, log slow queries with a scrubbed SQL
881/// fingerprint, record metrics, and map Postgres `57014` (statement timeout)
882/// to [`AutumnError::query_timeout`].
883///
884/// # Parameters
885/// - `sql`: The raw SQL string for slow-query fingerprinting (scrubbed before logging).
886/// - `route_key`: Label string used for metrics, e.g. `"GET /users"`.
887/// - `slow_threshold`: Queries taking longer than this emit a `WARN` log.
888/// - `metrics`: The [`crate::middleware::MetricsCollector`] to record into.
889/// - `query`: The async closure that actually executes the query.
890///
891/// # Returns
892/// The result of `query()`, with Postgres `57014` mapped to
893/// [`AutumnError::query_timeout`].
894///
895/// # Errors
896/// Returns [`AutumnError`] from the underlying query, or [`AutumnError::query_timeout`]
897/// when Postgres cancels the statement due to `statement_timeout`.
898pub async fn run_instrumented<F, Fut, T>(
899 sql: &str,
900 route_key: &str,
901 slow_threshold: std::time::Duration,
902 metrics: &crate::middleware::metrics::MetricsCollector,
903 query: F,
904) -> Result<T, AutumnError>
905where
906 F: FnOnce() -> Fut,
907 Fut: std::future::Future<Output = Result<T, diesel::result::Error>>,
908{
909 let start = std::time::Instant::now();
910 let result = query().await;
911 let elapsed = start.elapsed();
912 let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
913
914 // Record metrics regardless of success/failure
915 let verb = sql.split_whitespace().next().unwrap_or("?");
916 let metric_key = format!("{route_key} {verb}");
917 metrics.record_db_query(&metric_key, elapsed_ms);
918 // NOTE: deliberately *not* recorded into the per-request Server-Timing
919 // accumulator. The connection-level `RequestQueryTimer` instrumentation
920 // (installed at `Db::checkout`) already brackets every executed statement
921 // — including any query run through this helper — so recording here too
922 // would double-count it in `db;dur` and `desc="N queries"`.
923
924 // Log slow queries with scrubbed SQL
925 if elapsed >= slow_threshold {
926 let fingerprint = scrub_sql(sql);
927 tracing::warn!(
928 route = %route_key,
929 sql = %fingerprint,
930 duration_ms = elapsed_ms,
931 "slow database query"
932 );
933 }
934
935 // Map result — translate Postgres 57014 to query_timeout
936 result.map_err(|db_err| {
937 if is_query_canceled(&db_err) {
938 tracing::warn!(
939 route = %route_key,
940 duration_ms = elapsed_ms,
941 "database query cancelled: statement_timeout exceeded"
942 );
943 AutumnError::query_timeout(format!(
944 "Database query timed out after {elapsed_ms}ms (statement_timeout exceeded)"
945 ))
946 } else {
947 AutumnError::from(db_err)
948 }
949 })
950}
951
952/// Walk `err`'s source chain looking for a `tokio_postgres` SQLSTATE matching
953/// `predicate`, downcasting each link to [`tokio_postgres::Error`] and
954/// [`tokio_postgres::error::DbError`] in turn. Shared by [`is_query_canceled`]
955/// and [`is_retryable_txn_error`] — both need the same downcast-through-the-
956/// chain strategy, only with a different SQLSTATE to look for.
957fn source_chain_has_sqlstate(
958 err: &(dyn std::error::Error + 'static),
959 predicate: impl Fn(&tokio_postgres::error::SqlState) -> bool,
960) -> bool {
961 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
962 while let Some(e) = source {
963 if e.downcast_ref::<tokio_postgres::Error>()
964 .and_then(tokio_postgres::Error::code)
965 .is_some_and(&predicate)
966 {
967 return true;
968 }
969 if e.downcast_ref::<tokio_postgres::error::DbError>()
970 .is_some_and(|db_err| predicate(db_err.code()))
971 {
972 return true;
973 }
974 source = e.source();
975 }
976 false
977}
978
979/// Check whether a Diesel error wraps a Postgres `57014` `query_canceled` error.
980///
981/// Prefers downcasting through the source chain to find a
982/// [`tokio_postgres::Error`] and checking its SQL state code directly,
983/// which is more robust than string-matching error messages.
984fn is_query_canceled(err: &diesel::result::Error) -> bool {
985 // Robust string-matching first to catch wrapped/unwrapped representations
986 let err_str = err.to_string().to_lowercase();
987 if err_str.contains("57014")
988 || err_str.contains("query_canceled")
989 || err_str.contains("canceling statement due to statement timeout")
990 || err_str.contains("statement timeout")
991 || err_str.contains("query canceled")
992 {
993 return true;
994 }
995
996 source_chain_has_sqlstate(err, |state| {
997 *state == tokio_postgres::error::SqlState::QUERY_CANCELED
998 })
999}
1000
1001/// Error type for pool creation failures.
1002///
1003/// Returned by [`create_pool`] (and the other topology builders) when a pool
1004/// cannot be constructed. Historically this was a bare alias for deadpool's
1005/// `BuildError`; it now also carries the boot-time refusal emitted for a
1006/// recognized-but-not-yet-wired backend (`SQLite`, issue #1614), so callers fail
1007/// fast at pool construction with an actionable message instead of at the first
1008/// query. The [`Build`](PoolError::Build) variant delegates its `Display` to the
1009/// underlying `BuildError`, so the Postgres path's error text is unchanged.
1010#[derive(Debug, thiserror::Error)]
1011#[non_exhaustive]
1012pub enum PoolError {
1013 /// The underlying deadpool builder failed (e.g., timeouts configured
1014 /// without a runtime, or an invalid max-size configuration).
1015 #[error(transparent)]
1016 Build(#[from] diesel_async::pooled_connection::deadpool::BuildError),
1017
1018 /// A database backend that Autumn recognizes but whose runtime pool is not
1019 /// available in this build was configured. See
1020 /// [`DatabaseBackend`](crate::config::DatabaseBackend).
1021 #[error("{0}")]
1022 UnsupportedBackend(String),
1023}
1024
1025/// Primary plus optional read-replica database pools.
1026#[derive(Clone)]
1027pub struct DatabaseTopology {
1028 primary: Pool<RuntimeConnection>,
1029 replica: Option<Pool<RuntimeConnection>>,
1030 /// Connection URL to target with startup migrations, when the provider
1031 /// resolved one at runtime that the static config doesn't carry (e.g. the
1032 /// managed-Postgres provider whose socket URL is only known after boot).
1033 /// Scoping it to the topology keeps it per-app instead of a process global.
1034 migration_url: Option<String>,
1035}
1036
1037impl DatabaseTopology {
1038 /// Build a topology from explicit primary and optional replica pools.
1039 ///
1040 /// This is useful for custom [`DatabasePoolProvider`] implementations that
1041 /// need to create or decorate both roles themselves.
1042 #[must_use]
1043 pub const fn from_pools(
1044 primary: Pool<RuntimeConnection>,
1045 replica: Option<Pool<RuntimeConnection>>,
1046 ) -> Self {
1047 Self {
1048 primary,
1049 replica,
1050 migration_url: None,
1051 }
1052 }
1053
1054 /// Build a topology from a primary pool only.
1055 #[must_use]
1056 pub const fn primary_only(primary: Pool<RuntimeConnection>) -> Self {
1057 Self {
1058 primary,
1059 replica: None,
1060 migration_url: None,
1061 }
1062 }
1063
1064 /// Attach a runtime-resolved migration URL (see [`Self::migration_url`]).
1065 ///
1066 /// Providers whose primary URL isn't present in the static config — such as
1067 /// the managed-Postgres provider — call this so startup migrations target
1068 /// the pool that was actually built, without publishing the URL to a
1069 /// process-global shared across every app instance.
1070 #[must_use]
1071 pub fn with_migration_url(mut self, url: Option<String>) -> Self {
1072 self.migration_url = url;
1073 self
1074 }
1075
1076 /// The runtime-resolved migration URL, if the provider supplied one.
1077 #[must_use]
1078 pub fn migration_url(&self) -> Option<&str> {
1079 self.migration_url.as_deref()
1080 }
1081
1082 /// Primary/write role pool.
1083 #[must_use]
1084 pub const fn primary(&self) -> &Pool<RuntimeConnection> {
1085 &self.primary
1086 }
1087
1088 /// Optional read/replica role pool.
1089 #[must_use]
1090 pub const fn replica(&self) -> Option<&Pool<RuntimeConnection>> {
1091 self.replica.as_ref()
1092 }
1093
1094 /// Pool used for read-only work.
1095 #[must_use]
1096 pub fn read(&self) -> &Pool<RuntimeConnection> {
1097 self.replica.as_ref().unwrap_or(&self.primary)
1098 }
1099}
1100
1101fn build_pool(
1102 url: &str,
1103 pool_size: usize,
1104 connect_timeout_secs: u64,
1105) -> Result<Pool<RuntimeConnection>, PoolError> {
1106 // Under the `sqlite` feature `RuntimeConnection` is a SQLite connection, so
1107 // the pool must be built over `SyncConnectionWrapper<SqliteConnection>`
1108 // rather than the Postgres manager below. Route to the dedicated SQLite
1109 // builder (PR2, issue #1614). This block is the whole function body under
1110 // the feature (the Postgres arms below are cfg'd out), so it is the tail
1111 // expression — no `return` needed.
1112 #[cfg(feature = "sqlite")]
1113 {
1114 build_sqlite_pool(url, pool_size, connect_timeout_secs)
1115 }
1116
1117 // Default (Postgres) build. A SQLite target is recognized here but its
1118 // runtime pool only exists in a `--features sqlite` build — the pool below
1119 // is a Postgres pool (`Pool<AsyncPgConnection>`). Refuse at pool-build
1120 // (boot) time with an actionable message so a SQLite misconfiguration fails
1121 // fast rather than reaching a confusing first-query failure. A Postgres
1122 // target skips this branch entirely.
1123 #[cfg(not(feature = "sqlite"))]
1124 if crate::config::DatabaseBackend::detect(url) == Some(crate::config::DatabaseBackend::Sqlite) {
1125 return Err(PoolError::UnsupportedBackend(format!(
1126 "SQLite is a recognized database backend but its runtime pool is only available in \
1127 a build of autumn-web compiled with `--features sqlite`; this is a default \
1128 (Postgres) build (target: {url:?})"
1129 )));
1130 }
1131
1132 #[cfg(not(feature = "sqlite"))]
1133 {
1134 let timeout = Duration::from_secs(connect_timeout_secs);
1135 // When the URL's `sslmode` asks for TLS, plug a rustls-backed connector
1136 // into the pool via a custom setup callback — diesel-async's default
1137 // establish path hardcodes `NoTls`, which cannot satisfy
1138 // `sslmode=require` at all. `sslmode` absent/`disable`/`prefer` keeps the
1139 // default (NoTls) path, so existing configurations behave exactly as
1140 // before. See [`tls`] for the full posture table.
1141 let manager = match tls::TlsPosture::from_database_url(url) {
1142 tls::TlsPosture::Off => AsyncDieselConnectionManager::<AsyncPgConnection>::new(url),
1143 posture => {
1144 let mut config =
1145 diesel_async::pooled_connection::ManagerConfig::<AsyncPgConnection>::default();
1146 config.custom_setup = tls::setup_callback(posture);
1147 AsyncDieselConnectionManager::<AsyncPgConnection>::new_with_config(url, config)
1148 }
1149 };
1150 Ok(Pool::builder(manager)
1151 .max_size(pool_size.max(1))
1152 .wait_timeout(Some(timeout))
1153 .create_timeout(Some(timeout))
1154 .runtime(deadpool::Runtime::Tokio1)
1155 .build()?)
1156 }
1157}
1158
1159/// Normalize a configured `SQLite` target into the filename token diesel's
1160/// `SqliteConnection` understands.
1161///
1162/// diesel's `SQLite` backend passes the string straight to `sqlite3_open`, which
1163/// understands a filesystem path, a `file:` URI, or the special `:memory:`
1164/// token — but **not** a `sqlite:` URL scheme. Strip the recognized `SQLite` URL
1165/// spellings down to that: `sqlite::memory:`, `sqlite://:memory:`, and an empty
1166/// `sqlite://` all become an in-memory database; `sqlite:///path` /
1167/// `sqlite://path` / `sqlite:path` reduce to their path; a `file:` URI or a
1168/// bare path passes through unchanged.
1169#[cfg(feature = "sqlite")]
1170fn normalize_sqlite_target(url: &str) -> String {
1171 if url.starts_with("file:") {
1172 return url.to_owned();
1173 }
1174 let rest = url
1175 .strip_prefix("sqlite://")
1176 .or_else(|| url.strip_prefix("sqlite:"))
1177 .unwrap_or(url);
1178 if rest.is_empty() || rest == ":memory:" {
1179 return String::from(":memory:");
1180 }
1181 rest.to_owned()
1182}
1183
1184/// Whether a normalized `SQLite` target names a **private** in-memory database
1185/// (each such connection is its own private database, so the pool must be
1186/// single-slot to stay consistent — see [`build_sqlite_pool`]).
1187///
1188/// Covers every in-memory spelling `SQLite` accepts through this pool: the bare
1189/// `:memory:` token, the `file:` URI form `file::memory:` (with or without a
1190/// query string — addresses Codex P1: the multi-slot default would otherwise
1191/// hand out connections that each see a different, empty in-memory database),
1192/// and any `file:` URI that asks for `mode=memory`.
1193///
1194/// A **shared-cache** in-memory database (`cache=shared`) is the deliberate
1195/// exception: it IS shareable across the pool's connections within one process,
1196/// so it must NOT be forced single-slot and returns `false` here.
1197#[cfg(feature = "sqlite")]
1198fn sqlite_target_is_memory(target: &str) -> bool {
1199 if target.contains("cache=shared") {
1200 return false;
1201 }
1202 target == ":memory:"
1203 || target == "file::memory:"
1204 || target.starts_with("file::memory:?")
1205 || target.contains("mode=memory")
1206}
1207
1208/// Whether a `SQLite` database URL (any accepted spelling) resolves to **any**
1209/// in-memory target — the private spellings (`sqlite::memory:` / `:memory:` /
1210/// `file::memory:`) AND the shared-cache in-memory form
1211/// (`file::memory:?cache=shared`, `file:app?mode=memory&cache=shared`).
1212///
1213/// This is deliberately broader than [`sqlite_target_is_memory`] (the pool
1214/// *sizing* predicate): it does NOT exempt `cache=shared`. It is the predicate
1215/// the startup-migration reject uses, because **no** in-memory target — private
1216/// or shared-cache — can retain a registered migration for the runtime pool. The
1217/// migration runs on a transient synchronous connection; `SQLite` destroys a
1218/// shared in-memory database the moment its *last* connection closes, and the
1219/// runtime deadpool is created lazily (it may not have checked out a connection
1220/// yet), so the pool's first checkout opens a fresh, empty in-memory database and
1221/// every DB-backed request then 500s with "no such table". Only a **file-backed**
1222/// database survives the migration connection closing, so that is the sole
1223/// supported remedy.
1224///
1225/// Pool *sizing* deliberately keeps using [`sqlite_target_is_memory`] instead:
1226/// a shared-cache in-memory database IS shareable across the pool's connections
1227/// within one live process, so it must NOT be forced single-slot. The two
1228/// predicates answer different questions ("share across the pool?" vs. "survive
1229/// the migration connection closing?") and must not be conflated.
1230#[cfg(feature = "sqlite")]
1231pub(crate) fn sqlite_target_is_any_in_memory(url: &str) -> bool {
1232 let target = normalize_sqlite_target(url);
1233 target == ":memory:"
1234 || target == "file::memory:"
1235 || target.starts_with("file::memory:?")
1236 || target.contains("mode=memory")
1237}
1238
1239/// Whether a normalized `SQLite` target names a **read-only** database via its
1240/// URI query string (`mode=ro`, or `immutable=1`/`immutable=true`).
1241///
1242/// A read-only target rejects any write, so the per-connection setup batch must
1243/// skip the write-affecting pragmas (`journal_mode = WAL`) that would otherwise
1244/// fail with "attempt to write a readonly database" and take the whole pool 503
1245/// (see [`build_sqlite_pool`]). The query string is parsed key-by-key
1246/// (case-insensitive keys and values) rather than substring-matched, so an
1247/// unrelated value that merely contains `ro` never trips it.
1248///
1249/// A plain file path, an in-memory target (`mode=memory`, which is not
1250/// read-only), and a `cache=shared` target all return `false`.
1251#[cfg(feature = "sqlite")]
1252fn sqlite_target_is_read_only(target: &str) -> bool {
1253 let Some((_, query)) = target.split_once('?') else {
1254 return false;
1255 };
1256 for pair in query.split('&') {
1257 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
1258 let (key, value) = (key.trim(), value.trim());
1259 if key.eq_ignore_ascii_case("mode") && value.eq_ignore_ascii_case("ro") {
1260 return true;
1261 }
1262 if key.eq_ignore_ascii_case("immutable")
1263 && (value == "1" || value.eq_ignore_ascii_case("true"))
1264 {
1265 return true;
1266 }
1267 }
1268 false
1269}
1270
1271/// Build a deadpool pool over `SyncConnectionWrapper<SqliteConnection>` for a
1272/// `SQLite` target (issue #1614, PR2).
1273///
1274/// `SyncConnectionWrapper` runs synchronous diesel `SqliteConnection` calls on
1275/// Tokio's blocking pool, so this integrates with the async runtime like the
1276/// Postgres path. Pool sizing is deliberately conservative: `SQLite` is
1277/// single-writer, so a large pool only multiplies `SQLITE_BUSY` contention, and
1278/// an in-memory database is **private per connection** — a multi-slot pool over
1279/// `:memory:` would hand out connections that each see a different, empty
1280/// database (so migrations applied on one would be invisible on another).
1281/// In-memory targets are therefore forced to a single slot; file targets
1282/// respect the configured size (still small by convention).
1283#[cfg(feature = "sqlite")]
1284fn build_sqlite_pool(
1285 url: &str,
1286 pool_size: usize,
1287 connect_timeout_secs: u64,
1288) -> Result<Pool<RuntimeConnection>, PoolError> {
1289 // Under the `sqlite` feature the runtime targets SQLite. A Postgres URL here
1290 // is a misconfiguration — refuse with an actionable message rather than
1291 // trying to open a file literally named "postgres://…".
1292 if crate::config::DatabaseBackend::detect(url) == Some(crate::config::DatabaseBackend::Postgres)
1293 {
1294 return Err(PoolError::UnsupportedBackend(format!(
1295 "this build of autumn-web targets SQLite (compiled with `--features sqlite`) but the \
1296 configured database URL is a Postgres target; configure a `sqlite:` URL instead \
1297 (target: {url:?})"
1298 )));
1299 }
1300
1301 let timeout = Duration::from_secs(connect_timeout_secs);
1302 let target = normalize_sqlite_target(url);
1303 let max_size = if sqlite_target_is_memory(&target) {
1304 1
1305 } else {
1306 pool_size.max(1)
1307 };
1308 // SQLite starts every connection with `foreign_keys` OFF, so a bare manager
1309 // would hand out pooled connections that silently ignore `REFERENCES`
1310 // constraints — orphan rows and referential-integrity violations become
1311 // possible for the whole app (addresses Codex P1). It also uses a default
1312 // busy handler that returns `SQLITE_BUSY` *immediately* when another pooled
1313 // connection holds the single writer lock, so ordinary overlapping writes on
1314 // a >1 slot file pool fail as 5xx instead of waiting briefly for the lock to
1315 // clear (addresses Codex P1). Install both pragmas — plus a deliberate WAL
1316 // journal mode with `synchronous = NORMAL` for better write concurrency —
1317 // during EVERY pooled connection's setup, mirroring how the sync store
1318 // configures its own SQLite connection (see `crate::sync::store`:
1319 // `busy_timeout = 5000`, `journal_mode = WAL`, `synchronous = NORMAL`,
1320 // `foreign_keys = ON`). `busy_timeout` is set FIRST so everything after it
1321 // (and every later query) queues on the timeout instead of failing on a
1322 // held lock; `journal_mode = WAL` is a harmless no-op for a pure `:memory:`
1323 // database. A `custom_setup` callback on the manager runs once per
1324 // newly-created connection, which is exactly the per-connection hook we need
1325 // (the same mechanism the Postgres path uses to install TLS).
1326 //
1327 // A **read-only** URI target (`mode=ro` / `immutable`, e.g.
1328 // `sqlite://file:/srv/reference.db?mode=ro`) is the exception: `journal_mode
1329 // = WAL` writes to the database (it rewrites the file header and creates the
1330 // `-wal`/`-shm` sidecars), so it fails with "attempt to write a readonly
1331 // database" — `custom_setup` would propagate that as a connection-setup
1332 // error and the pool could not service even read-only queries (`Db` routes
1333 // 503). For such targets we install only the non-writing per-connection
1334 // pragmas (`busy_timeout`, `foreign_keys`) and skip the write-affecting
1335 // ones, so a read-only pool builds and serves reads. In-memory targets are
1336 // NOT read-only and keep the full batch.
1337 let mut config = diesel_async::pooled_connection::ManagerConfig::<RuntimeConnection>::default();
1338 config.custom_setup = Box::new(|url: &str| {
1339 use diesel_async::{AsyncConnection as _, SimpleAsyncConnection as _};
1340 let url = url.to_owned();
1341 async move {
1342 let mut conn = RuntimeConnection::establish(&url).await?;
1343 let pragmas = if sqlite_target_is_read_only(&url) {
1344 // Non-writing pragmas only — WAL + synchronous would write and
1345 // fail on a read-only database.
1346 "PRAGMA busy_timeout = 5000; \
1347 PRAGMA foreign_keys = ON;"
1348 } else {
1349 "PRAGMA busy_timeout = 5000; \
1350 PRAGMA journal_mode = WAL; \
1351 PRAGMA synchronous = NORMAL; \
1352 PRAGMA foreign_keys = ON;"
1353 };
1354 conn.batch_execute(pragmas)
1355 .await
1356 .map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
1357 // #1910 FTS5 capability probe. Searchable repositories emit FTS5
1358 // virtual tables + `bm25()` ranking, and the `AddSearch` migration's
1359 // `CREATE VIRTUAL TABLE ... USING fts5(...)` is the hard stop that
1360 // fails loudly at boot if the linked SQLite lacks FTS5. Probe it here
1361 // (create + drop a throwaway FTS5 table in the always-writable `temp`
1362 // database — harmless on read-only main targets) so the failure is a
1363 // clear, actionable diagnostic naming FTS5 and the fix, instead of a
1364 // bare "no such module: fts5" surfacing from a migration. There is NO
1365 // silent fallback to LIKE — full-text search requires FTS5.
1366 if let Err(e) = conn
1367 .batch_execute(
1368 "CREATE VIRTUAL TABLE temp.__autumn_fts5_probe USING fts5(x); \
1369 DROP TABLE temp.__autumn_fts5_probe;",
1370 )
1371 .await
1372 {
1373 return Err(diesel::ConnectionError::CouldntSetupConfiguration(
1374 diesel::result::Error::QueryBuilderError(
1375 format!(
1376 "SQLite FTS5 is not available in the linked SQLite library, but \
1377 autumn-web full-text search (searchable repositories / the \
1378 `--search` scaffold, issue #1910) requires it. Build with the \
1379 bundled, FTS5-enabled SQLite by enabling autumn-web's `sqlite` \
1380 feature (it turns on `libsqlite3-sys/bundled`, whose amalgamation \
1381 defines SQLITE_ENABLE_FTS5). Underlying probe error: {e}"
1382 )
1383 .into(),
1384 ),
1385 ));
1386 }
1387 Ok(conn)
1388 }
1389 .boxed()
1390 });
1391 let manager =
1392 AsyncDieselConnectionManager::<RuntimeConnection>::new_with_config(target, config);
1393 Ok(Pool::builder(manager)
1394 .max_size(max_size)
1395 .wait_timeout(Some(timeout))
1396 .create_timeout(Some(timeout))
1397 .runtime(deadpool::Runtime::Tokio1)
1398 .build()?)
1399}
1400
1401/// Create a connection pool from the database configuration.
1402///
1403/// Returns `Ok(None)` if no primary database URL is configured
1404/// (`database.primary_url` and the legacy `database.url` are absent or `null`
1405/// in `autumn.toml`).
1406///
1407/// # Errors
1408///
1409/// Returns [`PoolError`] if the pool cannot be built (e.g., invalid
1410/// max-size configuration).
1411pub fn create_pool(config: &DatabaseConfig) -> Result<Option<Pool<RuntimeConnection>>, PoolError> {
1412 let Some(url) = config.effective_primary_url() else {
1413 return Ok(None);
1414 };
1415
1416 #[cfg(feature = "sqlite")]
1417 reject_sqlite_statement_timeout(config.statement_timeout)?;
1418
1419 let pool = build_pool(
1420 url,
1421 config.effective_primary_pool_size(),
1422 config.connect_timeout_secs,
1423 )?;
1424
1425 Ok(Some(pool))
1426}
1427
1428/// Reject a configured `database.statement_timeout` under the `SQLite` backend.
1429///
1430/// `SQLite` cannot enforce a per-statement wall-clock timeout through the async
1431/// connection wrapper: diesel's `SqliteConnection` exposes no
1432/// interrupt/progress-handler hook (nor the raw `sqlite3` handle) through
1433/// `SyncConnectionWrapper`, so a runaway query cannot be aborted mid-flight.
1434/// Rather than silently ignore a correctness guarantee we cannot honor, this
1435/// fails the boot fast with an actionable message (fail-closed, issue #1996).
1436///
1437/// A `None` or zero timeout (the default) asks for no guarantee and boots
1438/// cleanly, so ordinary `SQLite` apps are unaffected — only an operator who
1439/// explicitly configured a timeout we cannot meet is stopped. This is the single
1440/// choke point for every pool-construction entry point: the built-in
1441/// `create_pool`, `create_topology`, and `create_shard_topology` factories all
1442/// call it before the pool is built, and `setup_database` calls it once more at
1443/// the pool-provider dispatch boundary so a custom
1444/// [`DatabasePoolProvider`](crate::db::DatabasePoolProvider) — whose
1445/// `create_topology`/`create_shard_topology` need not route through those
1446/// factories — cannot bypass the guard. No configured timeout can reach a live
1447/// `SQLite` pool. `busy_timeout` still bounds lock waits; a real per-statement
1448/// timeout is tracked by #1996/#1910.
1449///
1450/// # Errors
1451///
1452/// Returns [`PoolError::UnsupportedBackend`] when `statement_timeout` is `Some`
1453/// and non-zero.
1454#[cfg(feature = "sqlite")]
1455pub(crate) fn reject_sqlite_statement_timeout(
1456 statement_timeout: Option<Duration>,
1457) -> Result<(), PoolError> {
1458 let Some(timeout) = statement_timeout.filter(|t| !t.is_zero()) else {
1459 return Ok(());
1460 };
1461 Err(PoolError::UnsupportedBackend(format!(
1462 "SQLite backend cannot enforce database.statement_timeout ({}ms): diesel's \
1463 SqliteConnection exposes no interrupt/progress-handler hook through the async \
1464 connection wrapper, so a runaway query cannot be aborted. Unset \
1465 database.statement_timeout for the SQLite backend (busy_timeout already bounds \
1466 lock waits), or run on Postgres. Tracking issue: #1996/#1910.",
1467 timeout.as_millis()
1468 )))
1469}
1470
1471/// Reject a `SQLite` `replica_url` that cannot act as a real read replica for the
1472/// given `primary_url`.
1473///
1474/// Under the `sqlite` feature a configured replica cannot replicate: `SQLite` has
1475/// no primary/replica replication in this pool architecture. Two single-slot
1476/// `:memory:` pools are two *private* empty databases, and a distinct replica
1477/// *file* has nothing replicating into it, so read-routed queries would hit an
1478/// empty replica after writes and schema setup went to the primary. Reject an
1479/// in-memory or distinct-file replica with an actionable boot error (addresses
1480/// Codex P2). A replica that normalizes to the SAME file as the primary is the
1481/// same database — harmless — so it is allowed. `normalize_sqlite_target` is
1482/// reused so "same file" matches how the pool actually opens the file, and
1483/// `sqlite_target_is_memory` so pool sizing and this guard agree on what
1484/// "in-memory" means. Shared by the control-database and per-shard topologies so
1485/// the two rejection rules cannot drift.
1486#[cfg(feature = "sqlite")]
1487fn reject_unusable_sqlite_replica(primary_url: &str, replica_url: &str) -> Result<(), PoolError> {
1488 let replica_target = normalize_sqlite_target(replica_url);
1489 let primary_target = normalize_sqlite_target(primary_url);
1490 if sqlite_target_is_memory(&replica_target) || replica_target != primary_target {
1491 return Err(PoolError::UnsupportedBackend(format!(
1492 "SQLite does not support a separate read replica: replica_url {replica_url:?} \
1493 is in-memory or differs from primary_url {primary_url:?}. Configure only a \
1494 primary, or point the replica at the same database file as the primary."
1495 )));
1496 }
1497 Ok(())
1498}
1499
1500/// Create primary and optional replica pools from the database configuration.
1501///
1502/// Returns `Ok(None)` when neither `database.primary_url` nor the legacy
1503/// `database.url` compatibility field is configured.
1504///
1505/// # Errors
1506///
1507/// Returns [`PoolError`] if either configured role cannot be built.
1508pub fn create_topology(config: &DatabaseConfig) -> Result<Option<DatabaseTopology>, PoolError> {
1509 let Some(primary_url) = config.effective_primary_url() else {
1510 return Ok(None);
1511 };
1512
1513 #[cfg(feature = "sqlite")]
1514 reject_sqlite_statement_timeout(config.statement_timeout)?;
1515
1516 let primary = build_pool(
1517 primary_url,
1518 config.effective_primary_pool_size(),
1519 config.connect_timeout_secs,
1520 )?;
1521
1522 #[cfg(feature = "sqlite")]
1523 if let Some(replica_url) = config.replica_url.as_deref() {
1524 reject_unusable_sqlite_replica(primary_url, replica_url)?;
1525 }
1526
1527 let replica = config
1528 .replica_url
1529 .as_deref()
1530 .map(|url| {
1531 build_pool(
1532 url,
1533 config.effective_replica_pool_size(),
1534 config.connect_timeout_secs,
1535 )
1536 })
1537 .transpose()?;
1538
1539 Ok(Some(DatabaseTopology::from_pools(primary, replica)))
1540}
1541
1542/// Create one shard's primary and optional replica pools, applying the
1543/// shard's pool-size and timeout fallbacks to the `[database]` defaults.
1544///
1545/// # Errors
1546///
1547/// Returns [`PoolError`] if either configured role cannot be built.
1548pub fn create_shard_topology(
1549 shard: &crate::config::ShardConfig,
1550 defaults: &DatabaseConfig,
1551) -> Result<DatabaseTopology, PoolError> {
1552 // A SQLite `[[database.shards]]` deployment reaches this via the ungated
1553 // `sharding::create_shard_set` → `create_shard_topology` path (the
1554 // transactional test-harness variant `create_shard_set_transactional` is
1555 // Postgres-only), so the same fail-closed guard applies per shard. The shard
1556 // inherits the `[database]` `statement_timeout`.
1557 #[cfg(feature = "sqlite")]
1558 reject_sqlite_statement_timeout(defaults.statement_timeout)?;
1559
1560 let primary = build_pool(
1561 &shard.primary_url,
1562 shard.effective_primary_pool_size(defaults),
1563 defaults.connect_timeout_secs,
1564 )?;
1565
1566 // A per-shard `replica_url` is subject to the same SQLite-replica rule as the
1567 // control database: without it a SQLite `[[database.shards]]` entry with a
1568 // distinct or in-memory replica would boot and route reads to an unrelated,
1569 // empty database. Reuse the exact same helper so the two rejections cannot
1570 // drift (addresses Codex P2).
1571 #[cfg(feature = "sqlite")]
1572 if let Some(replica_url) = shard.replica_url.as_deref() {
1573 reject_unusable_sqlite_replica(&shard.primary_url, replica_url)?;
1574 }
1575
1576 let replica = shard
1577 .replica_url
1578 .as_deref()
1579 .map(|url| {
1580 build_pool(
1581 url,
1582 shard.effective_replica_pool_size(defaults),
1583 defaults.connect_timeout_secs,
1584 )
1585 })
1586 .transpose()?;
1587
1588 Ok(DatabaseTopology::from_pools(primary, replica))
1589}
1590
1591// ── Transaction options, retry, and isolation (issue #1202) ──────────────────
1592
1593/// Postgres transaction isolation level, requested per call via [`TxOptions`].
1594///
1595/// `ReadCommitted` is Postgres' default and Autumn's default; the stronger
1596/// levels are opt-in on [`Db::tx_with`]. See the transactions guide for what
1597/// each level buys and costs.
1598#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1599pub enum IsolationLevel {
1600 /// Postgres default. Each statement sees rows committed before it began.
1601 #[default]
1602 ReadCommitted,
1603 /// A snapshot fixed at the first query; no non-repeatable or phantom reads.
1604 RepeatableRead,
1605 /// Full serializability (SSI). Transactions behave as if run one at a time;
1606 /// conflicts surface as `40001` and are retried by [`Db::tx_with`].
1607 Serializable,
1608}
1609
1610impl IsolationLevel {
1611 /// The SQL fragment used in tracing (`db.isolation`).
1612 const fn as_str(self) -> &'static str {
1613 match self {
1614 Self::ReadCommitted => "read_committed",
1615 Self::RepeatableRead => "repeatable_read",
1616 Self::Serializable => "serializable",
1617 }
1618 }
1619}
1620
1621/// Default number of attempts (including the first) for the retrying isolation
1622/// levels. Chosen to match the jobs system's default retry budget.
1623const DEFAULT_TX_MAX_ATTEMPTS: u32 = 5;
1624
1625/// Options for [`Db::tx_with`]: isolation level, access mode, and the automatic
1626/// retry policy for transient serialization failures.
1627///
1628/// [`TxOptions::default`] is byte-for-byte equivalent to today's [`Db::tx`]:
1629/// READ COMMITTED, read-write, a single attempt, and no retry.
1630///
1631/// ```rust
1632/// use autumn_web::db::{TxOptions, IsolationLevel};
1633///
1634/// let opts = TxOptions::serializable().read_only().max_attempts(8);
1635/// assert_eq!(opts.isolation, IsolationLevel::Serializable);
1636/// assert!(opts.read_only);
1637/// assert_eq!(opts.max_attempts, 8);
1638/// ```
1639#[derive(Debug, Clone, Copy)]
1640pub struct TxOptions {
1641 /// Requested isolation level.
1642 pub isolation: IsolationLevel,
1643 /// Run the transaction `READ ONLY`.
1644 pub read_only: bool,
1645 /// Add `DEFERRABLE` (only meaningful for `SERIALIZABLE READ ONLY`).
1646 pub deferrable: bool,
1647 /// Total attempts including the first. `1` disables retry.
1648 pub max_attempts: u32,
1649 /// Backoff before the first retry; doubles each subsequent retry.
1650 pub initial_backoff: Duration,
1651 /// Upper bound on any single backoff delay.
1652 pub max_backoff: Duration,
1653}
1654
1655impl Default for TxOptions {
1656 fn default() -> Self {
1657 Self {
1658 isolation: IsolationLevel::ReadCommitted,
1659 read_only: false,
1660 deferrable: false,
1661 max_attempts: 1,
1662 initial_backoff: Duration::from_millis(5),
1663 max_backoff: Duration::from_millis(500),
1664 }
1665 }
1666}
1667
1668impl TxOptions {
1669 /// READ COMMITTED, no retry — identical to [`Db::tx`].
1670 #[must_use]
1671 pub fn new() -> Self {
1672 Self::default()
1673 }
1674
1675 /// READ COMMITTED (the default), no retry.
1676 #[must_use]
1677 pub fn read_committed() -> Self {
1678 Self {
1679 isolation: IsolationLevel::ReadCommitted,
1680 ..Self::default()
1681 }
1682 }
1683
1684 /// REPEATABLE READ with automatic retry (`serialization_failure` can occur
1685 /// at this level too).
1686 #[must_use]
1687 pub fn repeatable_read() -> Self {
1688 Self {
1689 isolation: IsolationLevel::RepeatableRead,
1690 max_attempts: DEFAULT_TX_MAX_ATTEMPTS,
1691 ..Self::default()
1692 }
1693 }
1694
1695 /// SERIALIZABLE with automatic retry. This is the correctness-critical
1696 /// default: conflicts surface as `40001` and are retried transparently.
1697 #[must_use]
1698 pub fn serializable() -> Self {
1699 Self {
1700 isolation: IsolationLevel::Serializable,
1701 max_attempts: DEFAULT_TX_MAX_ATTEMPTS,
1702 ..Self::default()
1703 }
1704 }
1705
1706 /// Set the isolation level, keeping the other options.
1707 #[must_use]
1708 pub const fn isolation(mut self, level: IsolationLevel) -> Self {
1709 self.isolation = level;
1710 self
1711 }
1712
1713 /// Run the transaction `READ ONLY`.
1714 #[must_use]
1715 pub const fn read_only(mut self) -> Self {
1716 self.read_only = true;
1717 self
1718 }
1719
1720 /// Add `DEFERRABLE` (for `SERIALIZABLE READ ONLY`).
1721 #[must_use]
1722 pub const fn deferrable(mut self) -> Self {
1723 self.deferrable = true;
1724 self
1725 }
1726
1727 /// Set the maximum number of attempts (clamped to at least 1). A value of
1728 /// `1` disables retry.
1729 #[must_use]
1730 pub const fn max_attempts(mut self, attempts: u32) -> Self {
1731 self.max_attempts = if attempts < 1 { 1 } else { attempts };
1732 self
1733 }
1734
1735 /// `max_attempts`, clamped to at least 1.
1736 ///
1737 /// The `max_attempts` field is public (struct-literal update syntax is a
1738 /// supported way to build a `TxOptions`), so a value of `0` can reach the
1739 /// struct without going through [`TxOptions::max_attempts`]'s clamp. The
1740 /// retry loop calls this instead of reading the field directly, so `0`
1741 /// always behaves like `1` (run the closure once, no retry) rather than
1742 /// skipping the closure entirely.
1743 // Only the Postgres retry loop consults this; unused in a `--features
1744 // sqlite` library build (still unit-tested, so keep it compiled).
1745 #[cfg_attr(feature = "sqlite", allow(dead_code))]
1746 const fn effective_max_attempts(&self) -> u32 {
1747 if self.max_attempts < 1 {
1748 1
1749 } else {
1750 self.max_attempts
1751 }
1752 }
1753
1754 /// Set the backoff before the first retry.
1755 #[must_use]
1756 pub const fn initial_backoff(mut self, delay: Duration) -> Self {
1757 self.initial_backoff = delay;
1758 self
1759 }
1760
1761 /// Set the ceiling on any single backoff delay.
1762 #[must_use]
1763 pub const fn max_backoff(mut self, delay: Duration) -> Self {
1764 self.max_backoff = delay;
1765 self
1766 }
1767}
1768
1769/// Whether the retry loop should re-run the closure or return the error.
1770// The whole serialization-failure retry machinery is Postgres-only (see
1771// `Db::tx_with`); these items are unused in a `--features sqlite` library build
1772// but remain unit-tested, so keep them compiled and silence dead-code under the
1773// feature.
1774#[cfg_attr(feature = "sqlite", allow(dead_code))]
1775#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1776enum RetryDecision {
1777 Retry,
1778 Stop,
1779}
1780
1781/// Decide whether a just-failed attempt should be retried.
1782///
1783/// `attempt` is the 1-indexed number of the attempt that just failed. A retry
1784/// happens only when the error is retryable **and** attempts remain.
1785#[cfg_attr(feature = "sqlite", allow(dead_code))]
1786const fn retry_decision(attempt: u32, max_attempts: u32, retryable: bool) -> RetryDecision {
1787 if retryable && attempt < max_attempts {
1788 RetryDecision::Retry
1789 } else {
1790 RetryDecision::Stop
1791 }
1792}
1793
1794/// Deterministic capped exponential backoff base: `initial * 2^(attempt-1)`,
1795/// saturating on overflow and capped at `max`.
1796///
1797/// Mirrors the jobs system's backoff shape (`pg_retry_delay_ms`) plus the cap
1798/// idiom from the migrate startup loop. Kept jitter-free so it is unit-testable.
1799#[cfg_attr(feature = "sqlite", allow(dead_code))]
1800fn retry_backoff_base(initial: Duration, max: Duration, attempt: u32) -> Duration {
1801 // Cap the shift so `2^shift` never overflows and huge attempts saturate.
1802 let shift = attempt.saturating_sub(1).min(31);
1803 let multiplier = 1u32 << shift;
1804 initial.checked_mul(multiplier).unwrap_or(max).min(max)
1805}
1806
1807/// [`retry_backoff_base`] with +/-20% jitter applied, never exceeding `max`.
1808///
1809/// Jitter decorrelates a thundering herd of transactions all retrying after the
1810/// same conflict. Delegates to [`crate::cache::jittered_ttl`] (rather than
1811/// re-implementing the RNG/fallback logic) so there's one jitter
1812/// implementation in the crate, including its `getrandom`-failure fallback to
1813/// `SystemTime` entropy instead of silently degrading to no jitter.
1814#[cfg_attr(feature = "sqlite", allow(dead_code))]
1815fn retry_backoff_delay(initial: Duration, max: Duration, attempt: u32) -> Duration {
1816 let base = retry_backoff_base(initial, max, attempt);
1817 crate::cache::jittered_ttl(base, 0.2).min(max)
1818}
1819
1820/// Whether `err` wraps a Postgres serialization failure (`40001`) or deadlock
1821/// (`40P01`) — the two transient errors that are safe to retry by re-running
1822/// the whole transaction.
1823///
1824/// Classification is structural and authoritative, never based on scanning an
1825/// arbitrary error's displayed message: a `diesel::result::Error` anywhere in
1826/// `err`'s source chain (not just the top-level wrapped error — a custom `E`
1827/// that wraps a diesel error via `#[source]`/`#[from]` is still found) is
1828/// classified by its `DatabaseErrorKind`. `diesel-async`'s Postgres backend
1829/// maps `40001` to `SerializationFailure` directly from the real SQLSTATE (see
1830/// `diesel_async::pg::error_helper::from_tokio_postgres_error`) — fully
1831/// reliable, no message inspection involved. Any *other* kind (e.g.
1832/// `UniqueViolation`) is trusted as non-retryable outright — never
1833/// string-matched, so a constraint name or key value that happens to contain
1834/// `"40001"` cannot misclassify it.
1835///
1836/// `40P01` (deadlock) has no dedicated `DatabaseErrorKind` and always surfaces
1837/// as `Unknown`. Worse: the wrapper type diesel-async uses to carry Postgres's
1838/// error fields for an `Unknown`-kind error (`PostgresDbErrorWrapper`, private
1839/// to diesel-async) implements only `DatabaseErrorInformation`, not
1840/// `std::error::Error` — so [`source_chain_has_sqlstate`]'s downcast walk can
1841/// **never** reach the real SQLSTATE for it; there is no structural path to a
1842/// deadlock's code at all through this crate boundary. The only signal
1843/// available is the message, so this checks it — but with an **exact**
1844/// match, not a substring: Postgres's deadlock detector always raises the
1845/// primary message as precisely `"deadlock detected"` with nothing else (the
1846/// context lives in DETAIL/HINT, not here), so an app's own `RAISE EXCEPTION`
1847/// would have to reproduce that exact string as its *entire* message to
1848/// collide — unlike a substring check, which a longer business message
1849/// merely mentioning the phrase would already trip.
1850///
1851/// When no `diesel::result::Error` is found anywhere in the chain, this checks
1852/// for a raw `tokio_postgres` SQLSTATE directly (a custom `E` that bypasses
1853/// diesel). If neither is found, the error is **not** retried — there is
1854/// deliberately no generic message-substring fallback beyond the one exact
1855/// match above: retrying based on bare text risks misclassifying an unrelated
1856/// domain/validation error as a transient conflict, silently re-running a
1857/// non-idempotent closure and delaying the response.
1858#[cfg_attr(feature = "sqlite", allow(dead_code))]
1859fn is_retryable_txn_error(err: &AutumnError) -> bool {
1860 use tokio_postgres::error::SqlState;
1861
1862 fn is_retryable_sqlstate(state: &SqlState) -> bool {
1863 *state == SqlState::T_R_SERIALIZATION_FAILURE || *state == SqlState::T_R_DEADLOCK_DETECTED
1864 }
1865
1866 if let Some(diesel_err) = err.downcast_chain_ref::<diesel::result::Error>() {
1867 return match diesel_err {
1868 // Fast path: diesel-async maps 40001 to SerializationFailure from
1869 // the real SQLSTATE. Fully structural, no message involved.
1870 diesel::result::Error::DatabaseError(
1871 diesel::result::DatabaseErrorKind::SerializationFailure,
1872 _,
1873 ) => true,
1874 // 40P01 (deadlock) has no dedicated kind and surfaces as Unknown.
1875 // The chain walk is kept for forward-compatibility (a future
1876 // diesel/diesel-async release, or another backend, could start
1877 // exposing the real SQLSTATE through the source chain) but is
1878 // currently unreachable for diesel-async's own Postgres backend
1879 // — see the doc comment above. The message check that follows is
1880 // therefore the only thing that actually fires deadlock retries
1881 // today, hence the exact (not substring) match.
1882 diesel::result::Error::DatabaseError(
1883 diesel::result::DatabaseErrorKind::Unknown,
1884 info,
1885 ) => {
1886 source_chain_has_sqlstate(diesel_err, is_retryable_sqlstate)
1887 || info
1888 .message()
1889 .trim()
1890 .eq_ignore_ascii_case("deadlock detected")
1891 }
1892 // Any other kind (UniqueViolation, NotNullViolation, ...) is
1893 // authoritatively non-retryable — no string matching against its
1894 // message/constraint text.
1895 _ => source_chain_has_sqlstate(diesel_err, is_retryable_sqlstate),
1896 };
1897 }
1898
1899 // No `diesel::result::Error` anywhere in the chain — check for a raw
1900 // `tokio_postgres` SQLSTATE directly. No text-based fallback beyond this;
1901 // see the doc comment above for why.
1902 err.downcast_chain_ref::<tokio_postgres::Error>()
1903 .and_then(tokio_postgres::Error::code)
1904 .is_some_and(is_retryable_sqlstate)
1905 || err
1906 .downcast_chain_ref::<tokio_postgres::error::DbError>()
1907 .is_some_and(|db| is_retryable_sqlstate(db.code()))
1908}
1909
1910/// Run `f` inside a transaction on `conn`, adapting the `ScopedBoxFuture`
1911/// callback shape used throughout Autumn's generated code to the
1912/// `AsyncFnOnce` callback [`diesel_async::AsyncConnection::transaction`]
1913/// expects since diesel-async 0.9.
1914///
1915/// This is a runtime support function for code generated by Autumn proc
1916/// macros. It is semver-exempt; do not call it directly.
1917///
1918/// # Errors
1919///
1920/// Returns the error from `f`, or a `diesel::result::Error` from starting,
1921/// committing, or rolling back the transaction.
1922#[doc(hidden)]
1923pub async fn scoped_transaction<'a, T, E, C, F>(conn: &'a mut C, f: F) -> Result<T, E>
1924where
1925 C: diesel_async::AsyncConnection + Send,
1926 T: Send + 'a,
1927 E: From<diesel::result::Error> + Send + 'a,
1928 F: for<'r> FnOnce(&'r mut C) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
1929 + Send
1930 + 'a,
1931{
1932 // Mirrors the default body of `TransactionManager::transaction` in
1933 // diesel-async 0.9, but drives the boxed callback future directly instead
1934 // of going through the `AsyncFnOnce` bounds (which reject the boxed
1935 // `ScopedBoxFuture` callback shape).
1936 use diesel_async::TransactionManager as _;
1937
1938 C::TransactionManager::begin_transaction(conn).await?;
1939 match f(&mut *conn).await {
1940 Ok(value) => {
1941 C::TransactionManager::commit_transaction(conn).await?;
1942 Ok(value)
1943 }
1944 Err(user_error) => match C::TransactionManager::rollback_transaction(conn).await {
1945 // A broken transaction manager means the rollback error is a
1946 // consequence of the original error; surface the original.
1947 Ok(()) | Err(diesel::result::Error::BrokenTransactionManager) => Err(user_error),
1948 Err(rollback_error) => Err(rollback_error.into()),
1949 },
1950 }
1951}
1952
1953/// Run a write read-modify-write closure inside a transaction that takes the
1954/// SQLite write lock up front (`BEGIN IMMEDIATE`).
1955///
1956/// On Postgres this delegates to [`scoped_transaction`] unchanged. On SQLite it
1957/// begins the transaction with `BEGIN IMMEDIATE` before running `f`, so a
1958/// concurrent writer queues on the connection's `busy_timeout` instead of
1959/// failing its deferred read→write snapshot upgrade with `SQLITE_BUSY_SNAPSHOT`
1960/// (which bypasses the busy handler). It is the transaction primitive for
1961/// generated write-RMW paths (`with_lock`, `update`, `delete_by_id`,
1962/// `find_or_create_by`); read-only transactions, [`Db::tx`], and [`savepoint`]
1963/// deliberately stay on the deferred [`scoped_transaction`] so read-only user
1964/// transactions keep their read concurrency.
1965///
1966/// This is a runtime support function for code generated by Autumn proc macros.
1967/// It is semver-exempt; do not call it directly.
1968///
1969/// # SQLite nesting parity
1970///
1971/// The `BEGIN IMMEDIATE` is issued **through** diesel's `AnsiTransactionManager`
1972/// (`begin_transaction_sql`) rather than as a raw statement, so the manager's
1973/// depth counter is synchronized (0 → 1). A nested [`savepoint`] or a
1974/// `TransactionManager`-driven `.transaction()` inside `f` therefore emits a
1975/// `SAVEPOINT` (matching Postgres) instead of a raw `BEGIN` that would fail with
1976/// "cannot start a transaction within a transaction". Commit and rollback also
1977/// route through the manager, so its depth/status bookkeeping stays correct. If
1978/// a `COMMIT` fails, the manager leaves the connection marked in-transaction,
1979/// so deadpool sees a broken transaction manager and discards the connection
1980/// rather than recycling it with an open write transaction.
1981///
1982/// # Errors
1983///
1984/// Returns the error from `f`, or a `diesel::result::Error` from starting or
1985/// committing the transaction. On SQLite a panic inside `f` rolls the
1986/// transaction back (through the transaction manager) before resuming the
1987/// unwind, so the pooled connection is never recycled with an open write
1988/// transaction.
1989#[doc(hidden)]
1990pub async fn scoped_immediate_transaction<'a, T, E, F>(
1991 conn: &'a mut RuntimeConnection,
1992 f: F,
1993) -> Result<T, E>
1994where
1995 T: Send + 'a,
1996 E: From<diesel::result::Error> + Send + 'a,
1997 F: for<'r> FnOnce(
1998 &'r mut RuntimeConnection,
1999 ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2000 + Send
2001 + 'a,
2002{
2003 crate::backend_select! {
2004 pg => { scoped_transaction(conn, f).await },
2005 sqlite => {{
2006 // Begin the immediate transaction THROUGH the transaction manager so
2007 // its depth counter is kept in sync (depth 0 → 1). Reaching the inner
2008 // `SqliteConnection` for `begin_transaction_sql` requires the concrete
2009 // `SyncConnectionWrapper<SqliteConnection>` (`spawn_blocking`), which
2010 // is why this helper takes `&mut RuntimeConnection` rather than a
2011 // generic connection.
2012 use diesel::connection::{AnsiTransactionManager, TransactionManager};
2013
2014 // Take the write lock up front. A concurrent writer queues here on
2015 // `busy_timeout` instead of failing a deferred snapshot upgrade. With
2016 // the depth counter at 1, a nested transaction/savepoint inside `f`
2017 // becomes a SAVEPOINT (Postgres parity) instead of a raw nested BEGIN.
2018 conn.spawn_blocking(|inner| {
2019 AnsiTransactionManager::begin_transaction_sql(inner, "BEGIN IMMEDIATE")
2020 })
2021 .await
2022 .map_err(E::from)?;
2023
2024 // `catch_unwind` is mandatory: a panic that unwound without a rollback
2025 // would leave deadpool free to recycle this connection with an open,
2026 // uncommitted write transaction.
2027 let outcome = AssertUnwindSafe(f(&mut *conn)).catch_unwind().await;
2028 match outcome {
2029 Ok(Ok(value)) => {
2030 // Commit through the manager. At depth 1 it runs `COMMIT`. If
2031 // that fails (e.g. a deferred-FK violation) the manager leaves
2032 // the connection marked in-transaction, so its
2033 // `is_broken_transaction_manager` reports broken and deadpool
2034 // discards the connection on return rather than recycling it
2035 // with an open write transaction — the pool never hands back a
2036 // dirty connection.
2037 conn.spawn_blocking(|inner| {
2038 <AnsiTransactionManager as TransactionManager<
2039 diesel::SqliteConnection,
2040 >>::commit_transaction(inner)
2041 })
2042 .await
2043 .map_err(E::from)?;
2044 Ok(value)
2045 }
2046 Ok(Err(user_error)) => {
2047 if let Err(e) = conn
2048 .spawn_blocking(|inner| {
2049 <AnsiTransactionManager as TransactionManager<
2050 diesel::SqliteConnection,
2051 >>::rollback_transaction(inner)
2052 })
2053 .await
2054 {
2055 tracing::warn!(
2056 "failed to roll back immediate transaction after error: {e}"
2057 );
2058 }
2059 Err(user_error)
2060 }
2061 Err(panic) => {
2062 if let Err(e) = conn
2063 .spawn_blocking(|inner| {
2064 <AnsiTransactionManager as TransactionManager<
2065 diesel::SqliteConnection,
2066 >>::rollback_transaction(inner)
2067 })
2068 .await
2069 {
2070 tracing::error!(
2071 "failed to roll back immediate transaction during panic: {e}"
2072 );
2073 }
2074 std::panic::resume_unwind(panic);
2075 }
2076 }
2077 }},
2078 }
2079}
2080
2081/// Run `f` inside a Postgres `SAVEPOINT` on a connection already inside a
2082/// transaction.
2083///
2084/// Pass the `conn` handed to a [`Db::tx`] / [`Db::tx_with`] closure. The
2085/// savepoint is released when `f` returns `Ok`, or rolled back (`ROLLBACK TO
2086/// SAVEPOINT`) when it returns `Err` — leaving the surrounding transaction
2087/// intact.
2088///
2089/// This is the supported way to get a nested, partially-rollbackable unit of
2090/// work: `Db::tx` itself cannot be re-entered on the same connection (its
2091/// closure receives `&mut PooledConnection`, not `&mut Db`), so a same-connection
2092/// savepoint can only live here, inside the closure.
2093///
2094/// # Caveat
2095///
2096/// After-commit callbacks registered inside the savepoint via
2097/// [`register_after_commit`] fire when the **outer** transaction commits,
2098/// regardless of whether this savepoint rolled back — the callback registry is
2099/// transaction-scoped, not savepoint-scoped.
2100///
2101/// # Errors
2102///
2103/// Returns the error from `f`, or a `diesel::result::Error` if the savepoint
2104/// itself cannot be established or released.
2105pub async fn savepoint<'a, C, T, E, F>(conn: &'a mut C, f: F) -> Result<T, E>
2106where
2107 // Generic over the connection so it works with the `&mut PooledConnection`
2108 // handed to a `tx` closure and the `&mut AsyncPgConnection` handed to a
2109 // `tx_with` closure alike.
2110 C: diesel_async::AsyncConnection + Send,
2111 T: Send + 'a,
2112 E: From<diesel::result::Error> + Send + 'a,
2113 F: for<'r> FnOnce(&'r mut C) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2114 + Send
2115 + 'a,
2116{
2117 // `scoped_transaction` drives `C::TransactionManager` exactly like
2118 // `conn.transaction` does. diesel-async issues SAVEPOINT (not BEGIN)
2119 // because `conn` is already in a transaction, and RELEASE / ROLLBACK TO on
2120 // Ok / Err respectively.
2121 scoped_transaction(conn, f).await
2122}
2123
2124// ── Db extractor ─────────────────────────────────────────────
2125
2126/// Connection type managed by the deadpool pool.
2127pub type PooledConnection = diesel_async::pooled_connection::deadpool::Object<RuntimeConnection>;
2128
2129struct TxDepthGuard<'a> {
2130 depth: &'a mut usize,
2131 poisoned: &'a mut bool,
2132 disarmed: bool,
2133}
2134
2135impl Drop for TxDepthGuard<'_> {
2136 fn drop(&mut self) {
2137 *self.depth -= 1;
2138 if !self.disarmed {
2139 *self.poisoned = true;
2140 }
2141 }
2142}
2143
2144/// Async database connection extractor.
2145///
2146/// Declare `db: Db` in a handler signature to get a pooled connection to
2147/// Postgres. The connection is returned to the pool when `Db` is dropped
2148/// at the end of the request.
2149///
2150/// `Db` implements [`Deref`](std::ops::Deref) and
2151/// [`DerefMut`](std::ops::DerefMut) to
2152/// `diesel_async::AsyncPgConnection`, so you can use it directly with
2153/// Diesel query methods.
2154///
2155/// If no database is configured (i.e., `database.primary_url` and legacy
2156/// `database.url` are absent),
2157/// requests that use `Db` will receive a `503 Service Unavailable`
2158/// response.
2159///
2160/// # Examples
2161///
2162/// ```rust,no_run
2163/// use autumn_web::prelude::*;
2164///
2165/// #[get("/ping-db")]
2166/// async fn ping_db(db: Db) -> AutumnResult<&'static str> {
2167/// // `db` dereferences to AsyncPgConnection
2168/// Ok("database is reachable")
2169/// }
2170/// ```
2171/// Extension/extractor struct for route-level statement timeout override.
2172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2173pub struct StatementTimeout(pub std::time::Duration);
2174
2175pub struct Db {
2176 conn: PooledConnection,
2177 /// Span covering the full checkout-to-release window. Dropped when
2178 /// `Db` is dropped at the end of the request, so span duration
2179 /// reflects real connection hold time rather than just `pool.get()`
2180 /// latency. Exposed via [`Db::span`] so handlers can attach
2181 /// per-query spans as children with
2182 /// [`tracing::Instrument::instrument`].
2183 span: tracing::Span,
2184 tx_depth: usize,
2185 tx_poisoned: bool,
2186 route_key: Option<String>,
2187 metrics: Option<crate::middleware::MetricsCollector>,
2188 slow_query_threshold: std::time::Duration,
2189 start_time: std::time::Instant,
2190 is_test_tx: bool,
2191}
2192
2193impl Db {
2194 /// Connection-scoped span. Instrument a query future with this to
2195 /// emit a child span tagged under the connection checkout window.
2196 ///
2197 /// ```rust,no_run
2198 /// use autumn_web::prelude::*;
2199 /// use tracing::Instrument as _;
2200 ///
2201 /// # async fn example(mut db: Db) -> AutumnResult<()> {
2202 /// let span = db.span().clone();
2203 /// // run a Diesel query here, e.g. users::table.load(&mut *db)
2204 /// async {
2205 /// // ... diesel_async query ...
2206 /// Ok::<_, AutumnError>(())
2207 /// }
2208 /// .instrument(span)
2209 /// .await
2210 /// # }
2211 /// ```
2212 #[must_use]
2213 pub const fn span(&self) -> &tracing::Span {
2214 &self.span
2215 }
2216
2217 /// Run an async closure inside a database transaction at the default
2218 /// isolation level (READ COMMITTED).
2219 ///
2220 /// Commits when the closure returns `Ok(_)`, rolls back when it returns
2221 /// `Err(_)`. For a stronger isolation level and/or automatic
2222 /// serialization-failure retry, use [`Db::tx_with`].
2223 ///
2224 /// # Errors
2225 ///
2226 /// Returns [`AutumnError`] when:
2227 ///
2228 /// - the underlying transaction returns an error,
2229 /// - the closure returns an error that converts into `AutumnError`,
2230 /// - this `Db` is already inside a transaction,
2231 /// - this `Db` has been poisoned by a previously cancelled/dropped
2232 /// transaction future.
2233 ///
2234 /// # Panics
2235 ///
2236 /// Panics if the internal after-commit registry mutex is poisoned (only
2237 /// possible if a previous thread holding the lock panicked).
2238 pub async fn tx<'a, T, E, F>(&'a mut self, f: F) -> Result<T, crate::error::AutumnError>
2239 where
2240 T: Send + 'a,
2241 E: From<diesel::result::Error> + Send + Sync + 'a,
2242 crate::error::AutumnError: From<E>,
2243 F: for<'r> FnOnce(
2244 &'r mut PooledConnection,
2245 ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2246 + Send
2247 + 'a,
2248 {
2249 if self.tx_poisoned {
2250 return Err(crate::error::AutumnError::service_unavailable_msg(
2251 "Database connection is in an invalid transaction state",
2252 ));
2253 }
2254 if self.tx_depth > 0 {
2255 return Err(crate::error::AutumnError::bad_request_msg(
2256 NESTED_TX_MESSAGE,
2257 ));
2258 }
2259 reject_ambient_after_commit_registry_for_tx()?;
2260 self.tx_depth += 1;
2261 let mut guard = TxDepthGuard {
2262 depth: &mut self.tx_depth,
2263 poisoned: &mut self.tx_poisoned,
2264 disarmed: false,
2265 };
2266
2267 // Each tx gets its own callback registry shared with the task-local so
2268 // that code running inside the closure (jobs, mailer, hooks) can push
2269 // callbacks without having access to `Db` directly. The `Arc` lets us
2270 // read the registry after the `scope` future completes.
2271 let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
2272
2273 // NOTE: `tx` keeps its own body rather than delegating to `tx_with`
2274 // because the two hand out different connection types. `transaction()`
2275 // runs on the pooled `Object` (closure sees `&mut PooledConnection`),
2276 // whereas `tx_with` must use `build_transaction()` — only available on
2277 // `AsyncPgConnection` — so its closure sees `&mut AsyncPgConnection`.
2278 // `scoped_transaction` adapts the public `ScopedBoxFuture` callback
2279 // shape to the transaction API diesel-async 0.9 expects.
2280 let result = AFTER_COMMIT_REGISTRY
2281 .scope(
2282 registry.clone(),
2283 scoped_transaction::<T, E, _, _>(&mut self.conn, f),
2284 )
2285 .await
2286 .map_err(Into::into);
2287
2288 guard.disarmed = true;
2289
2290 // On commit: spawn the registered callbacks outside the transaction
2291 // connection, but await them sequentially inside that task so callback
2292 // dependencies observe registration order.
2293 // Errors are counted and logged; they do NOT affect the committed tx.
2294 // In transactional tests (outer transaction is rolled back), we suppress
2295 // spawning these callbacks to prevent observing uncommitted side effects.
2296 if result.is_ok() {
2297 let callbacks: Vec<CommitCallback> = {
2298 let mut reg = registry.lock().expect("registry lock");
2299 std::mem::take(&mut *reg)
2300 };
2301
2302 if !callbacks.is_empty() && !self.is_test_tx {
2303 let _ = spawn_committed_after_commit_callbacks(callbacks);
2304 }
2305 }
2306
2307 result
2308 }
2309
2310 /// Run an async closure inside a database transaction with explicit
2311 /// [`TxOptions`] — isolation level, read-only/deferrable mode, and automatic
2312 /// retry of transient serialization failures (`40001`) and deadlocks
2313 /// (`40P01`).
2314 ///
2315 /// Commits when the closure returns `Ok(_)`, rolls back when it returns
2316 /// `Err(_)`. On a retryable failure with attempts remaining, the whole
2317 /// closure is re-run after a capped exponential backoff with jitter.
2318 ///
2319 /// # The closure must be re-runnable
2320 ///
2321 /// **Because the closure can run more than once, it must be free of
2322 /// side effects that are not themselves transactional (or must be
2323 /// idempotent).** Database work is rolled back between attempts, and
2324 /// after-commit callbacks from failed attempts are discarded — but any
2325 /// non-database side effect in the closure body (logging aside — external
2326 /// API calls, channel sends, in-memory mutation) will re-execute on each
2327 /// retry. Keep such effects out of the closure, or gate them on the final
2328 /// success.
2329 ///
2330 /// # Observability
2331 ///
2332 /// The transaction runs under a `db.transaction` span carrying
2333 /// `db.isolation` and the final `db.tx.attempts` count. Each retry also
2334 /// increments [`TX_RETRIES_TOTAL`]; an exhausted retry budget increments
2335 /// [`TX_RETRY_EXHAUSTED_TOTAL`].
2336 ///
2337 /// # Errors
2338 ///
2339 /// Returns [`AutumnError`] under the same conditions as [`Db::tx`]. A
2340 /// non-retryable error is returned immediately; when the retry budget is
2341 /// exhausted, the **final** underlying error is returned (never swallowed).
2342 ///
2343 /// Under the `sqlite` feature there is no transaction builder that can
2344 /// enforce `READ ONLY`, so a request carrying [`TxOptions::read_only`]
2345 /// returns an unsupported-options error **before the closure runs** rather
2346 /// than silently executing a writable transaction (which would let writes
2347 /// commit under a read-only contract). A normal read-write transaction is
2348 /// unaffected. The Postgres path enforces read-only via the transaction
2349 /// builder and never rejects.
2350 ///
2351 /// # Panics
2352 ///
2353 /// Panics if the internal after-commit registry mutex is poisoned (only
2354 /// possible if a previous thread holding the lock panicked).
2355 #[allow(clippy::too_many_lines)]
2356 // The Postgres path re-borrows `f` per retry attempt (`&mut f`); the SQLite
2357 // path consumes it once, so `mut` is unused there.
2358 #[cfg_attr(feature = "sqlite", allow(unused_mut))]
2359 pub async fn tx_with<'a, T, E, F>(
2360 &'a mut self,
2361 opts: TxOptions,
2362 mut f: F,
2363 ) -> Result<T, crate::error::AutumnError>
2364 where
2365 T: Send + 'a,
2366 E: From<diesel::result::Error> + Send + Sync + 'a,
2367 crate::error::AutumnError: From<E>,
2368 // The closure receives `&mut RuntimeConnection` (not `&mut PooledConnection`
2369 // as `tx` does): on Postgres, isolation levels require `build_transaction()`,
2370 // which is inherent on `AsyncPgConnection` (the default `RuntimeConnection`).
2371 // Diesel query methods work on either backend. Under the `sqlite` feature
2372 // `RuntimeConnection` is a SQLite connection and the isolation/retry path
2373 // below degrades to a single plain transaction.
2374 F: for<'r> FnMut(
2375 &'r mut RuntimeConnection,
2376 ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2377 + Send
2378 + 'a,
2379 {
2380 if self.tx_poisoned {
2381 return Err(crate::error::AutumnError::service_unavailable_msg(
2382 "Database connection is in an invalid transaction state",
2383 ));
2384 }
2385 if self.tx_depth > 0 {
2386 return Err(crate::error::AutumnError::bad_request_msg(
2387 NESTED_TX_MESSAGE,
2388 ));
2389 }
2390 reject_ambient_after_commit_registry_for_tx()?;
2391
2392 // Under the SQLite runtime there is no transaction builder that can
2393 // enforce `READ ONLY` semantics — the `sqlite` arm below runs a single
2394 // plain, writable transaction. Silently honoring a caller's
2395 // `TxOptions::read_only()` by running a writable transaction anyway would
2396 // let writes succeed and commit under a contract that promised none — a
2397 // safety regression for any code relying on a read-only transaction to
2398 // prevent mutation. So reject the request up front, BEFORE the closure
2399 // can run, rather than pretending to honor it. (Real `query_only`
2400 // enforcement is avoided deliberately: deadpool's `custom_setup` runs on
2401 // CREATE only, so a leaked `PRAGMA query_only = ON` would poison a pooled
2402 // connection for its lifetime.) The Postgres path enforces read-only via
2403 // the transaction builder's `read_only()` and is unaffected.
2404 #[cfg(feature = "sqlite")]
2405 if opts.read_only {
2406 return Err(crate::error::AutumnError::bad_request_msg(
2407 "SQLite runtime does not support read-only transactions \
2408 (TxOptions::read_only); this build cannot enforce read-only \
2409 semantics on SQLite. Remove the read_only option or run on \
2410 Postgres.",
2411 ));
2412 }
2413
2414 self.tx_depth += 1;
2415 let mut guard = TxDepthGuard {
2416 depth: &mut self.tx_depth,
2417 poisoned: &mut self.tx_poisoned,
2418 disarmed: false,
2419 };
2420
2421 let span = tracing::info_span!(
2422 "db.transaction",
2423 db.system = "postgresql",
2424 db.isolation = opts.isolation.as_str(),
2425 db.tx.attempts = tracing::field::Empty,
2426 );
2427
2428 if self.is_test_tx {
2429 // Under a transactional `TestApp` the connection is already inside
2430 // the test harness's outer transaction (`begin_test_transaction`),
2431 // so issuing a literal `BEGIN`/`SET TRANSACTION ISOLATION LEVEL`
2432 // via `build_transaction()` here would be invalid — Postgres
2433 // rejects `SET TRANSACTION ISOLATION LEVEL` inside a
2434 // subtransaction, and the retry loop's per-attempt `&mut f`
2435 // re-borrow doesn't type-check against the plain `transaction()`
2436 // method's lifetime shape (unlike `build_transaction().run()`, its
2437 // bound is not scoped to a single call). So: nest via `SAVEPOINT`
2438 // instead, exactly like `Db::tx`, running the closure exactly
2439 // once — the requested isolation/read-only/deferrable/retry
2440 // options are inherited from (or meaningless nested inside) the
2441 // outer test transaction, so there is nothing to retry against a
2442 // single test-harness connection.
2443 let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
2444 let conn: &mut RuntimeConnection = &mut self.conn;
2445 let result = AFTER_COMMIT_REGISTRY
2446 .scope(registry, scoped_transaction::<T, E, _, _>(conn, f))
2447 .instrument(span.clone())
2448 .await
2449 .map_err(Into::into);
2450
2451 span.record("db.tx.attempts", 1u32);
2452 guard.disarmed = true;
2453 // `is_test_tx` always suppresses after-commit spawning (matching
2454 // `Db::tx`), so the registry is simply dropped without draining.
2455 return result;
2456 }
2457
2458 // SQLite has no `SET TRANSACTION ISOLATION LEVEL` / read-only /
2459 // deferrable transaction builder and no serialization-failure retry
2460 // semantics, so run the closure once inside a single plain transaction.
2461 // The requested isolation level, read-only, deferrable, and retry
2462 // options are Postgres-only and are ignored here (documented).
2463 // After-commit callbacks still fire on commit, exactly as on the
2464 // Postgres path.
2465 #[cfg(feature = "sqlite")]
2466 {
2467 let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
2468 let conn: &mut RuntimeConnection = &mut self.conn;
2469 let result: Result<T, E> = AFTER_COMMIT_REGISTRY
2470 .scope(registry.clone(), scoped_transaction::<T, E, _, _>(conn, f))
2471 .instrument(span.clone())
2472 .await;
2473 span.record("db.tx.attempts", 1u32);
2474 guard.disarmed = true;
2475 // This block is the whole remaining function body under the feature
2476 // (the Postgres retry loop below is cfg'd out), so the `match` is the
2477 // tail expression — no `return` needed.
2478 match result {
2479 Ok(value) => {
2480 let callbacks: Vec<CommitCallback> = {
2481 let mut reg = registry.lock().expect("registry lock");
2482 std::mem::take(&mut *reg)
2483 };
2484 if !callbacks.is_empty() {
2485 let _ = spawn_committed_after_commit_callbacks(callbacks);
2486 }
2487 Ok(value)
2488 }
2489 Err(user_error) => Err(crate::error::AutumnError::from(user_error)),
2490 }
2491 }
2492
2493 #[cfg(not(feature = "sqlite"))]
2494 {
2495 let max_attempts = opts.effective_max_attempts();
2496 let mut attempt: u32 = 0;
2497
2498 let outcome: Result<T, crate::error::AutumnError> = loop {
2499 attempt += 1;
2500
2501 // A fresh registry per attempt: a rolled-back attempt's after-commit
2502 // callbacks are discarded simply by dropping this `Arc` undrained.
2503 let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
2504
2505 let attempt_result: Result<T, E> = {
2506 // *** LOAD-BEARING ORDER: borrow `f` BEFORE `build_transaction()`.
2507 // diesel-async's `TransactionBuilder::run` bounds the closure by
2508 // the connection-borrow lifetime; `&mut f`'s region must start
2509 // earlier than that borrow or it fails to compile. Do not reorder.
2510 let f_ref = &mut f;
2511
2512 let mut builder = self.conn.build_transaction();
2513 builder = match opts.isolation {
2514 IsolationLevel::ReadCommitted => builder.read_committed(),
2515 IsolationLevel::RepeatableRead => builder.repeatable_read(),
2516 IsolationLevel::Serializable => builder.serializable(),
2517 };
2518 if opts.read_only {
2519 builder = builder.read_only();
2520 }
2521 if opts.deferrable {
2522 builder = builder.deferrable();
2523 }
2524
2525 AFTER_COMMIT_REGISTRY
2526 .scope(
2527 registry.clone(),
2528 builder.run::<T, E, _>(async move |conn| f_ref(conn).await),
2529 )
2530 .instrument(span.clone())
2531 .await
2532 };
2533
2534 match attempt_result {
2535 Ok(value) => {
2536 // Commit path: drain THIS attempt's callbacks and spawn
2537 // them. (The `is_test_tx` case already returned above, so
2538 // spawning here is always live.)
2539 let callbacks: Vec<CommitCallback> = {
2540 let mut reg = registry.lock().expect("registry lock");
2541 std::mem::take(&mut *reg)
2542 };
2543 if !callbacks.is_empty() {
2544 let _ = spawn_committed_after_commit_callbacks(callbacks);
2545 }
2546 break Ok(value);
2547 }
2548 Err(e) => {
2549 // Convert to AutumnError first, then classify on it.
2550 let ae = crate::error::AutumnError::from(e);
2551 let retryable = is_retryable_txn_error(&ae);
2552 match retry_decision(attempt, max_attempts, retryable) {
2553 RetryDecision::Retry => {
2554 let retries_total = record_tx_retry();
2555 let delay = retry_backoff_delay(
2556 opts.initial_backoff,
2557 opts.max_backoff,
2558 attempt,
2559 );
2560 tracing::debug!(
2561 parent: &span,
2562 attempt,
2563 max_attempts,
2564 delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
2565 autumn.tx.retries_total = retries_total,
2566 "retrying transaction after serialization/deadlock failure"
2567 );
2568 // `registry` drops here → rolled-back attempt's
2569 // after-commit callbacks are discarded; the loop
2570 // then re-runs the closure.
2571 tokio::time::sleep(delay).await;
2572 }
2573 RetryDecision::Stop => {
2574 if retryable {
2575 let exhausted_total = record_tx_retry_exhausted();
2576 tracing::warn!(
2577 parent: &span,
2578 attempt,
2579 max_attempts,
2580 autumn.tx.retry_exhausted_total = exhausted_total,
2581 "transaction retry budget exhausted; returning final error"
2582 );
2583 }
2584 break Err(ae);
2585 }
2586 }
2587 }
2588 }
2589 };
2590
2591 span.record("db.tx.attempts", attempt);
2592 guard.disarmed = true;
2593 outcome
2594 }
2595 }
2596}
2597
2598impl std::ops::Deref for Db {
2599 type Target = RuntimeConnection;
2600 fn deref(&self) -> &Self::Target {
2601 assert!(
2602 !self.tx_poisoned,
2603 "Db connection is poisoned due to a cancelled/dropped transaction"
2604 );
2605 &self.conn
2606 }
2607}
2608
2609impl std::ops::DerefMut for Db {
2610 fn deref_mut(&mut self) -> &mut Self::Target {
2611 assert!(
2612 !self.tx_poisoned,
2613 "Db connection is poisoned due to a cancelled/dropped transaction"
2614 );
2615 &mut self.conn
2616 }
2617}
2618
2619/// Everything required to check out and instrument a pooled connection.
2620///
2621/// Shared by the plain [`Db`] extractor and shard-routed checkouts so that
2622/// every connection — regardless of which pool it came from — gets the same
2623/// span, interceptor, statement-timeout, and slow-query treatment.
2624pub(crate) struct DbCheckoutParams<'a> {
2625 /// Pool to check the connection out of.
2626 pub pool: &'a Pool<RuntimeConnection>,
2627 /// Role label surfaced to [`DbConnectionInterceptor`]s, e.g. `"primary"`
2628 /// or `"shard:<name>:primary"`.
2629 pub pool_name: &'a str,
2630 /// Shard name recorded on the `db.connection` span, when routed.
2631 pub shard: Option<&'a str>,
2632 /// Resolved statement timeout (route override already merged with the
2633 /// global config). `None` disables the timeout (`SET statement_timeout = 0`).
2634 pub statement_timeout: Option<std::time::Duration>,
2635 /// `"METHOD /matched/path"` key used for per-route DB metrics.
2636 pub route_key: Option<String>,
2637 pub metrics: Option<crate::middleware::MetricsCollector>,
2638 pub slow_query_threshold: std::time::Duration,
2639 pub interceptors: Vec<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
2640}
2641
2642impl Db {
2643 /// Check a connection out of `params.pool` with full instrumentation.
2644 ///
2645 /// This is the single code path behind the [`Db`] extractor and all
2646 /// shard-routed checkouts: span creation, checkout interceptors,
2647 /// `SET statement_timeout`, and the metrics captured for the
2648 /// slow-query warning on `Drop`.
2649 pub(crate) async fn checkout(params: DbCheckoutParams<'_>) -> Result<Self, AutumnError> {
2650 // `SET statement_timeout` (and its i32-cap arithmetic below) is a
2651 // Postgres session GUC. Under the `sqlite` feature the runtime backend
2652 // is entirely SQLite (the `RuntimeConnection` alias flips wholesale —
2653 // see `build_sqlite_pool`), which rejects the statement and would turn
2654 // every `Db`-using route into a 503. The const, the `RunQueryDsl`
2655 // import, the timeout arithmetic, and the `SET` itself are therefore all
2656 // gated off on the SQLite build; the Postgres path is byte-identical.
2657 #[cfg(not(feature = "sqlite"))]
2658 const PG_TIMEOUT_MAX_MS: u64 = i32::MAX as u64;
2659 #[cfg(not(feature = "sqlite"))]
2660 use diesel_async::RunQueryDsl as _;
2661
2662 // Span covers the full time the connection is held — from
2663 // checkout through the end of the request — rather than just
2664 // `pool.get()`. Dropping `Db` closes the span, so span duration
2665 // reflects real connection hold time and `db.system=postgresql`
2666 // propagates to any query futures handlers instrument with
2667 // `db.span()`.
2668 let span = tracing::info_span!(
2669 "db.connection",
2670 otel.kind = "client",
2671 db.system = "postgresql",
2672 db.shard = tracing::field::Empty,
2673 );
2674 if let Some(shard) = params.shard {
2675 span.record("db.shard", shard);
2676 }
2677
2678 let pool = params.pool;
2679 let mut checkout_future: std::pin::Pin<
2680 Box<
2681 dyn std::future::Future<Output = Result<PooledConnection, AutumnError>> + Send + '_,
2682 >,
2683 > = Box::pin(async move {
2684 pool.get().await.map_err(|e| {
2685 tracing::error!("Failed to acquire database connection: {e}");
2686 AutumnError::service_unavailable_msg(e.to_string())
2687 })
2688 });
2689 for interceptor in ¶ms.interceptors {
2690 let ctx = crate::interceptor::DbCheckoutContext {
2691 pool_name: params.pool_name.to_string(),
2692 };
2693 checkout_future = interceptor.intercept_checkout(ctx, checkout_future);
2694 }
2695
2696 let mut conn = checkout_future.instrument(span.clone()).await?;
2697
2698 // `statement_timeout` is a Postgres session GUC; it is intentionally
2699 // unused on the SQLite backend (see the gating note below), so consume
2700 // it here to keep the shared `DbCheckoutParams` field from reading as
2701 // dead code under `--features sqlite`.
2702 #[cfg(feature = "sqlite")]
2703 let _ = params.statement_timeout;
2704
2705 // Postgres statement_timeout is a signed 32-bit integer (milliseconds).
2706 // Cap at i32::MAX to avoid a confusing 503 for very large configured values.
2707 #[cfg(not(feature = "sqlite"))]
2708 let timeout_ms = params.statement_timeout.map_or(0u64, |d| {
2709 u64::try_from(d.as_millis())
2710 .unwrap_or(PG_TIMEOUT_MAX_MS)
2711 .min(PG_TIMEOUT_MAX_MS)
2712 });
2713
2714 // Install a fresh per-request query timer, but ONLY when a query
2715 // observer is active — EITHER a `REQUEST_DB_TIMINGS` scope (the
2716 // `ServerTimingLayer`, enabled by `[observability] server_timing`) OR a
2717 // `REQUEST_QUERY_CAPTURE` scope (the test harness capturing the SQL
2718 // list, which runs with `server_timing` off). The timer feeds both
2719 // lanes via `record_request_db_query`. Installed BEFORE the `SET
2720 // statement_timeout` housekeeping statement below.
2721 //
2722 // `set_instrumentation` WHOLESALE REPLACES the connection's
2723 // instrumentation, so it must not run unconditionally: an application
2724 // that registered a global default via
2725 // `diesel::connection::set_default_instrumentation` (query logging,
2726 // tracing, metrics) would have it silently clobbered on the first
2727 // checkout and never restored — even when `server_timing` is disabled.
2728 // Gating on `request_db_timing_active() || request_query_capture_active()`
2729 // preserves the app's instrumentation whenever neither lane is scoped
2730 // (no `server_timing`, no test capture — the production default), and
2731 // only overwrites it for the duration a query observer is active.
2732 //
2733 // Installing a *fresh* timer on every observed checkout also clears any
2734 // stale `RequestQueryTimer` a pooled connection carried from a prior
2735 // request (diesel-async's deadpool manager never resets instrumentation
2736 // on recycle), so a stale timer can never record the upcoming
2737 // housekeeping `SET` (or later app queries) into a *different* request's
2738 // accumulator. Installing BEFORE the `SET` (which
2739 // `is_uncounted_statement` classifies as housekeeping) keeps that
2740 // statement out of the `Server-Timing` `db` count regardless. Any stale
2741 // timer left on a connection later reused by an opted-out request is a
2742 // cheap no-op: `on_start` probes both lanes before formatting or
2743 // recording anything, so it never allocates off-scope.
2744 #[cfg(feature = "db")]
2745 {
2746 use diesel_async::AsyncConnection as _;
2747 if request_db_timing_active() || request_query_capture_active() {
2748 conn.set_instrumentation(RequestQueryTimer::default());
2749 }
2750 }
2751
2752 // Postgres-only per-checkout initialization; see the gating note above.
2753 // SQLite builds skip it entirely (it would 503 every `Db`-using route).
2754 #[cfg(not(feature = "sqlite"))]
2755 diesel::sql_query(format!("SET statement_timeout = {timeout_ms}"))
2756 .execute(&mut conn)
2757 .await
2758 .map_err(|e| {
2759 tracing::error!("Failed to set database statement_timeout to {timeout_ms}ms: {e}");
2760 AutumnError::service_unavailable_msg(format!("Database initialization error: {e}"))
2761 })?;
2762
2763 let start_time = std::time::Instant::now();
2764 let is_test_tx = params
2765 .interceptors
2766 .iter()
2767 .any(|i| i.is_transactional_test());
2768
2769 Ok(Self {
2770 conn,
2771 span,
2772 tx_depth: 0,
2773 tx_poisoned: false,
2774 route_key: params.route_key,
2775 metrics: params.metrics,
2776 slow_query_threshold: params.slow_query_threshold,
2777 start_time,
2778 is_test_tx,
2779 })
2780 }
2781
2782 /// Check a plain, uninstrumented connection out of `pool` for use in tests.
2783 ///
2784 /// Unlike the request extractor, this applies no interceptors, statement
2785 /// timeout, or route metrics — it exists so integration tests can drive
2786 /// [`Db::tx`] / [`Db::tx_with`] directly against a [`crate::test::TestDb`]
2787 /// pool without spinning up a full `TestApp`.
2788 ///
2789 /// # Errors
2790 ///
2791 /// Returns [`AutumnError`] if a connection cannot be acquired from `pool`.
2792 #[cfg(feature = "test-support")]
2793 pub async fn connect_for_test(pool: &Pool<RuntimeConnection>) -> Result<Self, AutumnError> {
2794 Self::checkout(DbCheckoutParams {
2795 pool,
2796 pool_name: "test",
2797 shard: None,
2798 statement_timeout: None,
2799 route_key: None,
2800 metrics: None,
2801 slow_query_threshold: std::time::Duration::from_millis(500),
2802 interceptors: Vec::new(),
2803 })
2804 .await
2805 }
2806}
2807
2808/// Request-derived context shared by every `Db`-producing extractor.
2809///
2810/// Captures the route-override statement timeout, the matched-path metrics
2811/// key, and the state-held instrumentation handles so shard-routed
2812/// checkouts behave identically to the plain [`Db`] extractor.
2813#[derive(Clone)]
2814pub(crate) struct RequestDbContext {
2815 pub statement_timeout: Option<std::time::Duration>,
2816 pub route_key: Option<String>,
2817 pub metrics: Option<crate::middleware::MetricsCollector>,
2818 pub slow_query_threshold: std::time::Duration,
2819 pub interceptors: Vec<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
2820}
2821
2822impl RequestDbContext {
2823 pub(crate) fn from_parts<S: DbState>(parts: &axum::http::request::Parts, state: &S) -> Self {
2824 let timeout_override = parts.extensions.get::<StatementTimeout>().copied();
2825 let matched_path = parts
2826 .extensions
2827 .get::<axum::extract::MatchedPath>()
2828 .map_or_else(|| parts.uri.path(), axum::extract::MatchedPath::as_str);
2829 Self {
2830 statement_timeout: timeout_override
2831 .map(|t| t.0)
2832 .or_else(|| state.statement_timeout()),
2833 route_key: Some(format!("{} {}", parts.method, matched_path)),
2834 metrics: state.metrics().cloned(),
2835 slow_query_threshold: state.slow_query_threshold(),
2836 interceptors: state.db_interceptors(),
2837 }
2838 }
2839}
2840
2841impl<S> FromRequestParts<S> for Db
2842where
2843 S: DbState + Send + Sync,
2844{
2845 type Rejection = AutumnError;
2846
2847 async fn from_request_parts(
2848 parts: &mut axum::http::request::Parts,
2849 state: &S,
2850 ) -> Result<Self, Self::Rejection> {
2851 let pool = state
2852 .pool()
2853 .ok_or_else(|| AutumnError::service_unavailable_msg("Database not configured"))?;
2854 let ctx = RequestDbContext::from_parts(parts, state);
2855
2856 let result = Self::checkout(DbCheckoutParams {
2857 pool,
2858 pool_name: "primary",
2859 shard: None,
2860 statement_timeout: ctx.statement_timeout,
2861 route_key: ctx.route_key,
2862 metrics: ctx.metrics,
2863 slow_query_threshold: ctx.slow_query_threshold,
2864 interceptors: ctx.interceptors,
2865 })
2866 .await;
2867 // Notify the RYWW task-local that a primary connection was checked out.
2868 // No-op when read_your_writes = "off" (task-local absent).
2869 if result.is_ok() {
2870 crate::read_your_writes::mark_write();
2871 }
2872 result
2873 }
2874}
2875
2876impl Drop for Db {
2877 fn drop(&mut self) {
2878 if let (Some(route_key), Some(metrics)) = (&self.route_key, &self.metrics) {
2879 let elapsed = self.start_time.elapsed();
2880 let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
2881
2882 // Record DB query metric
2883 let metric_key = format!("{route_key} SELECT");
2884 metrics.record_db_query(&metric_key, elapsed_ms);
2885 // NOTE: deliberately *not* recorded into the Server-Timing
2886 // per-request accumulator. `elapsed` here is the whole
2887 // connection checkout-to-release window (see `start_time` /
2888 // the `span` doc above), not a single query's wall time. Every
2889 // request that extracts `Db` would otherwise add its entire
2890 // connection-hold time as one bogus "query", inflating both
2891 // `db;dur` and the `desc="N queries"` count (and double-counting
2892 // against real queries recorded by `run_instrumented`). The
2893 // accumulator must reflect only genuine instrumented queries.
2894
2895 // Log slow query if it exceeds the threshold
2896 if elapsed >= self.slow_query_threshold {
2897 tracing::warn!(
2898 route = %route_key,
2899 sql = "SELECT ?",
2900 duration_ms = elapsed_ms,
2901 "slow database query"
2902 );
2903 }
2904 }
2905 }
2906}
2907
2908// ----------------------------------------------------------------------------
2909// DatabasePoolProvider — tier-1 boot-time replaceable pool factory
2910// ----------------------------------------------------------------------------
2911
2912/// Pluggable boot-time database pool factory.
2913///
2914/// Replace the default `deadpool + diesel-async` factory with a custom
2915/// strategy (custom metrics wrapper, circuit breaker, separate pools per
2916/// shard, etc.) by implementing this trait and installing it on the
2917/// [`AppBuilder`](crate::app::AppBuilder) via
2918/// [`with_pool_provider`](crate::app::AppBuilder::with_pool_provider).
2919///
2920/// The trait abstracts the *factory*, not the pool *type* — the return type is
2921/// fixed at `Pool<AsyncPgConnection>` for now. Swapping to a different backend
2922/// (e.g. `MySQL`, `SQLite`) would require generic `Pool<C>` propagation through
2923/// `Db` / `DbState` / `AppState` and is intentionally out of scope.
2924///
2925/// Providers that only implement [`DatabasePoolProvider::create_pool`] still
2926/// participate in primary/replica topology: the default
2927/// [`DatabasePoolProvider::create_topology`] uses the custom primary pool and
2928/// builds the configured replica role with Autumn's deadpool factory. Override
2929/// `create_topology` when both roles need custom construction.
2930///
2931/// # Example
2932///
2933/// ```rust,no_run
2934/// use autumn_web::config::DatabaseConfig;
2935/// use autumn_web::db::{DatabasePoolProvider, PoolError};
2936/// use diesel_async::AsyncPgConnection;
2937/// use diesel_async::pooled_connection::deadpool::Pool;
2938///
2939/// pub struct MetricsPoolProvider;
2940///
2941/// impl DatabasePoolProvider for MetricsPoolProvider {
2942/// async fn create_pool(
2943/// &self,
2944/// config: &DatabaseConfig,
2945/// ) -> Result<Option<Pool<AsyncPgConnection>>, PoolError> {
2946/// // Wrap the default pool with custom metrics, then return it.
2947/// autumn_web::db::create_pool(config)
2948/// }
2949/// }
2950/// ```
2951pub trait DatabasePoolProvider: Send + Sync + 'static {
2952 /// Create a connection pool from the resolved [`DatabaseConfig`].
2953 ///
2954 /// Returning `Ok(None)` signals that the application should run without a
2955 /// database — useful for static-site / API-gateway use cases or for
2956 /// disabling the DB in test contexts.
2957 fn create_pool(
2958 &self,
2959 config: &DatabaseConfig,
2960 ) -> impl std::future::Future<Output = Result<Option<Pool<RuntimeConnection>>, PoolError>> + Send;
2961
2962 /// Create primary and optional replica pools from the resolved
2963 /// [`DatabaseConfig`].
2964 ///
2965 /// The default implementation preserves the provider's custom primary pool
2966 /// and builds a replica pool when `database.replica_url` is configured.
2967 ///
2968 /// # Errors
2969 ///
2970 /// Returns [`PoolError`] if either configured role cannot be built.
2971 fn create_topology(
2972 &self,
2973 config: &DatabaseConfig,
2974 ) -> impl std::future::Future<Output = Result<Option<DatabaseTopology>, PoolError>> + Send {
2975 async move {
2976 let Some(primary) = self.create_pool(config).await? else {
2977 return Ok(None);
2978 };
2979
2980 // A custom provider that overrides only `create_pool` still gets its
2981 // replica built by this default here, so the SQLite-replica rule must
2982 // be enforced on this path too -- otherwise a provider configuring a
2983 // distinct/in-memory `database.replica_url` would boot two unrelated
2984 // SQLite databases and route reads to an empty/stale replica. Reuse
2985 // the same helper `create_topology`/`create_shard_topology` use so the
2986 // rejection rule cannot drift (addresses Codex P2). The primary URL is
2987 // whatever `effective_primary_url` resolves; a provider returning a
2988 // pool for a `None` primary URL has no URL to compare, so skip then.
2989 #[cfg(feature = "sqlite")]
2990 if let (Some(primary_url), Some(replica_url)) = (
2991 config.effective_primary_url(),
2992 config.replica_url.as_deref(),
2993 ) {
2994 reject_unusable_sqlite_replica(primary_url, replica_url)?;
2995 }
2996
2997 let replica = config
2998 .replica_url
2999 .as_deref()
3000 .map(|url| {
3001 build_pool(
3002 url,
3003 config.effective_replica_pool_size(),
3004 config.connect_timeout_secs,
3005 )
3006 })
3007 .transpose()?;
3008
3009 Ok(Some(DatabaseTopology::from_pools(primary, replica)))
3010 }
3011 }
3012
3013 /// Create one shard's [`DatabaseTopology`] from its
3014 /// `[[database.shards]]` entry.
3015 ///
3016 /// The default implementation uses Autumn's deadpool factory for both
3017 /// roles. Override to decorate per-shard pools (metrics wrappers,
3018 /// circuit breakers) the same way `create_pool` decorates the control
3019 /// role.
3020 ///
3021 /// # Errors
3022 ///
3023 /// Returns [`PoolError`] if either configured role cannot be built.
3024 fn create_shard_topology(
3025 &self,
3026 shard: &crate::config::ShardConfig,
3027 defaults: &DatabaseConfig,
3028 ) -> impl std::future::Future<Output = Result<DatabaseTopology, PoolError>> + Send {
3029 async move { create_shard_topology(shard, defaults) }
3030 }
3031}
3032
3033/// Default [`DatabasePoolProvider`] — the `deadpool + diesel-async` factory.
3034///
3035/// Delegates to the free function [`create_pool`]. This is the provider used
3036/// when no override is installed via
3037/// [`with_pool_provider`](crate::app::AppBuilder::with_pool_provider).
3038#[derive(Debug, Default, Clone, Copy)]
3039pub struct DieselDeadpoolPoolProvider;
3040
3041impl DieselDeadpoolPoolProvider {
3042 /// Construct a new default provider.
3043 #[must_use]
3044 pub const fn new() -> Self {
3045 Self
3046 }
3047}
3048
3049impl DatabasePoolProvider for DieselDeadpoolPoolProvider {
3050 async fn create_pool(
3051 &self,
3052 config: &DatabaseConfig,
3053 ) -> Result<Option<Pool<RuntimeConnection>>, PoolError> {
3054 create_pool(config)
3055 }
3056}
3057
3058#[cfg(test)]
3059mod tests {
3060 use super::*;
3061 use crate::config::DatabaseConfig;
3062 use std::sync::Arc;
3063 use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
3064 use std::time::Duration;
3065
3066 // ── RequestQueryTimer / Server-Timing db accumulator tests ───
3067
3068 /// The connection instrumentation records one query per completed
3069 /// `StartQuery`/`FinishQuery` pair into the request accumulator, summing
3070 /// elapsed time. Drives the extracted `on_start`/`on_finish` accounting
3071 /// (the `InstrumentationEvent` itself is non-exhaustive and its
3072 /// constructors are gated behind an unstable diesel feature, so it cannot
3073 /// be built in a test). Deterministic: derives finish instants from the
3074 /// start via `Instant + Duration`, no sleeps.
3075 #[cfg(feature = "db")]
3076 #[tokio::test]
3077 async fn request_query_timer_accumulates_finished_queries() {
3078 let timings = Arc::new(RequestDbTimings::default());
3079 REQUEST_DB_TIMINGS
3080 .scope(Arc::clone(&timings), async {
3081 let mut timer = RequestQueryTimer::default();
3082
3083 // Query 1: 1200µs.
3084 let t0 = std::time::Instant::now();
3085 timer.on_start(t0, || "SELECT 1".to_string());
3086 timer.on_finish(t0 + Duration::from_micros(1_200));
3087
3088 // Query 2: 800µs.
3089 let t1 = std::time::Instant::now();
3090 timer.on_start(t1, || "UPDATE users SET name = $1".to_string());
3091 timer.on_finish(t1 + Duration::from_micros(800));
3092
3093 // A stray FinishQuery with no matching StartQuery is ignored.
3094 timer.on_finish(std::time::Instant::now());
3095 })
3096 .await;
3097
3098 assert_eq!(
3099 timings.query_count.load(Ordering::Relaxed),
3100 2,
3101 "only completed StartQuery/FinishQuery pairs are counted"
3102 );
3103 assert_eq!(
3104 timings.total_us.load(Ordering::Relaxed),
3105 2_000,
3106 "elapsed times accumulate (1200µs + 800µs)"
3107 );
3108 }
3109
3110 /// Outside a `REQUEST_DB_TIMINGS` scope the timer records nothing and does
3111 /// not panic — the off-request / middleware-disabled path.
3112 #[cfg(feature = "db")]
3113 #[tokio::test]
3114 async fn request_query_timer_is_noop_off_request() {
3115 let mut timer = RequestQueryTimer::default();
3116 let t0 = std::time::Instant::now();
3117 timer.on_start(t0, || "SELECT 1".to_string());
3118 // Must not panic even though no task-local accumulator is scoped.
3119 timer.on_finish(t0 + Duration::from_micros(500));
3120 }
3121
3122 /// Approach-(b) guarantee: when no `REQUEST_DB_TIMINGS` scope is active,
3123 /// `on_start` must be a cheap no-op — it must not invoke the (allocating)
3124 /// SQL-formatting closure, and must leave no in-flight statement. This is
3125 /// what makes it safe to leave a `RequestQueryTimer` installed on a pooled
3126 /// connection that a later opted-out request reuses.
3127 #[cfg(feature = "db")]
3128 #[tokio::test]
3129 async fn request_query_timer_on_start_is_cheap_noop_off_request() {
3130 let mut timer = RequestQueryTimer::default();
3131 let invoked = std::cell::Cell::new(false);
3132 let t0 = std::time::Instant::now();
3133 timer.on_start(t0, || {
3134 invoked.set(true);
3135 "SELECT 1".to_string()
3136 });
3137 assert!(
3138 !invoked.get(),
3139 "off-request, on_start must not format the SQL (no allocation)"
3140 );
3141 // No in-flight statement was recorded, so on_finish is a no-op.
3142 timer.on_finish(t0 + Duration::from_micros(500));
3143 }
3144
3145 /// Regression for the stale-timer / housekeeping-`SET` bug: a pooled
3146 /// connection reused across requests keeps a `RequestQueryTimer` installed,
3147 /// and `Db::checkout` runs a `SET statement_timeout` before handing the
3148 /// connection over. That housekeeping `SET` must NOT be counted, or every
3149 /// `Server-Timing` request would report a bogus `+1 query` before any app
3150 /// SQL. After the `SET`, the first real `SELECT` increments the count to
3151 /// exactly 1 and only its latency accumulates.
3152 #[cfg(feature = "db")]
3153 #[tokio::test]
3154 async fn request_query_timer_excludes_checkout_statement_timeout_set() {
3155 let timings = Arc::new(RequestDbTimings::default());
3156 REQUEST_DB_TIMINGS
3157 .scope(Arc::clone(&timings), async {
3158 // A timer as it would be freshly installed at checkout.
3159 let mut timer = RequestQueryTimer::default();
3160
3161 // Checkout housekeeping `SET` — must not be counted.
3162 let t0 = std::time::Instant::now();
3163 timer.on_start(t0, || "SET statement_timeout = 5000".to_string());
3164 timer.on_finish(t0 + Duration::from_millis(3));
3165
3166 assert_eq!(
3167 timings.query_count.load(Ordering::Relaxed),
3168 0,
3169 "the checkout SET statement_timeout must not be counted"
3170 );
3171 assert_eq!(
3172 timings.total_us.load(Ordering::Relaxed),
3173 0,
3174 "the checkout SET contributes no latency to db;dur"
3175 );
3176
3177 // First real application query: 600µs.
3178 let t1 = std::time::Instant::now();
3179 timer.on_start(t1, || "SELECT * FROM users".to_string());
3180 timer.on_finish(t1 + Duration::from_micros(600));
3181
3182 assert_eq!(
3183 timings.query_count.load(Ordering::Relaxed),
3184 1,
3185 "the real SELECT is the first counted query"
3186 );
3187 assert_eq!(
3188 timings.total_us.load(Ordering::Relaxed),
3189 600,
3190 "only the SELECT's 600µs accumulates; the SET is excluded"
3191 );
3192 })
3193 .await;
3194 }
3195
3196 /// `request_db_timing_active` — the predicate `RequestQueryTimer::on_start`
3197 /// probes to decide whether to record — must report `false` outside a
3198 /// `REQUEST_DB_TIMINGS` scope (the production / `server_timing` disabled
3199 /// default) and `true` inside one (the `ServerTimingLayer`-scoped request).
3200 /// This predicate gates two things: whether `Db::checkout` installs the
3201 /// timer at all (skipped off-scope so an app's own instrumentation is not
3202 /// clobbered), and whether a stale timer left on a reused connection does
3203 /// any work — with the probe `false`, `on_start` is a cheap no-op.
3204 ///
3205 /// NOTE: the real `Db::checkout` path needs a live Postgres connection,
3206 /// which the sandbox cannot provide, so this exercises the predicate
3207 /// directly.
3208 #[cfg(feature = "db")]
3209 #[tokio::test]
3210 async fn request_db_timing_active_reflects_scope() {
3211 assert!(
3212 !request_db_timing_active(),
3213 "no REQUEST_DB_TIMINGS scope active outside the ServerTimingLayer: \
3214 the timer records nothing"
3215 );
3216
3217 let timings = Arc::new(RequestDbTimings::default());
3218 REQUEST_DB_TIMINGS
3219 .scope(Arc::clone(&timings), async {
3220 assert!(
3221 request_db_timing_active(),
3222 "inside a REQUEST_DB_TIMINGS scope the probe is active: \
3223 the timer records queries"
3224 );
3225 })
3226 .await;
3227
3228 // Scope has ended; the gate reads false again.
3229 assert!(
3230 !request_db_timing_active(),
3231 "the gate is false again once the scope is dropped"
3232 );
3233 }
3234
3235 /// A transaction wraps its inner statements in `BEGIN`/`COMMIT`, which
3236 /// diesel-async runs through `batch_execute` — emitting a
3237 /// `StartQuery`/`FinishQuery` pair with that transaction-control SQL, just
3238 /// like a real query. The timer must skip those so a transaction with one
3239 /// real `SELECT` reports `desc="1 query"`, not `"3 queries"`, and excludes
3240 /// begin/commit latency from `db;dur`.
3241 #[cfg(feature = "db")]
3242 #[tokio::test]
3243 async fn request_query_timer_excludes_transaction_control_statements() {
3244 let timings = Arc::new(RequestDbTimings::default());
3245 REQUEST_DB_TIMINGS
3246 .scope(Arc::clone(&timings), async {
3247 let mut timer = RequestQueryTimer::default();
3248
3249 // BEGIN — must not be counted (10_000µs, would dominate if it
3250 // leaked into the total).
3251 let t0 = std::time::Instant::now();
3252 timer.on_start(t0, || "BEGIN".to_string());
3253 timer.on_finish(t0 + Duration::from_millis(10));
3254
3255 // The single real query: 700µs.
3256 let t1 = std::time::Instant::now();
3257 timer.on_start(t1, || "SELECT * FROM users".to_string());
3258 timer.on_finish(t1 + Duration::from_micros(700));
3259
3260 // COMMIT — must not be counted.
3261 let t2 = std::time::Instant::now();
3262 timer.on_start(t2, || "COMMIT".to_string());
3263 timer.on_finish(t2 + Duration::from_millis(10));
3264 })
3265 .await;
3266
3267 assert_eq!(
3268 timings.query_count.load(Ordering::Relaxed),
3269 1,
3270 "only the real SELECT is counted; BEGIN/COMMIT are excluded"
3271 );
3272 assert_eq!(
3273 timings.total_us.load(Ordering::Relaxed),
3274 700,
3275 "only the SELECT's 700µs accumulates; begin/commit latency is excluded"
3276 );
3277 }
3278
3279 /// The checkout install gate: `Db::checkout` calls `set_instrumentation`
3280 /// (which WHOLESALE REPLACES the connection's instrumentation — clobbering
3281 /// any app-registered `diesel::connection::set_default_instrumentation`)
3282 /// ONLY when `request_db_timing_active()` is true. Outside a
3283 /// `REQUEST_DB_TIMINGS` scope (the `server_timing`-disabled production
3284 /// default) the gate is false, so the guarded install is skipped and a
3285 /// pre-existing sentinel instrumentation is left intact. Inside a scope the
3286 /// timer is installed.
3287 ///
3288 /// NOTE: the real `Db::checkout` path needs a live Postgres connection the
3289 /// sandbox cannot provide, so this models the connection's single
3290 /// instrumentation slot and drives the exact gate expression from
3291 /// `Db::checkout`.
3292 #[cfg(feature = "db")]
3293 #[tokio::test]
3294 async fn checkout_installs_timer_only_when_scope_active() {
3295 // Models the connection's single instrumentation slot. "app" stands for
3296 // an application-registered default (`set_default_instrumentation`);
3297 // "timer" is autumn's `RequestQueryTimer`. Mirrors the guarded block in
3298 // `Db::checkout`: `if request_db_timing_active() { install timer }`.
3299 fn run_checkout_gate(slot: &mut &'static str) {
3300 if request_db_timing_active() {
3301 *slot = "timer";
3302 }
3303 }
3304
3305 // Off-scope (server_timing disabled / production default): the gate is
3306 // false, so the app's instrumentation must survive untouched.
3307 assert!(!request_db_timing_active());
3308 let mut slot = "app";
3309 run_checkout_gate(&mut slot);
3310 assert_eq!(
3311 slot, "app",
3312 "off-scope checkout must NOT clobber the app's instrumentation"
3313 );
3314
3315 // Inside a scope (server_timing enabled, handler wrapped by the layer):
3316 // the gate is true, so autumn installs its RequestQueryTimer.
3317 let timings = Arc::new(RequestDbTimings::default());
3318 REQUEST_DB_TIMINGS
3319 .scope(Arc::clone(&timings), async {
3320 let mut slot = "app";
3321 run_checkout_gate(&mut slot);
3322 assert_eq!(
3323 slot, "timer",
3324 "inside a REQUEST_DB_TIMINGS scope autumn installs its timer"
3325 );
3326 })
3327 .await;
3328 }
3329
3330 /// Unit coverage for the uncounted-statement classifier used by the timer:
3331 /// leading-token match is case-insensitive and covers savepoint/release, the
3332 /// two-word `START TRANSACTION` form, and any leading `SET` (both
3333 /// `SET TRANSACTION` and the checkout `SET statement_timeout` housekeeping),
3334 /// while real application queries are counted. `UPDATE ... SET ...` is
3335 /// counted because `SET` is not its leading token.
3336 #[cfg(feature = "db")]
3337 #[test]
3338 fn is_uncounted_statement_classifies_statements() {
3339 for sql in [
3340 "BEGIN",
3341 "begin",
3342 " COMMIT",
3343 "ROLLBACK",
3344 "ROLLBACK TO SAVEPOINT diesel_savepoint_0",
3345 "SAVEPOINT diesel_savepoint_1",
3346 "RELEASE SAVEPOINT diesel_savepoint_0",
3347 "start transaction",
3348 "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE",
3349 "SET statement_timeout = 5000",
3350 "set statement_timeout = 0",
3351 ] {
3352 assert!(
3353 RequestQueryTimer::is_uncounted_statement(sql),
3354 "{sql:?} should be treated as an uncounted housekeeping/tx statement"
3355 );
3356 }
3357
3358 for sql in [
3359 "SELECT 1",
3360 "UPDATE users SET name = $1",
3361 "INSERT INTO t VALUES (1)",
3362 "DELETE FROM t WHERE id = 1",
3363 "",
3364 ] {
3365 assert!(
3366 !RequestQueryTimer::is_uncounted_statement(sql),
3367 "{sql:?} should be treated as a countable query"
3368 );
3369 }
3370 }
3371
3372 /// Finding-1 regression: the capture path stores the parameterised
3373 /// statement WITHOUT diesel's trailing `-- binds: [...]` annotation, so the
3374 /// per-row executions of an N+1 pattern (which differ only in their bind
3375 /// values) collapse to a single template and `detect_n_plus_one` catches
3376 /// them. Without the strip, each `-- binds: [N]` would normalise to a
3377 /// distinct template and the N+1 would go undetected. Locks the fix with no
3378 /// live database.
3379 #[cfg(feature = "db")]
3380 #[test]
3381 fn strip_bind_annotation_lets_detect_n_plus_one_see_per_row_repetition() {
3382 use crate::inspector::{QueryRecord, detect_n_plus_one};
3383
3384 // Construct QueryRecords exactly as `record_request_db_query` does:
3385 // the captured `sql` is diesel's `DebugQuery` `Display` output run
3386 // through `strip_bind_annotation`.
3387 fn record(diesel_display: &str) -> QueryRecord {
3388 QueryRecord {
3389 sql: strip_bind_annotation(diesel_display).to_owned(),
3390 params: Vec::new(),
3391 elapsed_ms: 1,
3392 location: String::new(),
3393 }
3394 }
3395
3396 // The `-- binds:` marker (and the leading whitespace before it) is
3397 // stripped; the `$N` placeholder is preserved.
3398 assert_eq!(
3399 strip_bind_annotation("SELECT * FROM books WHERE author_id = $1 -- binds: [1]"),
3400 "SELECT * FROM books WHERE author_id = $1"
3401 );
3402 // Zero-bind annotation is stripped too.
3403 assert_eq!(
3404 strip_bind_annotation("SELECT * FROM authors -- binds: []"),
3405 "SELECT * FROM authors"
3406 );
3407 // No marker (transaction-control / synthetic input) → unchanged.
3408 assert_eq!(strip_bind_annotation("SELECT 1"), "SELECT 1");
3409 assert_eq!(strip_bind_annotation("BEGIN"), "BEGIN");
3410
3411 // A classic N+1: one parent query, then the same per-row child query
3412 // executed once per row with a different bind value each time.
3413 let n_plus_one = vec![
3414 record("SELECT * FROM authors -- binds: []"),
3415 record("SELECT * FROM books WHERE author_id = $1 -- binds: [1]"),
3416 record("SELECT * FROM books WHERE author_id = $1 -- binds: [2]"),
3417 record("SELECT * FROM books WHERE author_id = $1 -- binds: [3]"),
3418 ];
3419 let warning = detect_n_plus_one(&n_plus_one, 3)
3420 .expect("three identical per-row templates should trip the N+1 detector");
3421 assert_eq!(
3422 warning.count, 3,
3423 "all three per-row executions collapse to a single template"
3424 );
3425 assert_eq!(
3426 warning.sql_template, "select * from books where author_id = $1",
3427 "the reported template is the parameterised statement, bind-free"
3428 );
3429
3430 // Distinct query templates must NOT trip the detector, even at the
3431 // same total query count.
3432 let distinct = vec![
3433 record("SELECT * FROM authors -- binds: []"),
3434 record("SELECT * FROM books WHERE id = $1 -- binds: [1]"),
3435 record("UPDATE users SET name = $1 WHERE id = $2 -- binds: [\"x\", 2]"),
3436 ];
3437 assert!(
3438 detect_n_plus_one(&distinct, 3).is_none(),
3439 "three distinct query templates must not be reported as N+1"
3440 );
3441 }
3442
3443 // ── after_commit tests ───────────────────────────────────────
3444
3445 #[tokio::test]
3446 async fn register_after_commit_outside_tx_runs_eagerly() {
3447 // When called outside a db.tx block, the callback should run immediately.
3448 let counter = Arc::new(AtomicUsize::new(0));
3449 let c = counter.clone();
3450 register_after_commit(move || async move {
3451 c.fetch_add(1, Ordering::SeqCst);
3452 Ok(())
3453 })
3454 .await;
3455 assert_eq!(counter.load(Ordering::SeqCst), 1);
3456 }
3457
3458 #[tokio::test]
3459 async fn register_after_commit_eager_failure_increments_failure_counter() {
3460 let before = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
3461
3462 register_after_commit(|| async {
3463 Err(crate::AutumnError::internal_server_error_msg(
3464 "deliberate eager after-commit failure",
3465 ))
3466 })
3467 .await;
3468
3469 let after = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
3470 assert!(
3471 after > before,
3472 "eager after_commit failures should be counted for recovery signals"
3473 );
3474 }
3475
3476 #[tokio::test]
3477 async fn register_after_commit_inside_scope_defers_until_drained() {
3478 // Inside a task-local scope (simulating Db::tx), callbacks are deferred.
3479 let counter = Arc::new(AtomicUsize::new(0));
3480 let c = counter.clone();
3481
3482 let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
3483
3484 // Simulate being inside a db.tx by setting the task-local
3485 AFTER_COMMIT_REGISTRY
3486 .scope(registry.clone(), async {
3487 register_after_commit(move || async move {
3488 c.fetch_add(1, Ordering::SeqCst);
3489 Ok(())
3490 })
3491 .await;
3492 })
3493 .await;
3494
3495 // Callback must NOT have run yet
3496 assert_eq!(counter.load(Ordering::SeqCst), 0);
3497
3498 // Drain and run the callbacks (simulating post-commit)
3499 let callbacks: Vec<CommitCallback> = {
3500 let mut reg = registry.lock().unwrap();
3501 std::mem::take(&mut *reg)
3502 };
3503 for cb in callbacks {
3504 cb().await.unwrap();
3505 }
3506
3507 assert_eq!(counter.load(Ordering::SeqCst), 1);
3508 }
3509
3510 #[tokio::test]
3511 async fn register_after_commit_on_rollback_callbacks_dropped() {
3512 // Callbacks registered inside a tx scope that is NOT drained are dropped.
3513 let counter = Arc::new(AtomicUsize::new(0));
3514 let c = counter.clone();
3515
3516 let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
3517
3518 AFTER_COMMIT_REGISTRY
3519 .scope(registry.clone(), async {
3520 register_after_commit(move || async move {
3521 c.fetch_add(1, Ordering::SeqCst);
3522 Ok(())
3523 })
3524 .await;
3525 })
3526 .await;
3527
3528 // Simulate rollback: drop the callbacks without running them
3529 drop(registry);
3530
3531 assert_eq!(counter.load(Ordering::SeqCst), 0);
3532 }
3533
3534 #[tokio::test]
3535 async fn register_after_commit_callbacks_run_in_registration_order() {
3536 let order = Arc::new(std::sync::Mutex::new(Vec::<u32>::new()));
3537 let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
3538
3539 let o1 = order.clone();
3540 let o2 = order.clone();
3541 let o3 = order.clone();
3542
3543 AFTER_COMMIT_REGISTRY
3544 .scope(registry.clone(), async {
3545 register_after_commit(move || async move {
3546 o1.lock().unwrap().push(1);
3547 Ok(())
3548 })
3549 .await;
3550 register_after_commit(move || async move {
3551 o2.lock().unwrap().push(2);
3552 Ok(())
3553 })
3554 .await;
3555 register_after_commit(move || async move {
3556 o3.lock().unwrap().push(3);
3557 Ok(())
3558 })
3559 .await;
3560 })
3561 .await;
3562
3563 let callbacks: Vec<CommitCallback> = {
3564 let mut reg = registry.lock().unwrap();
3565 std::mem::take(&mut *reg)
3566 };
3567 for cb in callbacks {
3568 cb().await.unwrap();
3569 }
3570
3571 assert_eq!(*order.lock().unwrap(), vec![1, 2, 3]);
3572 }
3573
3574 #[tokio::test]
3575 async fn production_after_commit_drain_preserves_registration_order() {
3576 let order = Arc::new(std::sync::Mutex::new(Vec::<u32>::new()));
3577 let (release_first, wait_first) = tokio::sync::oneshot::channel::<()>();
3578
3579 let first_order = order.clone();
3580 let second_order = order.clone();
3581 let callbacks: Vec<CommitCallback> = vec![
3582 Box::new(move || {
3583 Box::pin(async move {
3584 wait_first
3585 .await
3586 .expect("test should release first callback");
3587 first_order.lock().unwrap().push(1);
3588 Ok(())
3589 })
3590 }),
3591 Box::new(move || {
3592 Box::pin(async move {
3593 second_order.lock().unwrap().push(2);
3594 Ok(())
3595 })
3596 }),
3597 ];
3598
3599 let drain = spawn_committed_after_commit_callbacks(callbacks)
3600 .expect("non-empty callback list should spawn a drain task");
3601 tokio::task::yield_now().await;
3602
3603 assert_eq!(
3604 *order.lock().unwrap(),
3605 Vec::<u32>::new(),
3606 "later callbacks must wait for earlier callbacks to finish"
3607 );
3608
3609 release_first
3610 .send(())
3611 .expect("first callback receiver alive");
3612 drain.await.expect("drain task should not panic");
3613
3614 assert_eq!(*order.lock().unwrap(), vec![1, 2]);
3615 }
3616
3617 #[tokio::test]
3618 async fn production_after_commit_drain_isolates_panicking_callbacks() {
3619 let before = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
3620 let ran_later = Arc::new(AtomicU64::new(0));
3621 let later = ran_later.clone();
3622
3623 let callbacks: Vec<CommitCallback> = vec![
3624 Box::new(|| Box::pin(async { panic!("deliberate after_commit panic") })),
3625 Box::new(move || {
3626 Box::pin(async move {
3627 later.fetch_add(1, Ordering::SeqCst);
3628 Ok(())
3629 })
3630 }),
3631 ];
3632
3633 let drain = spawn_committed_after_commit_callbacks(callbacks)
3634 .expect("non-empty callback list should spawn a drain task");
3635 drain.await.expect("panicking callback should be isolated");
3636
3637 assert_eq!(
3638 ran_later.load(Ordering::SeqCst),
3639 1,
3640 "later callbacks must still run after an earlier callback panics"
3641 );
3642 let after = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
3643 assert!(
3644 after > before,
3645 "panicking after_commit callbacks must increment the failure counter"
3646 );
3647 }
3648
3649 #[tokio::test]
3650 async fn db_tx_rejects_ambient_after_commit_registry() {
3651 let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
3652
3653 let err = AFTER_COMMIT_REGISTRY
3654 .scope(registry, async {
3655 reject_ambient_after_commit_registry_for_tx().expect_err(
3656 "starting Db::tx inside an ambient transaction registry should fail",
3657 )
3658 })
3659 .await;
3660
3661 assert!(
3662 err.to_string().contains("Nested Db::tx calls"),
3663 "unexpected nested transaction error: {err}"
3664 );
3665 }
3666
3667 #[tokio::test]
3668 async fn register_after_commit_callback_error_is_swallowed() {
3669 // A failing callback is logged but doesn't panic or propagate.
3670 let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
3671
3672 AFTER_COMMIT_REGISTRY
3673 .scope(registry.clone(), async {
3674 register_after_commit(|| async {
3675 Err(crate::AutumnError::internal_server_error_msg(
3676 "deliberate error",
3677 ))
3678 })
3679 .await;
3680 })
3681 .await;
3682
3683 let callbacks: Vec<CommitCallback> = {
3684 let mut reg = registry.lock().unwrap();
3685 std::mem::take(&mut *reg)
3686 };
3687 // Running a failing callback should not panic
3688 for cb in callbacks {
3689 let _ = cb().await;
3690 }
3691 }
3692
3693 // ── Pool provider trait tests ────────────────────────────────
3694
3695 /// No-op provider for tests — always returns `Ok(None)` regardless of the
3696 /// supplied config. Verifies the trait actually overrides the default
3697 /// (which would otherwise build a pool from the URL).
3698 struct NoOpPoolProvider;
3699
3700 impl DatabasePoolProvider for NoOpPoolProvider {
3701 async fn create_pool(
3702 &self,
3703 _config: &DatabaseConfig,
3704 ) -> Result<Option<Pool<crate::db::RuntimeConnection>>, PoolError> {
3705 Ok(None)
3706 }
3707 }
3708
3709 #[tokio::test]
3710 async fn pool_provider_trait_returns_supplied_pool() {
3711 // Even with a configured URL, the no-op provider returns None — proving
3712 // the trait can replace the default factory's behaviour.
3713 let config = DatabaseConfig {
3714 url: Some("postgres://localhost/ignored".to_owned()),
3715 ..Default::default()
3716 };
3717 let provider = NoOpPoolProvider;
3718 let pool = provider
3719 .create_pool(&config)
3720 .await
3721 .expect("no-op provider should succeed");
3722 assert!(
3723 pool.is_none(),
3724 "no-op provider must override default behaviour"
3725 );
3726 }
3727
3728 #[tokio::test]
3729 async fn default_pool_provider_matches_free_function() {
3730 let config = DatabaseConfig::default();
3731 let via_provider = DieselDeadpoolPoolProvider::new()
3732 .create_pool(&config)
3733 .await
3734 .expect("default provider should succeed");
3735 let via_function = create_pool(&config).expect("free fn should succeed");
3736 assert_eq!(via_provider.is_none(), via_function.is_none());
3737 }
3738
3739 // ── Pool creation tests ──────────────────────────────────────
3740
3741 #[tokio::test]
3742 async fn default_pool_provider_respects_url_config() {
3743 let config = DatabaseConfig {
3744 url: Some("postgres://localhost/test".into()),
3745 ..Default::default()
3746 };
3747 let provider = DieselDeadpoolPoolProvider::new();
3748 let pool = provider
3749 .create_pool(&config)
3750 .await
3751 .expect("default provider should succeed");
3752 assert!(
3753 pool.is_some(),
3754 "default provider should return Some when url is provided"
3755 );
3756 }
3757
3758 #[test]
3759 fn create_pool_with_no_url_returns_none() {
3760 let config = DatabaseConfig::default();
3761 let pool = create_pool(&config).expect("should not fail with no URL");
3762 assert!(pool.is_none());
3763 }
3764
3765 #[test]
3766 fn create_pool_with_url_returns_some() {
3767 let config = DatabaseConfig {
3768 url: Some("postgres://localhost/test".into()),
3769 ..Default::default()
3770 };
3771 let pool = create_pool(&config).expect("should build pool from valid config");
3772 assert!(pool.is_some());
3773 }
3774
3775 // In the default (Postgres) build a SQLite target has no runtime pool, so
3776 // pool construction must refuse at boot with an actionable message pointing
3777 // at the `--features sqlite` build — never reaching a first-query failure or
3778 // panic. Under `--features sqlite` the same target instead builds a real
3779 // pool (see the `sqlite_boot_serve` integration test), so this refusal
3780 // contract only applies to the default build.
3781 #[cfg(not(feature = "sqlite"))]
3782 #[test]
3783 fn create_pool_with_sqlite_url_fails_fast() {
3784 let config = DatabaseConfig {
3785 url: Some("sqlite:///var/lib/app.db".into()),
3786 ..Default::default()
3787 };
3788 // The Ok variant (`Option<Pool>`) is not `Debug`, so match rather than
3789 // use `expect_err`.
3790 let Err(err) = create_pool(&config) else {
3791 panic!("sqlite target must refuse at pool build");
3792 };
3793 assert!(
3794 matches!(err, PoolError::UnsupportedBackend(_)),
3795 "expected UnsupportedBackend, got: {err:?}"
3796 );
3797 let msg = err.to_string();
3798 assert!(
3799 msg.contains("SQLite") && msg.contains("--features sqlite"),
3800 "message must be actionable and name the sqlite build, got: {msg}"
3801 );
3802 }
3803
3804 #[cfg(not(feature = "sqlite"))]
3805 #[test]
3806 fn create_topology_with_sqlite_url_fails_fast() {
3807 let config = DatabaseConfig {
3808 primary_url: Some("sqlite::memory:".into()),
3809 ..Default::default()
3810 };
3811 let Err(err) = create_topology(&config) else {
3812 panic!("sqlite target must refuse at topology build");
3813 };
3814 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3815 }
3816
3817 // Under `--features sqlite` the same targets that refuse above instead build
3818 // a real pool/topology over `SyncConnectionWrapper<SqliteConnection>`.
3819 #[cfg(feature = "sqlite")]
3820 #[test]
3821 fn create_pool_with_sqlite_url_builds_under_feature() {
3822 let config = DatabaseConfig {
3823 primary_url: Some("sqlite::memory:".into()),
3824 ..Default::default()
3825 };
3826 let pool = create_pool(&config).expect("sqlite pool builds under the feature");
3827 assert!(pool.is_some(), "a configured sqlite url yields a pool");
3828 }
3829
3830 // A Postgres URL in a sqlite build is a misconfiguration and must refuse.
3831 #[cfg(feature = "sqlite")]
3832 #[test]
3833 fn create_pool_with_postgres_url_refuses_under_sqlite_feature() {
3834 let config = DatabaseConfig {
3835 url: Some("postgres://localhost/test".into()),
3836 ..Default::default()
3837 };
3838 let Err(err) = create_pool(&config) else {
3839 panic!("a Postgres url must refuse under the sqlite feature");
3840 };
3841 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3842 }
3843
3844 // A SQLite runtime has no primary/replica replication in this pool
3845 // architecture, so a separate replica pool would serve reads from an empty
3846 // database. `create_topology` must reject an in-memory or distinct-file
3847 // replica with an actionable boot error (addresses Codex P2).
3848 #[cfg(feature = "sqlite")]
3849 #[test]
3850 fn create_topology_rejects_in_memory_sqlite_replica() {
3851 let config = DatabaseConfig {
3852 primary_url: Some("sqlite:///var/lib/app.db".into()),
3853 replica_url: Some("sqlite::memory:".into()),
3854 ..Default::default()
3855 };
3856 let Err(err) = create_topology(&config) else {
3857 panic!("an in-memory sqlite replica must be rejected");
3858 };
3859 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3860 let msg = err.to_string();
3861 assert!(
3862 msg.contains("separate read replica") && msg.contains("primary"),
3863 "message must be actionable: {msg}"
3864 );
3865 }
3866
3867 #[cfg(feature = "sqlite")]
3868 #[test]
3869 fn create_topology_rejects_distinct_file_sqlite_replica() {
3870 let config = DatabaseConfig {
3871 primary_url: Some("sqlite:///var/lib/primary.db".into()),
3872 replica_url: Some("sqlite:///var/lib/replica.db".into()),
3873 ..Default::default()
3874 };
3875 let Err(err) = create_topology(&config) else {
3876 panic!("a distinct-file sqlite replica must be rejected");
3877 };
3878 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3879 }
3880
3881 // A replica that normalizes to the SAME file as the primary is the same
3882 // database — harmless — so it is allowed and the topology builds.
3883 #[cfg(feature = "sqlite")]
3884 #[test]
3885 fn create_topology_allows_same_file_sqlite_replica() {
3886 let config = DatabaseConfig {
3887 primary_url: Some("sqlite:///var/lib/app.db".into()),
3888 replica_url: Some("sqlite:///var/lib/app.db".into()),
3889 ..Default::default()
3890 };
3891 let topology = create_topology(&config)
3892 .expect("same-file sqlite replica must be allowed")
3893 .expect("a configured primary yields a topology");
3894 assert!(
3895 topology.replica().is_some(),
3896 "same-file replica pool should be built"
3897 );
3898 }
3899
3900 // The no-replica happy path still builds a topology fine under the feature.
3901 #[cfg(feature = "sqlite")]
3902 #[test]
3903 fn create_topology_sqlite_primary_only_builds() {
3904 let config = DatabaseConfig {
3905 primary_url: Some("sqlite::memory:".into()),
3906 ..Default::default()
3907 };
3908 let topology = create_topology(&config)
3909 .expect("sqlite primary-only topology builds")
3910 .expect("a configured primary yields a topology");
3911 assert!(topology.replica().is_none(), "no replica configured");
3912 }
3913
3914 // A custom provider that overrides ONLY `create_pool` still has its replica
3915 // built by the trait DEFAULT `create_topology`. That default path must apply
3916 // the same SQLite-replica rule as the free `create_topology` /
3917 // `create_shard_topology`, or a provider could boot a distinct/in-memory
3918 // replica and route reads to an unrelated, empty database (addresses Codex
3919 // P2). This minimal provider delegates `create_pool` to the default factory
3920 // and leaves `create_topology` as the trait default — exactly the path under
3921 // test.
3922 #[cfg(feature = "sqlite")]
3923 struct PrimaryOnlyProvider;
3924
3925 #[cfg(feature = "sqlite")]
3926 impl DatabasePoolProvider for PrimaryOnlyProvider {
3927 async fn create_pool(
3928 &self,
3929 config: &DatabaseConfig,
3930 ) -> Result<Option<Pool<RuntimeConnection>>, PoolError> {
3931 create_pool(config)
3932 }
3933 }
3934
3935 #[cfg(feature = "sqlite")]
3936 #[tokio::test]
3937 async fn default_provider_topology_rejects_distinct_file_sqlite_replica() {
3938 let config = DatabaseConfig {
3939 primary_url: Some("sqlite:///var/lib/primary.db".into()),
3940 replica_url: Some("sqlite:///var/lib/replica.db".into()),
3941 ..Default::default()
3942 };
3943 let Err(err) = PrimaryOnlyProvider.create_topology(&config).await else {
3944 panic!("the default-topology path must reject a distinct-file sqlite replica");
3945 };
3946 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3947 let msg = err.to_string();
3948 assert!(
3949 msg.contains("separate read replica") && msg.contains("primary"),
3950 "message must be actionable: {msg}"
3951 );
3952 }
3953
3954 #[cfg(feature = "sqlite")]
3955 #[tokio::test]
3956 async fn default_provider_topology_rejects_in_memory_sqlite_replica() {
3957 let config = DatabaseConfig {
3958 primary_url: Some("sqlite:///var/lib/app.db".into()),
3959 replica_url: Some("sqlite::memory:".into()),
3960 ..Default::default()
3961 };
3962 let Err(err) = PrimaryOnlyProvider.create_topology(&config).await else {
3963 panic!("an in-memory sqlite replica must be rejected by the default topology");
3964 };
3965 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
3966 }
3967
3968 // A same-file replica is the same database (harmless) and still builds
3969 // through the default topology; a primary-only config builds with no replica.
3970 #[cfg(feature = "sqlite")]
3971 #[tokio::test]
3972 async fn default_provider_topology_allows_same_file_and_primary_only() {
3973 let same_file = DatabaseConfig {
3974 primary_url: Some("sqlite:///var/lib/app.db".into()),
3975 replica_url: Some("sqlite:///var/lib/app.db".into()),
3976 ..Default::default()
3977 };
3978 let topology = PrimaryOnlyProvider
3979 .create_topology(&same_file)
3980 .await
3981 .expect("same-file replica must be allowed by the default topology")
3982 .expect("a configured primary yields a topology");
3983 assert!(
3984 topology.replica().is_some(),
3985 "same-file replica pool should be built"
3986 );
3987
3988 let primary_only = DatabaseConfig {
3989 primary_url: Some("sqlite::memory:".into()),
3990 ..Default::default()
3991 };
3992 let topology = PrimaryOnlyProvider
3993 .create_topology(&primary_only)
3994 .await
3995 .expect("primary-only default topology builds")
3996 .expect("a configured primary yields a topology");
3997 assert!(topology.replica().is_none(), "no replica configured");
3998 }
3999
4000 // The per-shard replica is subject to the SAME SQLite-replica rule as the
4001 // control database (addresses Codex P2): an in-memory or distinct-file shard
4002 // replica would route reads to an unrelated, empty database, so it must be
4003 // rejected; a shard with only a primary (or a same-file replica) must build.
4004 #[cfg(feature = "sqlite")]
4005 #[test]
4006 fn create_shard_topology_rejects_in_memory_sqlite_replica() {
4007 let defaults = DatabaseConfig::default();
4008 let shard = crate::config::ShardConfig {
4009 name: "shard0".into(),
4010 primary_url: "sqlite:///var/lib/shard0.db".into(),
4011 replica_url: Some("sqlite::memory:".into()),
4012 ..Default::default()
4013 };
4014 let Err(err) = create_shard_topology(&shard, &defaults) else {
4015 panic!("an in-memory sqlite shard replica must be rejected");
4016 };
4017 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
4018 let msg = err.to_string();
4019 assert!(
4020 msg.contains("separate read replica") && msg.contains("primary"),
4021 "message must be actionable: {msg}"
4022 );
4023 }
4024
4025 #[cfg(feature = "sqlite")]
4026 #[test]
4027 fn create_shard_topology_rejects_distinct_file_sqlite_replica() {
4028 let defaults = DatabaseConfig::default();
4029 let shard = crate::config::ShardConfig {
4030 name: "shard0".into(),
4031 primary_url: "sqlite:///var/lib/shard0-primary.db".into(),
4032 replica_url: Some("sqlite:///var/lib/shard0-replica.db".into()),
4033 ..Default::default()
4034 };
4035 let Err(err) = create_shard_topology(&shard, &defaults) else {
4036 panic!("a distinct-file sqlite shard replica must be rejected");
4037 };
4038 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
4039 }
4040
4041 #[cfg(feature = "sqlite")]
4042 #[test]
4043 fn create_shard_topology_allows_same_file_sqlite_replica() {
4044 let defaults = DatabaseConfig::default();
4045 let shard = crate::config::ShardConfig {
4046 name: "shard0".into(),
4047 primary_url: "sqlite:///var/lib/shard0.db".into(),
4048 replica_url: Some("sqlite:///var/lib/shard0.db".into()),
4049 ..Default::default()
4050 };
4051 let topology = create_shard_topology(&shard, &defaults)
4052 .expect("same-file sqlite shard replica must be allowed");
4053 assert!(
4054 topology.replica().is_some(),
4055 "same-file shard replica pool should be built"
4056 );
4057 }
4058
4059 #[cfg(feature = "sqlite")]
4060 #[test]
4061 fn create_shard_topology_sqlite_primary_only_builds() {
4062 let defaults = DatabaseConfig::default();
4063 let shard = crate::config::ShardConfig {
4064 name: "shard0".into(),
4065 primary_url: "sqlite::memory:".into(),
4066 ..Default::default()
4067 };
4068 let topology = create_shard_topology(&shard, &defaults)
4069 .expect("sqlite primary-only shard topology builds");
4070 assert!(topology.replica().is_none(), "no shard replica configured");
4071 }
4072
4073 // `file::memory:` (and its query-string form) is a private in-memory target
4074 // and must be forced single-slot; a shared-cache in-memory DB is shareable
4075 // and must NOT be (addresses Codex P1).
4076 #[cfg(feature = "sqlite")]
4077 #[test]
4078 fn sqlite_target_is_memory_covers_file_memory() {
4079 assert!(sqlite_target_is_memory(":memory:"));
4080 assert!(sqlite_target_is_memory("file::memory:"));
4081 assert!(sqlite_target_is_memory("file::memory:?foo=bar"));
4082 assert!(sqlite_target_is_memory("file:app?mode=memory"));
4083 // Shared-cache in-memory databases are shareable across connections.
4084 assert!(!sqlite_target_is_memory("file::memory:?cache=shared"));
4085 assert!(!sqlite_target_is_memory(
4086 "file:app?mode=memory&cache=shared"
4087 ));
4088 // Plain file targets are not in-memory.
4089 assert!(!sqlite_target_is_memory("/var/lib/app.db"));
4090 }
4091
4092 // `sqlite_target_is_any_in_memory` is the broader predicate the
4093 // startup-migration reject uses: it classifies EVERY in-memory spelling —
4094 // private AND shared-cache — as in-memory, because none of them survive the
4095 // transient migration connection closing to reach the runtime pool (issue
4096 // #1614 follow-up). It must diverge from `sqlite_target_is_memory` (pool
4097 // sizing) precisely on `cache=shared`, which sizing keeps NOT-in-memory so a
4098 // shared-cache DB is never forced single-slot.
4099 #[cfg(feature = "sqlite")]
4100 #[test]
4101 fn sqlite_target_is_any_in_memory_covers_shared_cache() {
4102 // Private in-memory spellings — in-memory for BOTH predicates.
4103 assert!(sqlite_target_is_any_in_memory(":memory:"));
4104 assert!(sqlite_target_is_any_in_memory("sqlite::memory:"));
4105 assert!(sqlite_target_is_any_in_memory("sqlite://:memory:"));
4106 assert!(sqlite_target_is_any_in_memory("sqlite://"));
4107 assert!(sqlite_target_is_any_in_memory("file::memory:"));
4108 assert!(sqlite_target_is_any_in_memory("file::memory:?foo=bar"));
4109 // Shared-cache in-memory: `any_in_memory` → true (rejected for
4110 // migrations), but pool sizing's `sqlite_target_is_memory` → false (NOT
4111 // forced single-slot). This divergence is the whole point.
4112 assert!(sqlite_target_is_any_in_memory("file::memory:?cache=shared"));
4113 assert!(sqlite_target_is_any_in_memory(
4114 "file:app?mode=memory&cache=shared"
4115 ));
4116 assert!(!sqlite_target_is_memory("file::memory:?cache=shared"));
4117 assert!(!sqlite_target_is_memory(
4118 "file:app?mode=memory&cache=shared"
4119 ));
4120 // File-backed targets are never in-memory under either predicate.
4121 assert!(!sqlite_target_is_any_in_memory("sqlite:///var/lib/app.db"));
4122 assert!(!sqlite_target_is_any_in_memory("/var/lib/app.db"));
4123 assert!(!sqlite_target_is_any_in_memory("file:/var/lib/app.db"));
4124 }
4125
4126 // The transient SQLite migration connection must carry `PRAGMA busy_timeout`
4127 // (mirroring the runtime pool's per-connection setup) so a concurrent
4128 // migrator or a briefly-held write lock WAITS instead of failing immediately
4129 // with SQLITE_BUSY and aborting `auto_migrate_sqlite` (Codex P1). Proof: a
4130 // fresh migration connection reports the configured non-zero timeout.
4131 #[cfg(feature = "sqlite")]
4132 #[test]
4133 fn sqlite_migration_connection_sets_busy_timeout() {
4134 use diesel::RunQueryDsl as _;
4135
4136 #[derive(diesel::QueryableByName)]
4137 struct BusyTimeout {
4138 #[diesel(sql_type = diesel::sql_types::Integer)]
4139 timeout: i32,
4140 }
4141
4142 // A tempfile-backed target (not `:memory:`) so the connection maps to a
4143 // real database the pragma read reflects.
4144 let tmp = tempfile::TempDir::new().expect("temp dir");
4145 let db_path = tmp.path().join("migration.db");
4146 let url = format!("sqlite://{}", db_path.display());
4147
4148 let mut conn = super::establish_sqlite_migration_connection(&url)
4149 .expect("establish sqlite migration connection");
4150 let rows: Vec<BusyTimeout> = diesel::sql_query("PRAGMA busy_timeout")
4151 .load(&mut conn)
4152 .expect("read busy_timeout pragma");
4153 let timeout = rows
4154 .into_iter()
4155 .next()
4156 .expect("busy_timeout pragma returns a row")
4157 .timeout;
4158 assert_eq!(
4159 timeout, 5000,
4160 "the migration connection must carry the configured busy_timeout (ms) \
4161 so concurrent/locked migrators wait rather than hitting SQLITE_BUSY"
4162 );
4163 }
4164
4165 // A read-only URI target (`mode=ro` / `immutable`) must be detected so the
4166 // per-connection setup batch skips the write-affecting `journal_mode = WAL`
4167 // pragma, which otherwise fails with "attempt to write a readonly database"
4168 // and 503s the whole pool (Codex P2).
4169 #[cfg(feature = "sqlite")]
4170 #[test]
4171 fn sqlite_target_is_read_only_detects_ro_and_immutable() {
4172 assert!(sqlite_target_is_read_only("file:/srv/ref.db?mode=ro"));
4173 // Case-insensitive key/value.
4174 assert!(sqlite_target_is_read_only("file:/srv/ref.db?MODE=RO"));
4175 assert!(sqlite_target_is_read_only("file:/srv/ref.db?immutable=1"));
4176 assert!(sqlite_target_is_read_only(
4177 "file:/srv/ref.db?immutable=true"
4178 ));
4179 assert!(sqlite_target_is_read_only(
4180 "file:/srv/ref.db?immutable=TRUE"
4181 ));
4182 // Read-only marker alongside other params.
4183 assert!(sqlite_target_is_read_only(
4184 "file:/srv/ref.db?cache=shared&mode=ro"
4185 ));
4186
4187 // A plain file, an in-memory target, and a writable/shared-cache target
4188 // are NOT read-only.
4189 assert!(!sqlite_target_is_read_only("/var/lib/app.db"));
4190 assert!(!sqlite_target_is_read_only(":memory:"));
4191 assert!(!sqlite_target_is_read_only("file::memory:?cache=shared"));
4192 assert!(!sqlite_target_is_read_only("file:app?mode=memory"));
4193 assert!(!sqlite_target_is_read_only("file:/srv/ref.db?mode=rwc"));
4194 assert!(!sqlite_target_is_read_only("file:/srv/ref.db?immutable=0"));
4195 // A value that merely CONTAINS `ro` must not trip the substring trap.
4196 assert!(!sqlite_target_is_read_only("file:/srv/ro-data.db"));
4197 }
4198
4199 #[cfg(feature = "sqlite")]
4200 #[test]
4201 fn file_memory_sqlite_pool_is_single_slot() {
4202 let config = DatabaseConfig {
4203 primary_url: Some("file::memory:".into()),
4204 pool_size: 5,
4205 ..Default::default()
4206 };
4207 let pool = create_pool(&config)
4208 .expect("file::memory: sqlite pool builds")
4209 .expect("a configured url yields a pool");
4210 assert_eq!(
4211 pool.status().max_size,
4212 1,
4213 "a private in-memory target must be forced single-slot"
4214 );
4215 }
4216
4217 #[cfg(feature = "sqlite")]
4218 #[test]
4219 fn shared_cache_in_memory_sqlite_pool_is_not_forced_single_slot() {
4220 let config = DatabaseConfig {
4221 primary_url: Some("file::memory:?cache=shared".into()),
4222 pool_size: 5,
4223 ..Default::default()
4224 };
4225 let pool = create_pool(&config)
4226 .expect("shared-cache in-memory sqlite pool builds")
4227 .expect("a configured url yields a pool");
4228 assert_eq!(
4229 pool.status().max_size,
4230 5,
4231 "a shared-cache in-memory target must respect the configured size"
4232 );
4233 }
4234
4235 #[cfg(feature = "sqlite")]
4236 #[test]
4237 fn normalize_sqlite_target_strips_schemes() {
4238 assert_eq!(normalize_sqlite_target("sqlite::memory:"), ":memory:");
4239 assert_eq!(normalize_sqlite_target("sqlite://:memory:"), ":memory:");
4240 assert_eq!(normalize_sqlite_target("sqlite://"), ":memory:");
4241 assert_eq!(
4242 normalize_sqlite_target("sqlite:///var/lib/app.db"),
4243 "/var/lib/app.db"
4244 );
4245 assert_eq!(normalize_sqlite_target("sqlite:app.db"), "app.db");
4246 assert_eq!(
4247 normalize_sqlite_target("file:/var/lib/app.db"),
4248 "file:/var/lib/app.db"
4249 );
4250 }
4251
4252 #[test]
4253 fn pool_respects_max_size() {
4254 let config = DatabaseConfig {
4255 url: Some("postgres://localhost/test".into()),
4256 pool_size: 5,
4257 ..Default::default()
4258 };
4259 let pool = create_pool(&config)
4260 .expect("should build pool")
4261 .expect("should be Some");
4262 assert_eq!(pool.status().max_size, 5);
4263 }
4264
4265 #[test]
4266 fn pool_clamps_size_to_one_if_zero() {
4267 let config = DatabaseConfig {
4268 url: Some("postgres://localhost/test".into()),
4269 pool_size: 0,
4270 ..Default::default()
4271 };
4272 let pool = create_pool(&config)
4273 .expect("should build pool")
4274 .expect("should be Some");
4275 assert_eq!(
4276 pool.status().max_size,
4277 1,
4278 "Pool size should be clamped to 1"
4279 );
4280 }
4281
4282 // ── Db extractor tests ───────────────────────────────────────
4283
4284 #[test]
4285 fn database_topology_builds_primary_and_replica_pools() {
4286 let config = DatabaseConfig {
4287 primary_url: Some("postgres://localhost/primary".into()),
4288 replica_url: Some("postgres://localhost/replica".into()),
4289 primary_pool_size: Some(6),
4290 replica_pool_size: Some(2),
4291 ..Default::default()
4292 };
4293
4294 let topology = create_topology(&config)
4295 .expect("topology should build")
4296 .expect("topology should be configured");
4297
4298 assert_eq!(topology.primary().status().max_size, 6);
4299 assert_eq!(
4300 topology.replica().expect("replica pool").status().max_size,
4301 2
4302 );
4303 assert_eq!(topology.read().status().max_size, 2);
4304 }
4305
4306 #[test]
4307 fn database_topology_single_url_builds_only_primary_pool() {
4308 let config = DatabaseConfig {
4309 url: Some("postgres://localhost/single".into()),
4310 pool_size: 5,
4311 ..Default::default()
4312 };
4313
4314 let topology = create_topology(&config)
4315 .expect("topology should build")
4316 .expect("topology should be configured");
4317
4318 assert_eq!(topology.primary().status().max_size, 5);
4319 assert!(topology.replica().is_none());
4320 assert_eq!(topology.read().status().max_size, 5);
4321 }
4322
4323 #[test]
4324 fn config_runtime_drift_pool_applies_connect_timeout_to_wait_and_create() {
4325 let config = DatabaseConfig {
4326 url: Some("postgres://localhost/test".into()),
4327 connect_timeout_secs: 7,
4328 ..Default::default()
4329 };
4330 let pool = create_pool(&config)
4331 .expect("should build pool")
4332 .expect("should be Some");
4333
4334 let timeouts = pool.timeouts();
4335 assert_eq!(timeouts.wait, Some(Duration::from_secs(7)));
4336 assert_eq!(timeouts.create, Some(Duration::from_secs(7)));
4337 }
4338
4339 #[derive(Clone)]
4340 struct TestDbState;
4341
4342 impl DbState for TestDbState {
4343 fn pool(&self) -> Option<&Pool<crate::db::RuntimeConnection>> {
4344 None
4345 }
4346 }
4347
4348 #[derive(Clone)]
4349 struct TestReadState {
4350 primary: Pool<crate::db::RuntimeConnection>,
4351 }
4352
4353 impl DbState for TestReadState {
4354 fn pool(&self) -> Option<&Pool<crate::db::RuntimeConnection>> {
4355 Some(&self.primary)
4356 }
4357 }
4358
4359 #[test]
4360 fn database_topology_read_pool_falls_back_to_primary() {
4361 let config = DatabaseConfig {
4362 url: Some("postgres://localhost/read-fallback".into()),
4363 pool_size: 3,
4364 ..Default::default()
4365 };
4366 let primary = create_pool(&config).unwrap().unwrap();
4367 let state = TestReadState { primary };
4368
4369 assert_eq!(state.read_pool().expect("read pool").status().max_size, 3);
4370 }
4371
4372 #[tokio::test]
4373 async fn db_extractor_rejects_when_no_pool() {
4374 use axum::Router;
4375 use axum::body::Body;
4376 use axum::http::{Request, StatusCode};
4377 use axum::routing::get;
4378 use tower::ServiceExt;
4379
4380 async fn handler(_db: Db) -> &'static str {
4381 "ok"
4382 }
4383
4384 let app = Router::new()
4385 .route("/", get(handler))
4386 .with_state(TestDbState);
4387
4388 let response = app
4389 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4390 .await
4391 .unwrap();
4392
4393 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
4394 }
4395
4396 #[tokio::test]
4397 async fn database_topology_primary_only_has_no_replica() {
4398 let config = DatabaseConfig {
4399 primary_url: Some("postgres://user:pass@localhost/db".to_string()),
4400 ..DatabaseConfig::default()
4401 };
4402 let topology = create_topology(&config).unwrap().unwrap();
4403
4404 let primary = topology.primary().clone();
4405
4406 let new_topology = DatabaseTopology::primary_only(primary);
4407 assert!(
4408 new_topology.replica().is_none(),
4409 "primary_only must set replica to None"
4410 );
4411 }
4412
4413 #[tokio::test]
4414 async fn database_topology_from_pools_retains_replica() {
4415 let config = DatabaseConfig {
4416 primary_url: Some("postgres://user:pass@localhost/db".to_string()),
4417 replica_url: Some("postgres://user:pass@localhost/db_replica".to_string()),
4418 ..DatabaseConfig::default()
4419 };
4420 let topology = create_topology(&config).unwrap().unwrap();
4421
4422 let primary = topology.primary().clone();
4423 let replica = topology.replica().cloned();
4424
4425 let new_topology = DatabaseTopology::from_pools(primary, replica);
4426 assert!(
4427 new_topology.replica().is_some(),
4428 "from_pools must preserve the replica pool"
4429 );
4430 }
4431
4432 // ── scrub_sql tests ───────────────────────────────────────────────────────
4433
4434 #[test]
4435 fn scrub_sql_strips_string_literals() {
4436 assert_eq!(
4437 super::scrub_sql("SELECT * FROM users WHERE name = 'Alice'"),
4438 "SELECT * FROM users WHERE name = '?'"
4439 );
4440 }
4441
4442 #[test]
4443 fn scrub_sql_strips_numeric_literals() {
4444 assert_eq!(
4445 super::scrub_sql("SELECT * FROM orders WHERE id = 42"),
4446 "SELECT * FROM orders WHERE id = ?"
4447 );
4448 }
4449
4450 #[test]
4451 fn scrub_sql_preserves_pg_positional_params() {
4452 assert_eq!(
4453 super::scrub_sql("SELECT * FROM t WHERE x = $1 AND y = $2"),
4454 "SELECT * FROM t WHERE x = $1 AND y = $2"
4455 );
4456 }
4457
4458 #[test]
4459 fn scrub_sql_does_not_stomp_identifiers() {
4460 // "table1" should not be replaced because it's not preceded by a separator
4461 assert_eq!(
4462 super::scrub_sql("SELECT * FROM table1 WHERE active = true"),
4463 "SELECT * FROM table1 WHERE active = true"
4464 );
4465 }
4466
4467 #[test]
4468 fn scrub_sql_multiple_literals_in_one_query() {
4469 assert_eq!(
4470 super::scrub_sql("INSERT INTO users (name, age) VALUES ('Bob', 30)"),
4471 "INSERT INTO users (name, age) VALUES ('?', ?)"
4472 );
4473 }
4474
4475 #[test]
4476 fn scrub_sql_handles_escaped_single_quotes() {
4477 assert_eq!(
4478 super::scrub_sql("SELECT * FROM t WHERE s = 'it''s a test'"),
4479 "SELECT * FROM t WHERE s = '?'"
4480 );
4481 }
4482
4483 #[test]
4484 fn scrub_sql_empty_string() {
4485 assert_eq!(super::scrub_sql(""), "");
4486 }
4487
4488 // ── Bug fixes: exponent suffix, dollar-quoted strings, E-string escapes ──
4489
4490 #[test]
4491 fn scrub_sql_scientific_notation_integer_exponent() {
4492 // 1e6 should be fully redacted to ? (the "e6" part is the exponent)
4493 assert_eq!(
4494 super::scrub_sql("SELECT * FROM t WHERE n = 1e6"),
4495 "SELECT * FROM t WHERE n = ?"
4496 );
4497 }
4498
4499 #[test]
4500 fn scrub_sql_scientific_notation_float_exponent() {
4501 // 2.5E-4 should be fully redacted: digit + decimal + E + sign + digit(s)
4502 assert_eq!(
4503 super::scrub_sql("SELECT * FROM t WHERE n = 2.5E-4"),
4504 "SELECT * FROM t WHERE n = ?"
4505 );
4506 }
4507
4508 #[test]
4509 fn scrub_sql_scientific_notation_uppercase_positive_exponent() {
4510 // 3E+10 — uppercase E with explicit + sign
4511 assert_eq!(
4512 super::scrub_sql("SELECT * FROM t WHERE n = 3E+10"),
4513 "SELECT * FROM t WHERE n = ?"
4514 );
4515 }
4516
4517 #[test]
4518 fn scrub_sql_dollar_quoted_anonymous() {
4519 // $$...$$ dollar-quoted string: content must be fully redacted
4520 assert_eq!(super::scrub_sql("SELECT $$secret value$$"), "SELECT '?'");
4521 }
4522
4523 #[test]
4524 fn scrub_sql_dollar_quoted_with_tag() {
4525 // $tag$...$tag$ — tagged dollar-quoted string
4526 assert_eq!(
4527 super::scrub_sql("SELECT $body$hello world$body$"),
4528 "SELECT '?'"
4529 );
4530 }
4531
4532 #[test]
4533 fn scrub_sql_dollar_quoted_does_not_affect_positional_params() {
4534 // $1, $2 positional params must still pass through unmodified
4535 assert_eq!(
4536 super::scrub_sql("SELECT $1, $2 FROM $$secret$$ WHERE id = $3"),
4537 "SELECT $1, $2 FROM '?' WHERE id = $3"
4538 );
4539 }
4540
4541 #[test]
4542 fn scrub_sql_estring_backslash_escaped_quote() {
4543 // E'it\'s secret' — backslash-escaped quote inside E'' string
4544 assert_eq!(
4545 super::scrub_sql(r"SELECT E'it\'s secret' FROM t"),
4546 "SELECT '?' FROM t"
4547 );
4548 }
4549
4550 #[test]
4551 fn scrub_sql_estring_uppercase() {
4552 // Uppercase E prefix variant E'...'
4553 assert_eq!(
4554 super::scrub_sql("SELECT E'hello world' FROM t"),
4555 "SELECT '?' FROM t"
4556 );
4557 }
4558
4559 #[test]
4560 fn scrub_sql_estring_multiple_backslash_escapes() {
4561 // Multiple backslash sequences inside one E'' literal
4562 assert_eq!(
4563 super::scrub_sql(r"SELECT E'line1\nline2' FROM t"),
4564 "SELECT '?' FROM t"
4565 );
4566 }
4567
4568 #[test]
4569 fn scrub_sql_leading_dot_numeric_literals() {
4570 assert_eq!(super::scrub_sql("SELECT .5"), "SELECT ?");
4571 assert_eq!(super::scrub_sql("SELECT .25 + .75"), "SELECT ? + ?");
4572 assert_eq!(super::scrub_sql("SELECT t.col"), "SELECT t.col");
4573 assert_eq!(
4574 super::scrub_sql("SELECT schema.table.col"),
4575 "SELECT schema.table.col"
4576 );
4577 }
4578
4579 #[test]
4580 fn scrub_sql_estring_doubled_quote_escape() {
4581 assert_eq!(
4582 super::scrub_sql("SELECT E'it''s secret' FROM t"),
4583 "SELECT '?' FROM t"
4584 );
4585 }
4586
4587 #[test]
4588 fn scrub_sql_numeric_literal_underscore_grouping() {
4589 assert_eq!(super::scrub_sql("SELECT 5_432_000"), "SELECT ?");
4590 assert_eq!(super::scrub_sql("SELECT 1_000.5_0"), "SELECT ?");
4591 assert_eq!(super::scrub_sql("SELECT col_5_val"), "SELECT col_5_val");
4592 assert_eq!(super::scrub_sql("SELECT col_5"), "SELECT col_5");
4593 }
4594
4595 // ── Transaction isolation + retry (issue #1202) ─────────────────
4596
4597 #[derive(Debug)]
4598 struct FakeDbErrorInfo {
4599 message: &'static str,
4600 }
4601
4602 impl diesel::result::DatabaseErrorInformation for FakeDbErrorInfo {
4603 fn message(&self) -> &str {
4604 self.message
4605 }
4606 fn details(&self) -> Option<&str> {
4607 None
4608 }
4609 fn hint(&self) -> Option<&str> {
4610 None
4611 }
4612 fn table_name(&self) -> Option<&str> {
4613 None
4614 }
4615 fn column_name(&self) -> Option<&str> {
4616 None
4617 }
4618 fn constraint_name(&self) -> Option<&str> {
4619 None
4620 }
4621 fn statement_position(&self) -> Option<i32> {
4622 None
4623 }
4624 }
4625
4626 fn diesel_db_error(
4627 kind: diesel::result::DatabaseErrorKind,
4628 message: &'static str,
4629 ) -> diesel::result::Error {
4630 diesel::result::Error::DatabaseError(kind, Box::new(FakeDbErrorInfo { message }))
4631 }
4632
4633 // ── TxOptions builder ────────────────────────────────────────
4634
4635 #[test]
4636 fn tx_options_default_is_read_committed_no_retry() {
4637 let opts = TxOptions::default();
4638 assert_eq!(opts.isolation, IsolationLevel::ReadCommitted);
4639 assert!(!opts.read_only);
4640 assert!(!opts.deferrable);
4641 assert_eq!(
4642 opts.max_attempts, 1,
4643 "default must behave exactly like today's tx(): one attempt, no retry"
4644 );
4645 }
4646
4647 #[test]
4648 fn tx_options_serializable_enables_retry() {
4649 let opts = TxOptions::serializable();
4650 assert_eq!(opts.isolation, IsolationLevel::Serializable);
4651 assert!(
4652 opts.max_attempts > 1,
4653 "serializable() should default to retrying since retry is the point"
4654 );
4655 }
4656
4657 #[test]
4658 fn tx_options_repeatable_read_enables_retry() {
4659 let opts = TxOptions::repeatable_read();
4660 assert_eq!(opts.isolation, IsolationLevel::RepeatableRead);
4661 assert!(opts.max_attempts > 1);
4662 }
4663
4664 #[test]
4665 fn tx_options_builder_chains() {
4666 let opts = TxOptions::serializable()
4667 .read_only()
4668 .deferrable()
4669 .max_attempts(9)
4670 .initial_backoff(Duration::from_millis(3))
4671 .max_backoff(Duration::from_millis(30));
4672 assert!(opts.read_only);
4673 assert!(opts.deferrable);
4674 assert_eq!(opts.max_attempts, 9);
4675 assert_eq!(opts.initial_backoff, Duration::from_millis(3));
4676 assert_eq!(opts.max_backoff, Duration::from_millis(30));
4677 }
4678
4679 #[test]
4680 fn tx_options_max_attempts_clamps_to_at_least_one() {
4681 // A zero here must never produce a loop that skips the closure entirely.
4682 assert_eq!(TxOptions::default().max_attempts(0).max_attempts, 1);
4683 }
4684
4685 #[test]
4686 fn tx_options_effective_max_attempts_clamps_struct_literal_bypass() {
4687 // `max_attempts` is a public field, so struct-literal update syntax can
4688 // set it to 0 directly, bypassing the `max_attempts()` builder's clamp.
4689 // The retry loop must still treat this as "run once", not "never run".
4690 let opts = TxOptions {
4691 max_attempts: 0,
4692 ..TxOptions::default()
4693 };
4694 assert_eq!(opts.max_attempts, 0, "the raw field is not itself clamped");
4695 assert_eq!(
4696 opts.effective_max_attempts(),
4697 1,
4698 "the retry loop's accessor must clamp a struct-literal 0 to 1"
4699 );
4700 }
4701
4702 // ── Backoff ──────────────────────────────────────────────────
4703
4704 #[test]
4705 fn retry_backoff_base_doubles_per_attempt() {
4706 let initial = Duration::from_millis(5);
4707 let max = Duration::from_secs(60);
4708 assert_eq!(
4709 retry_backoff_base(initial, max, 1),
4710 Duration::from_millis(5)
4711 );
4712 assert_eq!(
4713 retry_backoff_base(initial, max, 2),
4714 Duration::from_millis(10)
4715 );
4716 assert_eq!(
4717 retry_backoff_base(initial, max, 3),
4718 Duration::from_millis(20)
4719 );
4720 assert_eq!(
4721 retry_backoff_base(initial, max, 4),
4722 Duration::from_millis(40)
4723 );
4724 }
4725
4726 #[test]
4727 fn retry_backoff_base_caps_at_max() {
4728 let initial = Duration::from_millis(5);
4729 let max = Duration::from_millis(50);
4730 // 5,10,20,40 then capped at 50 for all higher attempts.
4731 assert_eq!(
4732 retry_backoff_base(initial, max, 5),
4733 Duration::from_millis(50)
4734 );
4735 assert_eq!(retry_backoff_base(initial, max, 40), max);
4736 // Huge attempt must not panic on shift overflow.
4737 assert_eq!(retry_backoff_base(initial, max, u32::MAX), max);
4738 }
4739
4740 #[test]
4741 fn retry_backoff_delay_stays_within_jitter_bounds_and_cap() {
4742 let initial = Duration::from_millis(100);
4743 let max = Duration::from_secs(10);
4744 for attempt in 1..=6u32 {
4745 let base = retry_backoff_base(initial, max, attempt);
4746 for _ in 0..64 {
4747 let d = retry_backoff_delay(initial, max, attempt);
4748 // Jitter is +/-20% of base, never exceeding the cap.
4749 assert!(
4750 d >= base.mul_f64(0.8) && d <= base.mul_f64(1.2),
4751 "delay {d:?} out of jitter bounds for base {base:?}"
4752 );
4753 assert!(d <= max, "delay {d:?} exceeded cap {max:?}");
4754 }
4755 }
4756 }
4757
4758 // ── Retryable-error classification ───────────────────────────
4759
4760 #[test]
4761 fn serialization_failure_is_retryable() {
4762 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4763 diesel::result::DatabaseErrorKind::SerializationFailure,
4764 "could not serialize access due to read/write dependencies among transactions",
4765 ));
4766 assert!(is_retryable_txn_error(&err));
4767 }
4768
4769 #[test]
4770 fn deadlock_is_retryable() {
4771 // Postgres 40P01 is not mapped to a dedicated DatabaseErrorKind, so it
4772 // is caught via an exact match against Postgres's own invariant
4773 // primary message for this condition.
4774 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4775 diesel::result::DatabaseErrorKind::Unknown,
4776 "deadlock detected",
4777 ));
4778 assert!(is_retryable_txn_error(&err));
4779 }
4780
4781 #[test]
4782 fn deadlock_message_is_matched_case_and_whitespace_insensitively() {
4783 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4784 diesel::result::DatabaseErrorKind::Unknown,
4785 " Deadlock Detected ",
4786 ));
4787 assert!(is_retryable_txn_error(&err));
4788 }
4789
4790 #[test]
4791 fn unknown_kind_message_merely_mentioning_deadlock_is_not_retryable() {
4792 // Regression (PR #1581 review, round 2): an `Unknown`-kind Postgres
4793 // error from application SQL (e.g. `RAISE EXCEPTION`) whose message
4794 // merely *mentions* "deadlock detected" or "40001" as part of a
4795 // longer business message must not be retried -- only an exact match
4796 // against Postgres's own invariant primary message counts.
4797 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4798 diesel::result::DatabaseErrorKind::Unknown,
4799 "order 40001 could not be processed: deadlock detected in workflow",
4800 ));
4801 assert!(!is_retryable_txn_error(&err));
4802 }
4803
4804 #[test]
4805 fn unknown_kind_40001_text_alone_is_not_retryable() {
4806 // A bare "40001" in an Unknown-kind message is no longer treated as a
4807 // retry signal -- genuine 40001s are already reliably classified via
4808 // the DatabaseErrorKind::SerializationFailure fast path (diesel-async
4809 // maps the real SQLSTATE), so this text pattern is dead weight that
4810 // only added false-positive risk.
4811 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4812 diesel::result::DatabaseErrorKind::Unknown,
4813 "ERROR: 40001: could not serialize access",
4814 ));
4815 assert!(!is_retryable_txn_error(&err));
4816 }
4817
4818 #[test]
4819 fn unique_violation_is_not_retryable() {
4820 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4821 diesel::result::DatabaseErrorKind::UniqueViolation,
4822 "duplicate key value violates unique constraint",
4823 ));
4824 assert!(!is_retryable_txn_error(&err));
4825 }
4826
4827 #[test]
4828 fn unique_violation_with_magic_substring_in_message_is_not_retryable() {
4829 // Regression: a non-retryable `DatabaseErrorKind` must never be
4830 // misclassified just because its message/constraint text happens to
4831 // contain a magic substring like "40001" or "deadlock detected".
4832 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
4833 diesel::result::DatabaseErrorKind::UniqueViolation,
4834 "duplicate key value violates unique constraint \"orders_40001_key\"",
4835 ));
4836 assert!(!is_retryable_txn_error(&err));
4837 }
4838
4839 #[test]
4840 fn plain_internal_error_is_not_retryable() {
4841 let err = AutumnError::internal_server_error_msg("something unrelated broke");
4842 assert!(!is_retryable_txn_error(&err));
4843 }
4844
4845 #[test]
4846 fn domain_error_with_magic_substring_and_no_db_error_is_not_retryable() {
4847 // Regression (PR #1581 review): a plain domain/validation error — no
4848 // diesel::result::Error or tokio_postgres error anywhere in its
4849 // chain — must never be retried just because its message happens to
4850 // contain a magic substring like "40001" or "deadlock detected".
4851 // Retry requires a structured signal, never bare text.
4852 let err = AutumnError::bad_request_msg(
4853 "order 40001 could not be processed: deadlock detected in workflow",
4854 );
4855 assert!(!is_retryable_txn_error(&err));
4856 }
4857
4858 #[derive(Debug, thiserror::Error)]
4859 #[error("app error")]
4860 struct WrappedDbError(#[source] diesel::result::Error);
4861
4862 #[test]
4863 fn serialization_failure_wrapped_in_custom_error_is_retryable() {
4864 // A custom `E` (e.g. an app error enum with a `#[from] diesel::result::Error`
4865 // variant) is not itself a `diesel::result::Error`, so it isn't found by
4866 // a top-level-only downcast. `downcast_chain_ref` walks `source()` to
4867 // find it, so wrapped diesel errors are still classified structurally
4868 // — no need to fall back to message scanning for this common case.
4869 let err: AutumnError = AutumnError::internal_server_error(WrappedDbError(diesel_db_error(
4870 diesel::result::DatabaseErrorKind::SerializationFailure,
4871 "could not serialize access due to read/write dependencies among transactions",
4872 )));
4873 assert!(is_retryable_txn_error(&err));
4874 }
4875
4876 #[test]
4877 fn unique_violation_wrapped_in_custom_error_is_not_retryable() {
4878 let err: AutumnError = AutumnError::internal_server_error(WrappedDbError(diesel_db_error(
4879 diesel::result::DatabaseErrorKind::UniqueViolation,
4880 "duplicate key value violates unique constraint",
4881 )));
4882 assert!(!is_retryable_txn_error(&err));
4883 }
4884
4885 // ── Attempt accounting ───────────────────────────────────────
4886
4887 #[test]
4888 fn retry_decision_retries_until_attempts_exhausted() {
4889 // A retryable error keeps retrying while attempts remain, then stops.
4890 let max = 3;
4891 assert_eq!(retry_decision(1, max, true), RetryDecision::Retry);
4892 assert_eq!(retry_decision(2, max, true), RetryDecision::Retry);
4893 assert_eq!(
4894 retry_decision(3, max, true),
4895 RetryDecision::Stop,
4896 "the final attempt must stop even when the error is retryable (exhausted)"
4897 );
4898 }
4899
4900 #[test]
4901 fn retry_decision_stops_immediately_on_non_retryable() {
4902 assert_eq!(retry_decision(1, 5, false), RetryDecision::Stop);
4903 }
4904
4905 #[test]
4906 fn retry_decision_single_attempt_never_retries() {
4907 // max_attempts == 1 (the default) must behave exactly like today's tx().
4908 assert_eq!(retry_decision(1, 1, true), RetryDecision::Stop);
4909 }
4910}
4911
4912// ── Postgres TLS (sslmode) support ───────────────────────────────────────────
4913
4914/// TLS support for the Postgres pool, driven by `sslmode` in the database URL.
4915///
4916/// diesel-async's default `AsyncPgConnection::establish` hardcodes
4917/// `tokio_postgres::NoTls`, which makes `sslmode=require` fail on every
4918/// connection with "no TLS implementation configured". This module plugs a
4919/// rustls-backed connector into the pool via
4920/// [`diesel_async::pooled_connection::ManagerConfig::custom_setup`] when the
4921/// URL asks for TLS. Both URL (`postgres://…?sslmode=require`) and
4922/// keyword/value (`host=… sslmode=require`) connection strings are
4923/// recognized. The synchronous migration/startup-wait path shares the same
4924/// connector through [`super::establish_migration_connection`] — the
4925/// bundled libpq has no SSL support, so the native `PgConnection` path
4926/// cannot reach TLS-only servers at all.
4927///
4928/// | `sslmode` | behavior |
4929/// | ----------------------------- | -------- |
4930/// | absent, `disable`, `prefer` | unchanged: the default `NoTls` path (plaintext), exactly as before this module existed |
4931/// | `require` | TLS. The connection is encrypted and the handshake signatures are verified, but the server's certificate **chain/identity is not** — matching what `libpq`/`psql` do for `require` (self-signed and private-CA servers work) |
4932/// | `verify-full` | TLS with full chain **and** hostname verification against the Mozilla root store (`webpki-roots`), plus any `sslrootcert=<PEM file>` from the URL |
4933/// | `verify-ca` | rejected with guidance: chain-only verification is not implemented; use `verify-full` (stricter) or `require` |
4934///
4935/// `require` combined with `sslrootcert` is also rejected rather than
4936/// silently ignoring the CA file: `libpq` documents that combination as
4937/// upgrading to certificate verification, so dropping the file would
4938/// silently weaken what the operator asked for.
4939mod tls {
4940 use std::sync::Arc;
4941
4942 use diesel::{ConnectionError, ConnectionResult};
4943 use diesel_async::pooled_connection::SetupCallback;
4944 use diesel_async::{AsyncConnection as _, AsyncPgConnection};
4945 use futures::FutureExt as _;
4946 use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
4947 use rustls::crypto::CryptoProvider;
4948 use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
4949 use rustls::{DigitallySignedStruct, SignatureScheme};
4950 use tokio_postgres_rustls::MakeRustlsConnect;
4951
4952 /// TLS posture derived from the connection string's `sslmode` (and
4953 /// `sslrootcert`). See the [module docs](self) for the full table.
4954 #[derive(Debug, Clone, PartialEq, Eq)]
4955 pub(super) enum TlsPosture {
4956 /// No TLS machinery: keep diesel-async's default `NoTls` setup path.
4957 Off,
4958 /// Encrypt without verifying the server certificate chain
4959 /// (`libpq` parity for `sslmode=require`).
4960 Require,
4961 /// Encrypt and fully verify the certificate chain + hostname.
4962 VerifyFull {
4963 /// Optional `sslrootcert` PEM file to trust in addition to the
4964 /// Mozilla root store.
4965 root_cert: Option<String>,
4966 },
4967 /// A recognized-but-unsupported combination; every connection attempt
4968 /// fails loudly with this reason instead of silently downgrading.
4969 Unsupported { reason: String },
4970 }
4971
4972 impl TlsPosture {
4973 /// Classify a database URL / keyword-value connection string.
4974 pub(super) fn from_database_url(database_url: &str) -> Self {
4975 let params = ssl_params(database_url);
4976 // Last occurrence wins, matching libpq/tokio-postgres semantics.
4977 let get = |key: &str| {
4978 params
4979 .iter()
4980 .rev()
4981 .find(|(k, _)| k == key)
4982 .map(|(_, v)| v.as_str())
4983 };
4984 let root_cert = get("sslrootcert").map(str::to_owned);
4985 match get("sslmode") {
4986 Some("require") => {
4987 if root_cert.is_some() {
4988 Self::Unsupported {
4989 reason: "sslmode=require with sslrootcert is not supported: \
4990 PostgreSQL treats that combination as requiring \
4991 certificate verification against the CA file, which \
4992 this pool implements only for sslmode=verify-full. \
4993 Use sslmode=verify-full to verify the certificate, or \
4994 sslmode=require without sslrootcert to encrypt without \
4995 verifying it."
4996 .to_owned(),
4997 }
4998 } else {
4999 Self::Require
5000 }
5001 }
5002 Some("verify-full") => Self::VerifyFull { root_cert },
5003 Some("verify-ca") => Self::Unsupported {
5004 reason: "sslmode=verify-ca is not supported: chain-only verification \
5005 (without hostname checking) is not implemented. Use \
5006 sslmode=verify-full (stricter) or sslmode=require (encrypts \
5007 without verifying the certificate)."
5008 .to_owned(),
5009 },
5010 // Absent, `disable`, `prefer`, or anything unrecognized:
5011 // preserve the pre-TLS behavior exactly (including the error
5012 // tokio-postgres itself raises for invalid values).
5013 _ => Self::Off,
5014 }
5015 }
5016 }
5017
5018 /// Build the pool's custom connection-setup callback for a TLS posture.
5019 // Only the default (Postgres) `build_pool` arm installs this custom TLS
5020 // setup; unused in a `--features sqlite` build (SQLite has no TLS transport).
5021 #[cfg_attr(feature = "sqlite", allow(dead_code))]
5022 pub(super) fn setup_callback(posture: TlsPosture) -> SetupCallback<AsyncPgConnection> {
5023 Box::new(move |url: &str| {
5024 let posture = posture.clone();
5025 let url = url.to_owned();
5026 async move { establish(&url, posture).await }.boxed()
5027 })
5028 }
5029
5030 /// Establish an [`AsyncPgConnection`] honoring `posture` — the single
5031 /// connect path shared by the pool's setup callback and the synchronous
5032 /// migration/wait-check wrapper
5033 /// ([`super::establish_migration_connection`]).
5034 pub(super) async fn establish(
5035 url: &str,
5036 posture: TlsPosture,
5037 ) -> ConnectionResult<AsyncPgConnection> {
5038 match posture {
5039 // Defensive: `build_pool` never installs the callback for
5040 // `Off`, but fall back to the stock path if it ever does.
5041 TlsPosture::Off => AsyncPgConnection::establish(url).await,
5042 TlsPosture::Require => connect_with(url, relaxed_connector()?).await,
5043 TlsPosture::VerifyFull { root_cert } => {
5044 // tokio-postgres's own connection-string parser
5045 // rejects `verify-*` and `sslrootcert`, so hand it a
5046 // sanitized string; the real verification lives in
5047 // the rustls config.
5048 let sanitized = sanitize_for_verify_full(url);
5049 connect_with(&sanitized, verifying_connector(root_cert.as_deref())?).await
5050 }
5051 TlsPosture::Unsupported { reason } => {
5052 Err(ConnectionError::InvalidConnectionUrl(reason))
5053 }
5054 }
5055 }
5056
5057 async fn connect_with(
5058 url: &str,
5059 tls: MakeRustlsConnect,
5060 ) -> ConnectionResult<AsyncPgConnection> {
5061 let (client, connection) = tokio_postgres::connect(url, tls).await.map_err(|e| {
5062 // tokio_postgres's Display gives only the error kind ("error
5063 // performing TLS handshake"); append the source so operators see
5064 // the actionable cause ("server does not support TLS", a
5065 // certificate verification failure, …).
5066 let msg = std::error::Error::source(&e)
5067 .map_or_else(|| e.to_string(), |source| format!("{e}: {source}"));
5068 ConnectionError::BadConnection(msg)
5069 })?;
5070 AsyncPgConnection::try_from_client_and_connection(client, connection).await
5071 }
5072
5073 /// Encrypt-only connector for `sslmode=require`: handshake signatures are
5074 /// verified but the certificate chain/identity is not, mirroring
5075 /// `libpq`/`psql` behavior for `require` (which self-signed and
5076 /// private-CA deployments rely on).
5077 fn relaxed_connector() -> Result<MakeRustlsConnect, ConnectionError> {
5078 let provider = Arc::new(rustls::crypto::ring::default_provider());
5079 let config = rustls::ClientConfig::builder_with_provider(provider.clone())
5080 .with_safe_default_protocol_versions()
5081 .map_err(|e| {
5082 ConnectionError::BadConnection(format!("failed to build TLS config: {e}"))
5083 })?
5084 .dangerous()
5085 .with_custom_certificate_verifier(Arc::new(NoServerCertVerification(
5086 (*provider).clone(),
5087 )))
5088 .with_no_client_auth();
5089 Ok(MakeRustlsConnect::new(config))
5090 }
5091
5092 /// Fully verifying connector for `sslmode=verify-full`: certificate chain
5093 /// and hostname are checked against the Mozilla root store plus any
5094 /// `sslrootcert` PEM file from the connection string. `webpki-roots` is
5095 /// used (rather than the platform store) so behavior is deterministic on
5096 /// every target, including mobile, where no native store is reachable.
5097 fn verifying_connector(root_cert: Option<&str>) -> Result<MakeRustlsConnect, ConnectionError> {
5098 let mut roots = rustls::RootCertStore::empty();
5099 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
5100 if let Some(path) = root_cert {
5101 use rustls_pki_types::pem::PemObject as _;
5102 let certs = CertificateDer::pem_file_iter(path).map_err(|e| {
5103 ConnectionError::BadConnection(format!("failed to read sslrootcert {path}: {e}"))
5104 })?;
5105 for cert in certs {
5106 let cert = cert.map_err(|e| {
5107 ConnectionError::BadConnection(format!(
5108 "failed to parse sslrootcert {path}: {e}"
5109 ))
5110 })?;
5111 roots.add(cert).map_err(|e| {
5112 ConnectionError::BadConnection(format!(
5113 "failed to trust sslrootcert {path}: {e}"
5114 ))
5115 })?;
5116 }
5117 }
5118 let provider = Arc::new(rustls::crypto::ring::default_provider());
5119 let config = rustls::ClientConfig::builder_with_provider(provider)
5120 .with_safe_default_protocol_versions()
5121 .map_err(|e| {
5122 ConnectionError::BadConnection(format!("failed to build TLS config: {e}"))
5123 })?
5124 .with_root_certificates(roots)
5125 .with_no_client_auth();
5126 Ok(MakeRustlsConnect::new(config))
5127 }
5128
5129 /// Whether tokio-postgres would parse this connection string as a URL —
5130 /// see [`crate::pg_conn_str::is_url`]. Getting this wrong would send
5131 /// keyword strings whose values embed URL-like tokens
5132 /// (`password=https://…`) down the URL path, where `url::Url::parse`
5133 /// fails and the TLS posture silently falls back to [`TlsPosture::Off`].
5134 /// Shared with config validation so the two never disagree about which
5135 /// strings are reachable.
5136 use crate::pg_conn_str::is_url as is_url_connection_string;
5137 /// Parse a libpq-style `key = value` connection string, mirroring
5138 /// tokio-postgres's (private) parser — see
5139 /// [`crate::pg_conn_str::keyword_value_pairs`]. Naive whitespace
5140 /// splitting would miss `sslmode` in quoted/spaced strings and silently
5141 /// downgrade the TLS posture to [`TlsPosture::Off`].
5142 use crate::pg_conn_str::keyword_value_pairs;
5143
5144 /// Extract query/keyword parameters relevant to TLS from either a
5145 /// `postgres://…` URL or a `key=value …` connection string.
5146 fn ssl_params(database_url: &str) -> Vec<(String, String)> {
5147 if is_url_connection_string(database_url) {
5148 url::Url::parse(database_url)
5149 .map(|u| {
5150 u.query_pairs()
5151 .map(|(k, v)| (k.into_owned(), v.into_owned()))
5152 .collect()
5153 })
5154 .unwrap_or_default()
5155 } else {
5156 keyword_value_pairs(database_url).unwrap_or_default()
5157 }
5158 }
5159
5160 /// Serialize one value of a keyword/value connection string, quoting it
5161 /// whenever it would not survive re-parsing as a bare token.
5162 fn quote_keyword_value(value: &str) -> String {
5163 if !value.is_empty()
5164 && !value.contains(|c: char| c.is_whitespace() || c == '\'' || c == '\\')
5165 {
5166 return value.to_owned();
5167 }
5168 let mut quoted = String::with_capacity(value.len() + 2);
5169 quoted.push('\'');
5170 for c in value.chars() {
5171 if c == '\'' || c == '\\' {
5172 quoted.push('\\');
5173 }
5174 quoted.push(c);
5175 }
5176 quoted.push('\'');
5177 quoted
5178 }
5179
5180 /// Rewrite a `verify-full` connection string into one tokio-postgres can
5181 /// parse: `sslmode` downgraded to `require` (the handshake decision), and
5182 /// `sslrootcert` removed (consumed by [`verifying_connector`] instead).
5183 fn sanitize_for_verify_full(database_url: &str) -> String {
5184 if is_url_connection_string(database_url) {
5185 let Ok(mut parsed) = url::Url::parse(database_url) else {
5186 return database_url.to_owned();
5187 };
5188 // Rewrite ONLY the sslmode/sslrootcert components, preserving
5189 // every other raw query component byte-for-byte: decoding and
5190 // re-serializing through query_pairs()/append_pair() would
5191 // form-encode spaces as `+`, which libpq/tokio-postgres do not
5192 // accept in Postgres URI parameters —
5193 // `options=-c%20search_path%3Dtenant` would be mangled into
5194 // `options=-c+search_path=tenant`. (The startup-wait splice in
5195 // migrate.rs avoids those APIs for the same reason.)
5196 let raw = parsed
5197 .query()
5198 .unwrap_or("")
5199 .split('&')
5200 .filter(|component| !component.is_empty())
5201 .filter_map(|component| {
5202 let key = component.split('=').next().unwrap_or(component);
5203 match key {
5204 "sslmode" => Some("sslmode=require"),
5205 "sslrootcert" => None,
5206 _ => Some(component),
5207 }
5208 })
5209 .collect::<Vec<_>>()
5210 .join("&");
5211 if raw.is_empty() {
5212 parsed.set_query(None);
5213 } else {
5214 parsed.set_query(Some(&raw));
5215 }
5216 parsed.to_string()
5217 } else {
5218 let Some(pairs) = keyword_value_pairs(database_url) else {
5219 // Malformed: pass through so tokio-postgres reports its own
5220 // parse error instead of us inventing a different string.
5221 return database_url.to_owned();
5222 };
5223 pairs
5224 .into_iter()
5225 .filter_map(|(k, v)| match k.as_str() {
5226 "sslmode" => Some("sslmode=require".to_owned()),
5227 "sslrootcert" => None,
5228 _ => Some(format!("{k}={}", quote_keyword_value(&v))),
5229 })
5230 .collect::<Vec<_>>()
5231 .join(" ")
5232 }
5233 }
5234
5235 /// Accepts any server certificate without validating its chain or
5236 /// hostname, while still cryptographically verifying the TLS handshake
5237 /// signatures (the connection is genuinely encrypted to *some* holder of
5238 /// the presented key — "no identity check", not "no security"). This is
5239 /// exactly `libpq`/`psql`'s posture for `sslmode=require`; identity
5240 /// verification is `sslmode=verify-full`'s job.
5241 #[derive(Debug)]
5242 struct NoServerCertVerification(CryptoProvider);
5243
5244 impl ServerCertVerifier for NoServerCertVerification {
5245 fn verify_server_cert(
5246 &self,
5247 _end_entity: &CertificateDer<'_>,
5248 _intermediates: &[CertificateDer<'_>],
5249 _server_name: &ServerName<'_>,
5250 _ocsp_response: &[u8],
5251 _now: UnixTime,
5252 ) -> Result<ServerCertVerified, rustls::Error> {
5253 Ok(ServerCertVerified::assertion())
5254 }
5255
5256 fn verify_tls12_signature(
5257 &self,
5258 message: &[u8],
5259 cert: &CertificateDer<'_>,
5260 dss: &DigitallySignedStruct,
5261 ) -> Result<HandshakeSignatureValid, rustls::Error> {
5262 rustls::crypto::verify_tls12_signature(
5263 message,
5264 cert,
5265 dss,
5266 &self.0.signature_verification_algorithms,
5267 )
5268 }
5269
5270 fn verify_tls13_signature(
5271 &self,
5272 message: &[u8],
5273 cert: &CertificateDer<'_>,
5274 dss: &DigitallySignedStruct,
5275 ) -> Result<HandshakeSignatureValid, rustls::Error> {
5276 rustls::crypto::verify_tls13_signature(
5277 message,
5278 cert,
5279 dss,
5280 &self.0.signature_verification_algorithms,
5281 )
5282 }
5283
5284 fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
5285 self.0.signature_verification_algorithms.supported_schemes()
5286 }
5287 }
5288
5289 #[cfg(test)]
5290 mod tests {
5291 use super::*;
5292
5293 #[test]
5294 fn absent_disable_and_prefer_keep_the_default_notls_path() {
5295 for url in [
5296 "postgres://user:pass@db.example.com:5432/app",
5297 "postgres://user:pass@db.example.com:5432/app?sslmode=disable",
5298 "postgres://user:pass@db.example.com:5432/app?sslmode=prefer",
5299 "host=db.example.com user=user",
5300 "host=db.example.com user=user sslmode=disable",
5301 ] {
5302 assert_eq!(
5303 TlsPosture::from_database_url(url),
5304 TlsPosture::Off,
5305 "{url} must keep the default NoTls path"
5306 );
5307 }
5308 }
5309
5310 #[test]
5311 fn unrecognized_sslmode_values_keep_the_default_path_and_its_errors() {
5312 // tokio-postgres itself rejects these at connect time; the pool
5313 // must not mask that with a different TLS decision.
5314 assert_eq!(
5315 TlsPosture::from_database_url("postgres://u@h/db?sslmode=allow"),
5316 TlsPosture::Off
5317 );
5318 assert_eq!(
5319 TlsPosture::from_database_url("postgres://u@h/db?sslmode=bogus"),
5320 TlsPosture::Off
5321 );
5322 }
5323
5324 #[test]
5325 fn require_selects_the_relaxed_tls_connector() {
5326 assert_eq!(
5327 TlsPosture::from_database_url(
5328 "postgres://user:pass@db.example.com:5432/app?sslmode=require"
5329 ),
5330 TlsPosture::Require
5331 );
5332 assert_eq!(
5333 TlsPosture::from_database_url("host=db.example.com sslmode=require"),
5334 TlsPosture::Require
5335 );
5336 }
5337
5338 #[test]
5339 fn verify_full_selects_the_verifying_connector_with_optional_root() {
5340 assert_eq!(
5341 TlsPosture::from_database_url("postgres://u@h/db?sslmode=verify-full"),
5342 TlsPosture::VerifyFull { root_cert: None }
5343 );
5344 assert_eq!(
5345 TlsPosture::from_database_url(
5346 "postgres://u@h/db?sslmode=verify-full&sslrootcert=/etc/ca.pem"
5347 ),
5348 TlsPosture::VerifyFull {
5349 root_cert: Some("/etc/ca.pem".to_owned())
5350 }
5351 );
5352 }
5353
5354 #[test]
5355 fn keyword_strings_with_spaces_around_equals_are_parsed() {
5356 // Regression: whitespace-splitting missed `sslmode` here and
5357 // silently downgraded the posture to Off (plaintext).
5358 assert_eq!(
5359 TlsPosture::from_database_url("host=db user=u sslmode = require"),
5360 TlsPosture::Require
5361 );
5362 assert_eq!(
5363 TlsPosture::from_database_url("sslmode = require"),
5364 TlsPosture::Require
5365 );
5366 assert_eq!(
5367 TlsPosture::from_database_url("host = db sslmode\t=\nverify-full user=u"),
5368 TlsPosture::VerifyFull { root_cert: None }
5369 );
5370 }
5371
5372 #[test]
5373 fn keyword_strings_with_quoted_values_are_parsed() {
5374 assert_eq!(
5375 TlsPosture::from_database_url("sslmode='verify-full'"),
5376 TlsPosture::VerifyFull { root_cert: None }
5377 );
5378 assert_eq!(
5379 TlsPosture::from_database_url(
5380 "host=db sslmode='verify-full' sslrootcert='/path with space/ca.pem'"
5381 ),
5382 TlsPosture::VerifyFull {
5383 root_cert: Some("/path with space/ca.pem".to_owned())
5384 }
5385 );
5386 // Backslash escapes work inside and outside quotes, as in
5387 // tokio-postgres/libpq.
5388 assert_eq!(
5389 TlsPosture::from_database_url(r"sslmode=verify-full sslrootcert='/pki/it\'s.pem'"),
5390 TlsPosture::VerifyFull {
5391 root_cert: Some("/pki/it's.pem".to_owned())
5392 }
5393 );
5394 assert_eq!(
5395 TlsPosture::from_database_url(r"sslmode=verify-full sslrootcert=/pki/my\ ca.pem"),
5396 TlsPosture::VerifyFull {
5397 root_cert: Some("/pki/my ca.pem".to_owned())
5398 }
5399 );
5400 }
5401
5402 #[test]
5403 fn malformed_keyword_strings_keep_the_default_path_and_its_errors() {
5404 // tokio-postgres rejects these at connect time; the posture must
5405 // not guess a different TLS decision for them.
5406 for url in [
5407 "host=db sslmode", // missing `=` and value
5408 "host=db sslmode=", // empty unquoted value
5409 "host=db sslmode='require", // unterminated quote
5410 r"host=db sslmode='require\", // EOF right after escape
5411 ] {
5412 assert_eq!(
5413 TlsPosture::from_database_url(url),
5414 TlsPosture::Off,
5415 "{url:?} must keep the default NoTls path"
5416 );
5417 }
5418 }
5419
5420 #[test]
5421 fn keyword_strings_with_url_like_values_stay_on_the_keyword_path() {
5422 // Regression: `contains("://")` sent this whole string to
5423 // url::Url::parse, which failed, so sslmode=require silently
5424 // became Off (plaintext). Only `postgres://`/`postgresql://`
5425 // prefixes are URLs, exactly as in tokio-postgres.
5426 assert_eq!(
5427 TlsPosture::from_database_url("host=db password=https://secret sslmode=require"),
5428 TlsPosture::Require
5429 );
5430 assert_eq!(
5431 TlsPosture::from_database_url(
5432 "host=db options='-c foo=bar://baz' sslmode='verify-full'"
5433 ),
5434 TlsPosture::VerifyFull { root_cert: None }
5435 );
5436 // The alternate scheme spelling is still parsed as a URL.
5437 assert_eq!(
5438 TlsPosture::from_database_url("postgresql://u@h/db?sslmode=require"),
5439 TlsPosture::Require
5440 );
5441 }
5442
5443 #[test]
5444 fn verify_ca_and_require_with_rootcert_fail_loudly() {
5445 assert!(matches!(
5446 TlsPosture::from_database_url("postgres://u@h/db?sslmode=verify-ca"),
5447 TlsPosture::Unsupported { .. }
5448 ));
5449 assert!(matches!(
5450 TlsPosture::from_database_url(
5451 "postgres://u@h/db?sslmode=require&sslrootcert=/etc/ca.pem"
5452 ),
5453 TlsPosture::Unsupported { .. }
5454 ));
5455 }
5456
5457 #[test]
5458 fn last_sslmode_occurrence_wins() {
5459 assert_eq!(
5460 TlsPosture::from_database_url("postgres://u@h/db?sslmode=disable&sslmode=require"),
5461 TlsPosture::Require
5462 );
5463 }
5464
5465 #[test]
5466 fn sanitize_rewrites_verify_full_and_drops_sslrootcert() {
5467 let sanitized = sanitize_for_verify_full(
5468 "postgres://u@h:5432/db?application_name=app&sslmode=verify-full&sslrootcert=/etc/ca.pem",
5469 );
5470 assert!(sanitized.contains("sslmode=require"), "{sanitized}");
5471 assert!(!sanitized.contains("verify-full"), "{sanitized}");
5472 assert!(!sanitized.contains("sslrootcert"), "{sanitized}");
5473 assert!(sanitized.contains("application_name=app"), "{sanitized}");
5474
5475 // Percent-encoded components survive byte-for-byte: form
5476 // re-encoding would turn %20 into `+`, which libpq and
5477 // tokio-postgres do not accept as a space in URI parameters.
5478 let sanitized = sanitize_for_verify_full(
5479 "postgres://u@h/db?options=-c%20search_path%3Dtenant&sslmode=verify-full&sslrootcert=/etc/ca.pem",
5480 );
5481 assert_eq!(
5482 sanitized, "postgres://u@h/db?options=-c%20search_path%3Dtenant&sslmode=require",
5483 "raw components must be preserved verbatim"
5484 );
5485 // Dropping the only params leaves a clean URL with no `?`.
5486 assert_eq!(
5487 sanitize_for_verify_full("postgres://u@h/db?sslrootcert=/etc/ca.pem"),
5488 "postgres://u@h/db"
5489 );
5490
5491 let kv = sanitize_for_verify_full(
5492 "host=h user=u sslmode=verify-full sslrootcert=/etc/ca.pem dbname=db",
5493 );
5494 assert_eq!(kv, "host=h user=u sslmode=require dbname=db");
5495
5496 // Whitespace/quoting variants normalize to a string
5497 // tokio-postgres parses to the same parameters.
5498 let kv = sanitize_for_verify_full(
5499 r"host=h sslmode = 'verify-full' sslrootcert='/path with space/ca.pem' password='it\'s'",
5500 );
5501 assert_eq!(kv, r"host=h sslmode=require password='it\'s'");
5502
5503 // URL-like values must not push a keyword string onto the URL
5504 // branch (which would pass it through unsanitized).
5505 let kv = sanitize_for_verify_full(
5506 "host=h password=https://secret sslmode=verify-full sslrootcert=/etc/ca.pem",
5507 );
5508 assert_eq!(kv, "host=h password=https://secret sslmode=require");
5509 }
5510
5511 #[test]
5512 fn verifying_connector_builds_with_and_without_root_cert_file() {
5513 assert!(verifying_connector(None).is_ok());
5514 assert!(
5515 verifying_connector(Some("/nonexistent/ca.pem")).is_err(),
5516 "an unreadable sslrootcert must fail loudly"
5517 );
5518 }
5519
5520 #[test]
5521 fn relaxed_connector_builds() {
5522 assert!(relaxed_connector().is_ok());
5523 }
5524 }
5525}
5526
5527// ── Synchronous TLS-aware connections (migrations, wait checks) ───────────────
5528
5529/// A synchronous diesel Pg connection for migrations and startup wait
5530/// checks, honoring the connection string's `sslmode` (issue #1585 review).
5531///
5532/// diesel's native [`diesel::PgConnection`] connects through libpq, and the
5533/// workspace bundles libpq **without** SSL support (`pq-sys`'s
5534/// `bundled_without_openssl`) — so with `sslmode=require`/`verify-full` the
5535/// async pool (rustls) connects fine while the sync migration path fails at
5536/// startup before the app ever serves. For those postures this wraps an
5537/// [`AsyncPgConnection`] established through the pool's own rustls connector
5538/// in diesel-async's [`AsyncConnectionWrapper`], which provides the full
5539/// sync diesel API including `MigrationHarness`. With TLS off the native
5540/// `PgConnection` path is kept byte-identical to the historical behavior.
5541///
5542/// [`AsyncConnectionWrapper`]:
5543/// diesel_async::async_connection_wrapper::AsyncConnectionWrapper
5544#[allow(clippy::large_enum_variant)] // short-lived, one per migration target
5545pub(crate) enum MigrationConnection {
5546 /// The historical libpq-backed connection (TLS posture `Off`).
5547 Native(diesel::PgConnection),
5548 /// rustls-backed sync wrapper (TLS posture `Require`/`VerifyFull`).
5549 Rustls {
5550 /// Runtime owning the connection's tokio driver task when none was
5551 /// ambient (plain sync contexts like the CLI); `None` when an
5552 /// ambient runtime drives it (`spawn_blocking` contexts). Callers
5553 /// must keep this alive as long as `conn` — dropping the runtime
5554 /// kills the driver and every query after that hangs or errors.
5555 runtime: Option<tokio::runtime::Runtime>,
5556 conn: diesel_async::async_connection_wrapper::AsyncConnectionWrapper<AsyncPgConnection>,
5557 },
5558}
5559
5560/// Whether migrations/wait checks for `database_url` must go through the
5561/// rustls wrapper rather than the native libpq path. Split out of
5562/// [`establish_migration_connection`] so the path selection is unit-testable
5563/// without a server.
5564fn migration_connection_needs_rustls(database_url: &str) -> bool {
5565 !matches!(
5566 tls::TlsPosture::from_database_url(database_url),
5567 tls::TlsPosture::Off
5568 )
5569}
5570
5571/// Establish a [`MigrationConnection`] for `database_url`.
5572///
5573/// **Never call from an async executor thread**: the rustls arm `block_on`s
5574/// connection setup. Sync contexts (the CLI) and `spawn_blocking` tasks (the
5575/// startup migration path) are both fine.
5576///
5577/// # Errors
5578///
5579/// Returns the underlying [`diesel::ConnectionError`] when the connection
5580/// cannot be established (including [`TlsPosture::Unsupported`] postures,
5581/// which fail with their documented guidance).
5582///
5583/// [`TlsPosture::Unsupported`]: tls::TlsPosture::Unsupported
5584pub(crate) fn establish_migration_connection(
5585 database_url: &str,
5586) -> Result<MigrationConnection, diesel::ConnectionError> {
5587 use diesel::Connection as _;
5588 if !migration_connection_needs_rustls(database_url) {
5589 return diesel::PgConnection::establish(database_url).map(MigrationConnection::Native);
5590 }
5591 let posture = tls::TlsPosture::from_database_url(database_url);
5592 // The driver task tokio::spawn()ed during establish must stay driven for
5593 // the connection's lifetime: reuse the ambient runtime when there is one
5594 // (spawn_blocking context), otherwise create one and keep it alive
5595 // alongside the connection.
5596 let (runtime, handle) = if let Ok(handle) = tokio::runtime::Handle::try_current() {
5597 (None, handle)
5598 } else {
5599 let runtime = tokio::runtime::Builder::new_multi_thread()
5600 .worker_threads(1)
5601 .enable_all()
5602 .build()
5603 .map_err(|e| {
5604 diesel::ConnectionError::BadConnection(format!(
5605 "failed to build a tokio runtime for the TLS migration connection: {e}"
5606 ))
5607 })?;
5608 let handle = runtime.handle().clone();
5609 (Some(runtime), handle)
5610 };
5611 let inner = handle.block_on(tls::establish(database_url, posture))?;
5612 Ok(MigrationConnection::Rustls {
5613 runtime,
5614 conn: diesel_async::async_connection_wrapper::AsyncConnectionWrapper::from(inner),
5615 })
5616}
5617
5618/// Establish a synchronous diesel [`diesel::SqliteConnection`] for the `SQLite`
5619/// startup-migration path (issue #1614, PR3).
5620///
5621/// The `SQLite` counterpart to [`establish_migration_connection`], and much
5622/// thinner: `SQLite` is a single-writer local database, so there is no TLS
5623/// negotiation and no advisory-lock connection wrapper — diesel's
5624/// `MigrationHarness` runs directly on a plain `SqliteConnection`. The URL is
5625/// normalized through the same [`normalize_sqlite_target`] the runtime pool
5626/// uses, so the migration connection and the pool open the same database file.
5627///
5628/// The connection sets `PRAGMA busy_timeout = 5000` right after opening,
5629/// mirroring the runtime pool's per-connection `custom_setup` (see
5630/// [`build_sqlite_pool`]): without it, a second concurrent migrator — or a write
5631/// lock briefly held by the runtime pool — makes this connection fail
5632/// *immediately* with `SQLITE_BUSY`, and `auto_migrate_sqlite` exits the process.
5633/// With the timeout, migration statements WAIT up to 5s for the lock to clear
5634/// instead of aborting; diesel migrations are idempotent, so a migrator that
5635/// waits and then finds migrations already applied is fine. Only `busy_timeout`
5636/// is set here — NOT `foreign_keys`/`journal_mode`, because `foreign_keys = ON`
5637/// can break table-recreating migrations.
5638///
5639/// # Errors
5640///
5641/// Returns the underlying [`diesel::ConnectionError`] when the database cannot
5642/// be opened, or [`diesel::ConnectionError::CouldntSetupConfiguration`] if the
5643/// `busy_timeout` pragma cannot be applied.
5644#[cfg(feature = "sqlite")]
5645pub(crate) fn establish_sqlite_migration_connection(
5646 database_url: &str,
5647) -> Result<diesel::SqliteConnection, diesel::ConnectionError> {
5648 use diesel::Connection as _;
5649 use diesel::connection::SimpleConnection as _;
5650 let mut conn = diesel::SqliteConnection::establish(&normalize_sqlite_target(database_url))?;
5651 conn.batch_execute("PRAGMA busy_timeout = 5000;")
5652 .map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
5653 Ok(conn)
5654}
5655
5656#[cfg(test)]
5657mod migration_connection_tests {
5658 use super::migration_connection_needs_rustls;
5659
5660 #[test]
5661 fn migration_path_selection_follows_the_tls_posture() {
5662 // NoTls URLs keep the historical native libpq path byte-identical.
5663 for url in [
5664 "postgres://u@h/db",
5665 "postgres://u@h/db?sslmode=disable",
5666 "postgres://u@h/db?sslmode=prefer",
5667 "host=db user=u",
5668 ] {
5669 assert!(
5670 !migration_connection_needs_rustls(url),
5671 "must stay on the native path: {url}"
5672 );
5673 }
5674 // TLS-requiring strings (URL and keyword forms) — and unsupported
5675 // postures, which must fail with the TLS module's guidance instead
5676 // of libpq's — go through the rustls wrapper.
5677 for url in [
5678 "postgres://u@h/db?sslmode=require",
5679 "postgres://u@h/db?sslmode=verify-full",
5680 "postgres://u@h/db?sslmode=verify-full&sslrootcert=/etc/ca.pem",
5681 "postgres://u@h/db?sslmode=verify-ca",
5682 "host=db user=u sslmode=require",
5683 "host=db sslmode = 'verify-full'",
5684 ] {
5685 assert!(
5686 migration_connection_needs_rustls(url),
5687 "must use the rustls wrapper: {url}"
5688 );
5689 }
5690 }
5691}