autumn_web/db.rs
1//! Database connection pool and extractor.
2//!
3//! This module provides async connectivity via `diesel-async` with the
4//! `deadpool` connection pool. The backend is whichever [`RuntimeConnection`]
5//! resolves to — Postgres by default, `SQLite` under the crate's `sqlite`
6//! feature. The pool is created at startup by
7//! [`AppBuilder::run`](crate::app::AppBuilder::run) and stored in
8//! [`crate::state::AppState`].
9//!
10//! When no `database.primary_url` or legacy `database.url` is configured,
11//! [`create_pool`] returns `Ok(None)` and the application runs without a
12//! database -- useful for static-site or API-gateway use cases.
13//!
14//! # The [`Db`] extractor
15//!
16//! Declare `db: Db` in your handler signature to get a pooled connection.
17//! The connection is automatically returned to the pool when `Db` is dropped
18//! at the end of the request.
19//!
20//! ```rust,no_run
21//! use autumn_web::prelude::*;
22//!
23//! #[get("/hello")]
24//! async fn hello(db: Db) -> AutumnResult<String> {
25//! // Use `db` with Diesel queries...
26//! Ok("hello from db".to_string())
27//! }
28//! ```
29
30// autumn-determinism-gate: production code in this module must read time and
31// mint identifiers through the framework's injected seams (ClockSource /
32// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
33// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
34// (issue #1797). Justify exceptions with
35// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
36#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
37
38use axum::extract::FromRequestParts;
39use diesel;
40// Named by the Postgres pool builder, TLS connector and migration connection.
41#[cfg(feature = "postgres")]
42use diesel_async::AsyncPgConnection;
43use diesel_async::pooled_connection::AsyncDieselConnectionManager;
44#[cfg(not(feature = "sqlite"))]
45use diesel_async::pooled_connection::RecyclingMethod;
46/// The deadpool connection pool Autumn's database seam produces.
47///
48/// Re-exported so a plugin implementing
49/// [`DatabasePoolProvider`] — a stable plugin surface (issue #1601) — can name
50/// the type its `create_pool` returns without taking its own `diesel-async`
51/// dependency at a matching major.
52pub use diesel_async::pooled_connection::deadpool::Pool;
53use futures::FutureExt as _;
54use std::any::Any;
55use std::future::Future;
56use std::panic::AssertUnwindSafe;
57use std::pin::Pin;
58use std::sync::atomic::{AtomicU64, Ordering};
59use std::sync::{Arc, Mutex};
60use std::time::Duration;
61use tracing::Instrument as _;
62
63use crate::config::DatabaseConfig;
64use crate::error::AutumnError;
65
66/// The database connection type the runtime is built against.
67///
68/// Defaults to Postgres (`AsyncPgConnection`). Enabling the crate's `sqlite`
69/// feature flips it to a `SQLite` connection. Threading this alias (instead of a
70/// hard-coded `AsyncPgConnection`) through the connection-typed surface — the
71/// pool, the pooled connection, the transaction/query helpers, and the
72/// `#[repository]`/`#[model]` generated code — is what lets the same codebase
73/// target either backend.
74///
75/// FEATURE-UNIFICATION HAZARD: the `sqlite` feature must ONLY be enabled by an
76/// end application (or an explicit `--features sqlite` build). If any workspace
77/// crate or dev-dependency enables it, cargo feature unification flips this
78/// alias for every consumer in the graph and breaks the Postgres default. See
79/// the doc comment above `sqlite = []` in `autumn/Cargo.toml`.
80#[cfg(not(feature = "sqlite"))]
81pub type RuntimeConnection = diesel_async::AsyncPgConnection;
82/// See [`RuntimeConnection`] (Postgres variant) for the full contract. Under
83/// the `sqlite` feature the runtime is built against a `SQLite` connection.
84#[cfg(feature = "sqlite")]
85pub type RuntimeConnection =
86 diesel_async::sync_connection_wrapper::SyncConnectionWrapper<diesel::SqliteConnection>;
87
88/// The diesel query backend the runtime is built against — the companion of
89/// [`RuntimeConnection`].
90///
91/// Defaults to Postgres (`diesel::pg::Pg`); the `sqlite` feature flips it to
92/// `diesel::sqlite::Sqlite`. Generated `#[repository]`/`#[model]` CRUD names
93/// this alias (as `::autumn_web::RuntimeBackend`) wherever a diesel
94/// `QueryFragment<_>` / `SelectableHelper<_>` bound must resolve to the *active*
95/// backend rather than a hard-coded `Pg`. Threading it (instead of `Pg`) through
96/// the always-emitted upsert set-clause and boxed-query types is what lets the
97/// same generated code type-check on either backend. Genuinely Postgres-only
98/// query fragments (advisory-lock upserts, FTS `searchable`) keep an explicit
99/// `Pg` bound — they are not portable and are cfg-gated off under `sqlite`.
100#[cfg(not(feature = "sqlite"))]
101pub type RuntimeBackend = diesel::pg::Pg;
102/// See [`RuntimeBackend`] (Postgres variant). Under the `sqlite` feature the
103/// runtime query backend is `diesel::sqlite::Sqlite`.
104#[cfg(feature = "sqlite")]
105pub type RuntimeBackend = diesel::sqlite::Sqlite;
106
107/// One `SQLite` database per tenant or per routing slot (ADR 0019).
108#[cfg(feature = "sqlite")]
109pub mod fleet;
110/// Slot-ownership epochs and fencing for a fleet (issue #3233).
111#[cfg(feature = "sqlite")]
112pub mod fleet_ownership;
113/// Continuous replication of a fleet's databases (ADR 0019 §5).
114#[cfg(feature = "sqlite")]
115pub mod fleet_replication;
116/// Background work on fleet databases nobody asks for (issue 3234).
117#[cfg(feature = "sqlite")]
118pub mod fleet_sweeper;
119pub mod pooler;
120/// `TEXT`-backed newtypes for foreign model-field types on `SQLite` (#1924).
121#[cfg(feature = "sqlite")]
122pub mod sqlite_types;
123
124// ── After-commit callback infrastructure ─────────────────────────────────────
125
126/// A boxed async callback registered for post-transaction execution.
127///
128/// Stored in [`AFTER_COMMIT_REGISTRY`] during an active [`Db::tx`] block.
129/// The registry is drained and each callback is awaited after the transaction
130/// commits successfully. On rollback or panic the callbacks are dropped
131/// without being called.
132pub type CommitCallback = Box<
133 dyn FnOnce() -> Pin<Box<dyn Future<Output = crate::AutumnResult<()>> + Send + 'static>>
134 + Send
135 + 'static,
136>;
137
138tokio::task_local! {
139 /// Task-local registry used by [`Db::tx`] to accumulate after-commit
140 /// callbacks. Only set while the [`Db::tx`] future is being polled;
141 /// absent outside a transaction block.
142 ///
143 /// Use [`register_after_commit`]: a callback pushed here directly does not
144 /// mark a failure capsule incomplete.
145 pub static AFTER_COMMIT_REGISTRY: Arc<Mutex<Vec<CommitCallback>>>;
146}
147
148/// Timeouts that each framework transaction sets with `SET LOCAL` (#3057).
149///
150/// A transaction pooler (`PgBouncer` in transaction mode) drops a session
151/// `SET`, but keeps a `SET LOCAL` for the transaction that issued it. The
152/// values are milliseconds. `Some(0)` turns the limit off.
153///
154/// An unset statement timeout is sent as `0`. `Db::checkout` and generated
155/// repositories already `SET statement_timeout = 0` for the session when none
156/// is configured. `DEFAULT` would undo that and bring back a role or database
157/// default. An unset idle timeout is sent as `DEFAULT`; nothing sets it for
158/// the session, so that is the session value.
159#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
160pub(crate) struct TxTimeouts {
161 /// `statement_timeout`, in milliseconds.
162 pub(crate) statement_ms: Option<u64>,
163 /// `idle_in_transaction_session_timeout`, in milliseconds.
164 pub(crate) idle_in_transaction_ms: Option<u64>,
165}
166
167#[cfg_attr(
168 feature = "sqlite",
169 allow(
170 dead_code,
171 reason = "`SET LOCAL` is Postgres-only; SQLite sends nothing"
172 )
173)]
174impl TxTimeouts {
175 /// Build from the configured durations. Each value is capped at
176 /// `i32::MAX` milliseconds, the Postgres maximum.
177 pub(crate) fn new(
178 statement: Option<std::time::Duration>,
179 idle_in_transaction: Option<std::time::Duration>,
180 ) -> Self {
181 Self {
182 statement_ms: statement.map(|d| pg_timeout_ms(Some(d))),
183 idle_in_transaction_ms: idle_in_transaction.map(|d| pg_timeout_ms(Some(d))),
184 }
185 }
186
187 /// The timeouts from an app's `[database]` config.
188 pub(crate) fn from_config(config: &crate::config::DatabaseConfig) -> Self {
189 Self::new(config.statement_timeout, config.idle_in_transaction_timeout)
190 }
191
192 /// The `SET LOCAL` batch, or `None` when neither value is set. Then
193 /// nothing is sent, and a transaction costs no extra round trip.
194 ///
195 /// The batch always has both statements, in this order. The capsule
196 /// recorder matches that exact form (`capsule::wire`).
197 pub(crate) fn set_local_sql(self) -> Option<String> {
198 (self.statement_ms.is_some() || self.idle_in_transaction_ms.is_some()).then(|| {
199 format!(
200 "SET LOCAL statement_timeout = {}; \
201 SET LOCAL idle_in_transaction_session_timeout = {}",
202 self.statement_ms.unwrap_or(0),
203 self.idle_in_transaction_ms
204 .map_or_else(|| "DEFAULT".to_owned(), |ms| ms.to_string())
205 )
206 })
207 }
208
209 /// Run `fut` with these timeouts in scope. Each outermost transaction
210 /// that [`scoped_transaction`] opens in `fut` sets them with `SET LOCAL`.
211 pub(crate) async fn scope<F: std::future::Future>(self, fut: F) -> F::Output {
212 TX_TIMEOUTS.scope(self, fut).await
213 }
214
215 /// Run a request `fut` with these timeouts as its defaults. A route's
216 /// [`StatementTimeout`] replaces the statement value when an extractor
217 /// sees it (see [`note_route_statement_timeout`]).
218 pub(crate) async fn scope_request<F: std::future::Future>(self, fut: F) -> F::Output {
219 REQUEST_TX_TIMEOUTS
220 .scope(Arc::new(Mutex::new(self)), fut)
221 .await
222 }
223
224 /// The timeouts in scope for this task: the innermost `Db` scope, else
225 /// the request defaults, else `None`.
226 pub(crate) fn current() -> Option<Self> {
227 TX_TIMEOUTS.try_with(|t| *t).ok().or_else(|| {
228 REQUEST_TX_TIMEOUTS
229 .try_with(|cell| {
230 *cell
231 .lock()
232 .unwrap_or_else(std::sync::PoisonError::into_inner)
233 })
234 .ok()
235 })
236 }
237}
238
239/// Run background work (a scheduled task or a job) with the app's configured
240/// [`TxTimeouts`] in scope, so its framework transactions set them as a
241/// request's do (#3057). A `Db` method inside still scopes its own values.
242///
243/// The values are read here, so the returned future holds no borrow of
244/// `state`.
245pub(crate) fn scope_background_tx_timeouts<S: DbState, F: std::future::Future>(
246 state: &S,
247 fut: F,
248) -> impl std::future::Future<Output = F::Output> + use<S, F> {
249 let timeouts = TxTimeouts::new(
250 state.statement_timeout(),
251 state.idle_in_transaction_timeout(),
252 );
253 TX_TIMEOUTS.scope(timeouts, fut)
254}
255
256/// Record a route's [`StatementTimeout`] for the request's transactions.
257///
258/// The request layer sits outside the route layer that adds the extension,
259/// so an extractor that sees it reports it here. `Db` does this itself.
260/// Generated repositories call it through `__private`.
261#[doc(hidden)]
262pub fn note_route_statement_timeout(parts: &axum::http::request::Parts) {
263 if let Some(timeout) = parts.extensions.get::<StatementTimeout>() {
264 note_statement_timeout(*timeout);
265 }
266}
267
268/// Record a route's [`StatementTimeout`] for the request's transactions, for
269/// a handler that receives the extension itself (the offline-sync routes).
270pub(crate) fn note_statement_timeout(timeout: StatementTimeout) {
271 let ms = pg_timeout_ms(Some(timeout.0));
272 let _ = REQUEST_TX_TIMEOUTS.try_with(|cell| {
273 cell.lock()
274 .unwrap_or_else(std::sync::PoisonError::into_inner)
275 .statement_ms = Some(ms);
276 });
277}
278
279/// A Postgres timeout setting in milliseconds, capped at `i32::MAX`.
280fn pg_timeout_ms(timeout: Option<std::time::Duration>) -> u64 {
281 const PG_TIMEOUT_MAX_MS: u64 = i32::MAX as u64;
282 timeout.map_or(0, |d| {
283 u64::try_from(d.as_millis())
284 .unwrap_or(PG_TIMEOUT_MAX_MS)
285 .min(PG_TIMEOUT_MAX_MS)
286 })
287}
288
289tokio::task_local! {
290 /// The [`TxTimeouts`] of one `Db`: set by [`Db::tx`] and its siblings.
291 static TX_TIMEOUTS: TxTimeouts;
292}
293
294tokio::task_local! {
295 /// The request's [`TxTimeouts`], for code that opens a transaction on a
296 /// connection of its own (generated repositories). Set by the request
297 /// layer. Mutable so that a route override can replace the default.
298 static REQUEST_TX_TIMEOUTS: Arc<Mutex<TxTimeouts>>;
299}
300
301/// Issue the in-scope [`TxTimeouts`] as `SET LOCAL`, at transaction depth 1
302/// only. A savepoint keeps the outer transaction's values.
303///
304/// # Errors
305///
306/// Returns the database error from the `SET LOCAL` batch.
307#[cfg(not(feature = "sqlite"))]
308async fn apply_tx_timeouts<C>(conn: &mut C) -> Result<(), diesel::result::Error>
309where
310 C: diesel_async::AsyncConnection + Send,
311{
312 use diesel_async::TransactionManager as _;
313 let Some(sql) = TxTimeouts::current().and_then(TxTimeouts::set_local_sql) else {
314 return Ok(());
315 };
316 let depth = C::TransactionManager::transaction_manager_status_mut(conn)
317 .transaction_depth()?
318 .map(std::num::NonZeroU32::get);
319 if depth == Some(1) {
320 conn.batch_execute(&sql).await?;
321 }
322 Ok(())
323}
324
325/// `SET LOCAL` is Postgres syntax. `SQLite` has no such settings.
326#[cfg(feature = "sqlite")]
327#[allow(
328 clippy::unused_async,
329 clippy::needless_pass_by_ref_mut,
330 reason = "same signature as the Postgres version"
331)]
332async fn apply_tx_timeouts<C>(_conn: &mut C) -> Result<(), diesel::result::Error>
333where
334 C: diesel_async::AsyncConnection + Send,
335{
336 Ok(())
337}
338
339/// Per-request accumulator for database query timing, used by the
340/// `Server-Timing` middleware to surface `db;dur=…;desc="N queries"`.
341///
342/// Populated by the [`RequestQueryTimer`] connection instrumentation, which
343/// [`Db::checkout`] installs only on connections checked out while this
344/// task-local is scoped (i.e. while the `ServerTimingLayer` has scoped the
345/// request — see [`request_db_timing_active`]). The timer only *records* when
346/// the task-local is scoped; a stale one left on a reused connection has an
347/// `on_start` that is a cheap bool-probe no-op formatting and recording nothing. It
348/// fires on every diesel-async query (including the raw `.load()`/`.execute()`
349/// calls generated repositories run). The connection instrumentation is the
350/// sole writer during a request — helpers like [`run_instrumented`]
351/// deliberately do *not* record here, to avoid double-counting the same
352/// statement. When the middleware is disabled the scope is absent, so opted-out
353/// requests pay only that per-query bool probe (no `DebugQuery` formatting, no
354/// allocation).
355#[derive(Default, Debug)]
356pub(crate) struct RequestDbTimings {
357 /// Total elapsed time across all DB queries for this request, in
358 /// microseconds. Microseconds keep the header format (`f64` ms rounded
359 /// to three decimals) consistent with the access-log clock without an
360 /// f64 atomic.
361 pub(crate) total_us: AtomicU64,
362 /// Number of instrumented DB queries this request performed.
363 pub(crate) query_count: std::sync::atomic::AtomicUsize,
364}
365
366tokio::task_local! {
367 /// Task-local accumulator populated by DB instrumentation and read by
368 /// the `Server-Timing` middleware. See [`RequestDbTimings`].
369 pub(crate) static REQUEST_DB_TIMINGS: Arc<RequestDbTimings>;
370}
371
372tokio::task_local! {
373 /// Task-local sink for request-wide SQL query capture, independent of the
374 /// `Server-Timing` timing accumulator ([`REQUEST_DB_TIMINGS`]).
375 ///
376 /// Scoped by the test harness (`RequestBuilder::send`) around the whole
377 /// request so every instrumented statement is retained as a
378 /// [`crate::inspector::QueryRecord`] for `TestResponse` query-count / N+1
379 /// assertions. Kept as a separate task-local lane — not folded into
380 /// `RequestDbTimings` — so query capture is unaffected by how the
381 /// `Server-Timing` middleware scopes (and nests) its per-scope timing
382 /// accumulators. Absent in production, where the capture lane is never
383 /// scoped and nothing is retained.
384 #[cfg(feature = "db")]
385 pub(crate) static REQUEST_QUERY_CAPTURE: Arc<Mutex<Vec<crate::inspector::QueryRecord>>>;
386}
387
388/// Strip the trailing `-- binds: [...]` annotation that diesel's `DebugQuery`
389/// `Display` appends to a query's SQL text.
390///
391/// diesel-async feeds each real query into the `StartQuery` instrumentation
392/// event as a `DebugQuery` (see `diesel::debug_query` /
393/// `AsyncPgConnection::with_prepared_statement`), whose `Display` renders
394/// `"{sql} -- binds: {binds:?}"` — the format lives in
395/// `diesel::query_builder::debug_query::display`
396/// (`write!(f, "{query} -- binds: {debug_binds:?}")`). So the SQL text we
397/// materialise from `query.to_string()` is e.g.
398/// `SELECT * FROM books WHERE author_id = $1 -- binds: [1]`.
399///
400/// The per-row executions of an N+1 pattern share the same parameterised
401/// statement (`… WHERE author_id = $1`) and differ only in their bind values
402/// (`-- binds: [1]`, `-- binds: [2]`, …). If the capture path retained that
403/// annotation, [`crate::inspector::normalize_sql`] (which only collapses
404/// whitespace and lower-cases) would treat each per-row execution as a distinct
405/// template, so [`crate::inspector::detect_n_plus_one`] would never see the
406/// repetition and `assert_no_n_plus_one()` would miss the N+1 it exists to
407/// catch. Truncating at the `-- binds` marker leaves the parameterised
408/// statement — `$N` placeholders intact — so repeated per-row queries collapse
409/// to one template.
410///
411/// Robust to input without the marker (transaction-control SQL diesel runs via
412/// `batch_execute`, or synthetic test input): returns the input unchanged. Uses
413/// the last occurrence, since diesel always appends the annotation after the
414/// full statement.
415#[cfg(feature = "db")]
416fn strip_bind_annotation(sql: &str) -> &str {
417 sql.rfind("-- binds:")
418 .map_or(sql, |idx| sql[..idx].trim_end())
419}
420
421/// Record one instrumented DB query into the current request's
422/// [`REQUEST_DB_TIMINGS`], if any. No-op when the task-local is unset —
423/// which is the case whenever the `Server-Timing` middleware is disabled
424/// or the query runs outside a request (e.g. background job).
425pub(crate) fn record_request_db_query(elapsed: Duration, sql: Option<&str>) {
426 // Lane 1: the `Server-Timing` timing accumulator (count + cumulative time).
427 let _ = REQUEST_DB_TIMINGS.try_with(|t| {
428 let micros = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX);
429 t.total_us.fetch_add(micros, Ordering::Relaxed);
430 t.query_count
431 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
432 });
433
434 // Lane 3: per-request cost metering (issue #1720). No-op outside a
435 // metered request.
436 #[cfg(feature = "db")]
437 crate::cost::record_db_query();
438
439 // Lane 2: request-wide SQL capture, independent of the timing lane. Only
440 // active when a test scoped `REQUEST_QUERY_CAPTURE`; a no-op otherwise
441 // (production, background jobs). The two `try_with` calls are independent —
442 // either lane may be active without the other — and no lock is held across
443 // an await.
444 #[cfg(feature = "db")]
445 if let Some(sql) = sql {
446 let _ = REQUEST_QUERY_CAPTURE.try_with(|sink| {
447 if let Ok(mut list) = sink.lock() {
448 list.push(crate::inspector::QueryRecord {
449 // Store the parameterised statement WITHOUT diesel's per-row
450 // `-- binds: [...]` annotation (the `$N` placeholders stay), so
451 // repeated per-row queries normalise to the same template and
452 // `detect_n_plus_one` can see the repetition. See
453 // [`strip_bind_annotation`].
454 sql: strip_bind_annotation(sql).to_owned(),
455 params: Vec::new(),
456 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
457 location: String::new(),
458 });
459 }
460 });
461 }
462}
463
464/// Whether the current task is inside a [`REQUEST_DB_TIMINGS`] scope — i.e.
465/// whether the `ServerTimingLayer` (only active when `[observability]
466/// server_timing` is enabled) has wrapped this request's handler future.
467///
468/// Cheap: probes the task-local without cloning the `Arc`, allocating, or
469/// formatting anything. Probed by [`RequestQueryTimer::on_start`] on every
470/// query: when no scope is active (the production default, `server_timing`
471/// unset), the timer records nothing and skips the `DebugQuery` formatting, so
472/// an always-installed timer costs opted-out requests only this bool probe per
473/// query — no per-statement allocation.
474#[cfg(feature = "db")]
475pub(crate) fn request_db_timing_active() -> bool {
476 REQUEST_DB_TIMINGS.try_with(|_| ()).is_ok()
477}
478
479/// Whether the current task is inside a [`REQUEST_QUERY_CAPTURE`] scope — i.e.
480/// whether the test harness (`RequestBuilder::send`) has scoped a capture sink
481/// around this request.
482///
483/// Mirrors [`request_db_timing_active`]: a cheap task-local probe that neither
484/// clones the `Arc` nor allocates. [`Db::checkout`] installs the
485/// [`RequestQueryTimer`] instrumentation when EITHER this lane or the timing
486/// lane is active, so query capture works even when `server_timing` is off (the
487/// common test case) and no `REQUEST_DB_TIMINGS` scope exists.
488#[cfg(feature = "db")]
489pub(crate) fn request_query_capture_active() -> bool {
490 REQUEST_QUERY_CAPTURE.try_with(|_| ()).is_ok()
491}
492
493/// Whether any per-request DB lane is active: `Server-Timing` timing, test
494/// query capture, or cost metering (issue #1720).
495#[cfg(feature = "db")]
496fn request_db_lane_active() -> bool {
497 request_db_timing_active()
498 || request_query_capture_active()
499 || crate::cost::request_cost_active()
500}
501
502/// diesel-async [`Instrumentation`](diesel::connection::Instrumentation)
503/// that feeds every executed statement into the per-request
504/// [`REQUEST_DB_TIMINGS`] accumulator for the `Server-Timing` `db` metric.
505///
506/// Installed on a pooled connection at [`Db::checkout`] only when a
507/// [`REQUEST_DB_TIMINGS`] scope is active (see [`request_db_timing_active`]),
508/// i.e. only for requests the `ServerTimingLayer` is measuring — so opted-out
509/// requests never install it and pay no per-query overhead. Because generated
510/// repositories run raw `diesel-async` `.load()`/`.execute()` (they do not go
511/// through [`run_instrumented`]), the connection-level instrumentation is the
512/// only thing that observes those queries. It brackets each statement between
513/// the `StartQuery`/`FinishQuery` events the connection emits and records the
514/// elapsed wall time via [`record_request_db_query`], which is a no-op when no
515/// request has scoped the task-local (background jobs, or the middleware being
516/// disabled) — so it never panics off-request.
517///
518/// Only genuine application statements are timed. The dedicated
519/// connection-establish, prepared-statement-cache, and
520/// `BeginTransaction`/`CommitTransaction`/`RollbackTransaction` events are
521/// ignored outright. Crucially, diesel-async runs the transaction-control SQL
522/// itself (`BEGIN`, `COMMIT`, `ROLLBACK`, and the `SAVEPOINT`/`RELEASE`
523/// variants for nested transactions) through `batch_execute`, which emits a
524/// `StartQuery`/`FinishQuery` pair with that SQL — exactly like a real query.
525/// Left unfiltered, a transaction wrapping a single `SELECT` would report
526/// `desc="3 queries"` (BEGIN + SELECT + COMMIT) and fold begin/commit latency
527/// into `db;dur`. Likewise [`Db::checkout`] issues a `SET statement_timeout`
528/// housekeeping statement on every checkout, which would otherwise add a bogus
529/// `+1 query` to every request before any app SQL runs. So at `StartQuery` we
530/// inspect the statement text and skip timing when it is a housekeeping /
531/// transaction-control command (see
532/// [`RequestQueryTimer::is_uncounted_statement`]). Queries on a single
533/// connection are strictly sequential (`&mut conn`), so a single
534/// `Option<Instant>` slot is sufficient — there is no overlap between a start
535/// and its finish, and skipping a start leaves the slot empty so the matching
536/// finish is a no-op.
537///
538/// The timer is installed at [`Db::checkout`] (before the housekeeping `SET`)
539/// **only when a `REQUEST_DB_TIMINGS` scope is active** — i.e. only for requests
540/// the `ServerTimingLayer` is measuring. This gate matters because
541/// `set_instrumentation` wholesale replaces the connection's instrumentation:
542/// installing unconditionally would clobber any global default an application
543/// registered via `diesel::connection::set_default_instrumentation` (query
544/// logging, tracing, metrics), even when `server_timing` is disabled. Gating on
545/// the scope leaves an opted-out app's instrumentation fully intact. When the
546/// scope *is* active, installing a fresh timer per checkout also clears any
547/// stale timer a pooled connection carried from a prior timed request. A stale
548/// timer left on a connection later reused by an opted-out request is a cheap
549/// no-op: `on_start` probes [`request_db_timing_active`] before doing any work,
550/// so it never formats SQL or allocates off-scope.
551///
552/// Autumn does not currently *compose* with an app-registered
553/// `set_default_instrumentation` — while `server_timing` is enabled its timer
554/// replaces the app's on measured checkouts. This is a documented limitation
555/// (`server_timing` is a dev/off-by-default feature); apps needing both should
556/// keep it disabled in that environment.
557#[cfg(feature = "db")]
558pub(crate) struct RequestQueryTimer {
559 /// The currently in-flight statement (start instant + SQL text), if any.
560 pending: Option<PendingQuery>,
561 /// Injected clock supplying the start/finish instants.
562 ///
563 /// Diesel's `Instrumentation::on_connection_event` signature is frozen and
564 /// carries no time, so the only way to reach the app's clock from inside it
565 /// is a field on the timer. Installed per checkout from
566 /// `DbCheckoutParams::clock`, so a `#[sim_test]` sees virtual query
567 /// latencies. It costs one `Arc` deref per statement — and only on
568 /// connections that actually carry a timer, which `Db::checkout` installs
569 /// solely while a query observer is scoped.
570 clock: std::sync::Arc<dyn crate::time::ClockSource>,
571 /// The sim gate's turn for the running statement. This timer replaces the
572 /// gate's own instrumentation on a connection, so it takes the turn too
573 /// (issue #3067). Off a sim runtime it does nothing.
574 turn: crate::sim::gate::QueryTurn,
575}
576
577#[cfg(feature = "db")]
578impl std::fmt::Debug for RequestQueryTimer {
579 // Hand-written because `dyn ClockSource` is not `Debug`; the clock is an
580 // injected handle with no useful representation here.
581 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
582 f.debug_struct("RequestQueryTimer")
583 .field("pending", &self.pending)
584 .finish_non_exhaustive()
585 }
586}
587
588#[cfg(feature = "db")]
589impl Default for RequestQueryTimer {
590 /// A timer on the real system clock — the behaviour before the clock became
591 /// injectable. Used by the unit tests that drive `on_start`/`on_finish`
592 /// directly with synthetic instants and never read this field.
593 fn default() -> Self {
594 Self {
595 pending: None,
596 clock: std::sync::Arc::new(crate::time::SystemClock),
597 turn: crate::sim::gate::QueryTurn::default(),
598 }
599 }
600}
601
602/// A statement whose `StartQuery` has fired but whose `FinishQuery` has not.
603///
604/// Holds the start instant (for latency) and the materialised SQL text (so a
605/// capturing accumulator can retain it). Queries on a single connection are
606/// strictly sequential, so a single slot suffices.
607#[cfg(feature = "db")]
608#[derive(Debug)]
609struct PendingQuery {
610 started_at: crate::time::MonotonicInstant,
611 sql: String,
612}
613
614#[cfg(feature = "db")]
615impl RequestQueryTimer {
616 /// A timer reading its instants from `clock` (the app's injected clock).
617 fn with_clock(clock: std::sync::Arc<dyn crate::time::ClockSource>) -> Self {
618 Self {
619 pending: None,
620 clock,
621 turn: crate::sim::gate::QueryTurn::default(),
622 }
623 }
624
625 /// Whether `sql` is a statement that must **not** be counted as an
626 /// application query in the `Server-Timing` `db` metric. Two kinds reach
627 /// the connection instrumentation but are not application work:
628 ///
629 /// * **Transaction control** that diesel-async runs via `batch_execute`
630 /// (which emits a `StartQuery`/`FinishQuery` pair just like a real
631 /// query): `BEGIN`, `COMMIT`, `ROLLBACK`, the `SAVEPOINT`/`RELEASE`
632 /// nested-transaction variants, and the two-word `START TRANSACTION` form.
633 /// * **Session/config housekeeping** — any leading-token `SET`. This covers
634 /// the `SET statement_timeout` that [`Db::checkout`] issues on every
635 /// checkout before handing the connection to the request, as well as
636 /// `SET TRANSACTION`. None of these are application queries.
637 ///
638 /// Matches a case-insensitive leading token in
639 /// `{BEGIN, COMMIT, ROLLBACK, SAVEPOINT, RELEASE, SET}`, plus the two-word
640 /// `START TRANSACTION` form. See the type-level docs.
641 fn is_uncounted_statement(sql: &str) -> bool {
642 let mut tokens = sql.split_whitespace();
643 let Some(first) = tokens.next() else {
644 return false;
645 };
646 match first.to_ascii_uppercase().as_str() {
647 "BEGIN" | "COMMIT" | "ROLLBACK" | "SAVEPOINT" | "RELEASE" | "SET" => true,
648 "START" => tokens
649 .next()
650 .is_some_and(|second| second.eq_ignore_ascii_case("TRANSACTION")),
651 _ => false,
652 }
653 }
654
655 /// Record the start of a statement.
656 ///
657 /// Probes both [`request_db_timing_active`] and
658 /// [`request_query_capture_active`] first: when NEITHER the timing
659 /// accumulator nor the capture sink is scoped (the opted-out / off-request
660 /// path) the timer does nothing and — crucially — never invokes `sql`, so
661 /// the caller's `DebugQuery` `to_string()` allocation is skipped entirely.
662 /// This is what makes it safe to leave a `RequestQueryTimer` installed on a
663 /// pooled connection that a later opted-out request reuses: the stale timer
664 /// is a cheap bool-probe no-op, not a per-query allocator.
665 ///
666 /// When either lane *is* active, the SQL text is materialised and inspected:
667 /// housekeeping / transaction-control statements (see
668 /// [`Self::is_uncounted_statement`], e.g. the checkout `SET
669 /// statement_timeout`) leave the slot empty so they are excluded from the
670 /// accumulator; genuine application statements record their start instant.
671 /// Clearing the slot keeps the matching `FinishQuery` a no-op.
672 ///
673 /// `sql` is a closure so the (allocating) formatting is deferred until we
674 /// know the statement will be counted. Extracted from the event handler so
675 /// the start/finish accounting is unit-testable without constructing a
676 /// (non-exhaustive, unstable-to-build) `InstrumentationEvent`.
677 fn on_start(&mut self, now: crate::time::MonotonicInstant, sql: impl FnOnce() -> String) {
678 // Probe BOTH lanes: the timing accumulator (`server_timing`) and the
679 // query-capture sink (test harness). Either being active means the
680 // upcoming statement must be observed. When neither is scoped (the
681 // opted-out / off-request path) the timer does nothing and never
682 // invokes `sql`, so the caller's `DebugQuery` `to_string()` allocation
683 // is skipped — keeping a stale installed timer a cheap bool-probe no-op.
684 if !request_db_lane_active() {
685 self.pending = None;
686 return;
687 }
688 // Materialise the SQL once: it is needed both for the housekeeping
689 // filter and (when a test opted into capture) to retain the statement
690 // text at `on_finish`.
691 let sql = sql();
692 self.pending = if Self::is_uncounted_statement(&sql) {
693 None
694 } else {
695 Some(PendingQuery {
696 started_at: now,
697 sql,
698 })
699 };
700 }
701
702 /// Record the completion of the in-flight statement, accumulating its
703 /// elapsed time into the per-request accumulator. A `FinishQuery` without
704 /// a matching `StartQuery` is ignored.
705 fn on_finish(&mut self, now: crate::time::MonotonicInstant) {
706 if let Some(p) = self.pending.take() {
707 record_request_db_query(now.saturating_duration_since(p.started_at), Some(&p.sql));
708 }
709 }
710}
711
712#[cfg(feature = "db")]
713impl diesel::connection::Instrumentation for RequestQueryTimer {
714 fn on_connection_event(&mut self, event: diesel::connection::InstrumentationEvent<'_>) {
715 use diesel::connection::InstrumentationEvent;
716 match event {
717 InstrumentationEvent::StartQuery { query, .. } => {
718 // `query` is an opaque `&dyn DebugQuery`; its `Display` impl
719 // renders the SQL text, which we inspect to skip housekeeping /
720 // transaction-control statements (see the type-level docs). The
721 // `to_string()` is deferred behind a closure so an installed but
722 // opted-out timer never pays the allocation — see `on_start`.
723 let now = self.clock.monotonic();
724 self.on_start(now, || query.to_string());
725 self.turn.start();
726 }
727 InstrumentationEvent::FinishQuery { .. } => {
728 self.turn.finish();
729 let now = self.clock.monotonic();
730 self.on_finish(now);
731 }
732 // Ignore connection-establish, prepared-statement cache, and the
733 // dedicated Begin/Commit/RollbackTransaction events — see the
734 // type-level docs.
735 _ => {}
736 }
737 }
738}
739
740/// Total count of after-commit callback errors since process start.
741///
742/// Incremented each time a callback registered via [`register_after_commit`]
743/// or [`Db::tx`] returns an error **after** the transaction has already
744/// committed. The underlying transaction is unaffected; this counter surfaces
745/// failures for alerting and dashboards.
746///
747/// Exposed by the `/actuator/health` endpoint as the top-level
748/// `autumn_after_commit_failures_total` field.
749pub static AFTER_COMMIT_FAILURES_TOTAL: AtomicU64 = AtomicU64::new(0);
750
751pub(crate) fn record_after_commit_failure() -> u64 {
752 AFTER_COMMIT_FAILURES_TOTAL.fetch_add(1, Ordering::Relaxed) + 1
753}
754
755/// Total number of transaction retries triggered by a transient serialization
756/// failure (`40001`) or deadlock (`40P01`) since process start.
757///
758/// Incremented by [`Db::tx_with`] each time a retryable transaction error is
759/// re-run under a stronger isolation level. Surfaces contention on the existing
760/// metrics surface (`autumn_tx_retries_total`).
761pub static TX_RETRIES_TOTAL: AtomicU64 = AtomicU64::new(0);
762
763/// Total number of transactions that exhausted their retry budget without
764/// succeeding, since process start. Exposed as `autumn_tx_retry_exhausted_total`.
765pub static TX_RETRY_EXHAUSTED_TOTAL: AtomicU64 = AtomicU64::new(0);
766
767// The transaction serialization-failure retry loop is Postgres-only (SQLite's
768// `tx_with` runs a single plain transaction), so these retry-metric helpers are
769// unused in a `--features sqlite` library build. They are still exercised by the
770// crate's unit tests, so keep them compiled and only silence the dead-code
771// warning under the feature.
772#[cfg_attr(feature = "sqlite", allow(dead_code))]
773pub(crate) fn record_tx_retry() -> u64 {
774 TX_RETRIES_TOTAL.fetch_add(1, Ordering::Relaxed) + 1
775}
776
777#[cfg_attr(feature = "sqlite", allow(dead_code))]
778pub(crate) fn record_tx_retry_exhausted() -> u64 {
779 TX_RETRY_EXHAUSTED_TOTAL.fetch_add(1, Ordering::Relaxed) + 1
780}
781
782/// Guidance appended to the nested-`tx` rejection, naming the supported
783/// alternative for a same-connection nested transaction.
784const NESTED_TX_MESSAGE: &str = "Nested Db::tx calls are not supported; use \
785 autumn_web::db::savepoint(conn, ..) inside the closure for a same-connection savepoint";
786
787pub(crate) fn reject_ambient_after_commit_registry_for_tx() -> Result<(), AutumnError> {
788 if AFTER_COMMIT_REGISTRY.try_with(|_| ()).is_ok() {
789 return Err(AutumnError::bad_request_msg(NESTED_TX_MESSAGE));
790 }
791 Ok(())
792}
793
794/// Run the committed transaction's after-commit callbacks on a new task.
795///
796/// A spawned task does not inherit task-locals, so the [`TxTimeouts`] in
797/// scope here (the request's, or a scheduled task's or job's) are carried
798/// into it. A repository write in a callback then sets them as well (#3057).
799pub(crate) fn spawn_committed_after_commit_callbacks(
800 callbacks: Vec<CommitCallback>,
801) -> Option<tokio::task::JoinHandle<()>> {
802 if callbacks.is_empty() {
803 return None;
804 }
805 // The callbacks run on a detached task, where capture cannot see their
806 // effects (#2351 item 3). A `register_after_commit` callback is counted
807 // from its registration, and marks the capsule incomplete here. A
808 // framework callback recorded its effect at registration and marks
809 // nothing. A rolled-back transaction drops its callbacks, so it marks
810 // nothing.
811 #[cfg(feature = "reporting")]
812 crate::capsule::boundary::note_committed_after_commit();
813
814 let timeouts = TxTimeouts::current().unwrap_or_default();
815 let callbacks_run = async move {
816 for cb in callbacks {
817 let result = match std::panic::catch_unwind(AssertUnwindSafe(cb)) {
818 Ok(callback) => AssertUnwindSafe(callback).catch_unwind().await,
819 Err(panic) => Err(panic),
820 };
821
822 match result {
823 Ok(Ok(())) => {}
824 Ok(Err(e)) => {
825 let failures_total = record_after_commit_failure();
826 tracing::error!(
827 autumn.after_commit.failures_total = failures_total,
828 "after_commit callback failed (tx already committed): {e}"
829 );
830 }
831 Err(panic) => {
832 let failures_total = record_after_commit_failure();
833 let panic = after_commit_panic_message(&*panic);
834 tracing::error!(
835 autumn.after_commit.failures_total = failures_total,
836 "after_commit callback panicked (tx already committed): {panic}"
837 );
838 }
839 }
840 }
841 };
842 // During a capsule replay the callbacks get the replay tape, so their
843 // effects are served or refused and never reach live services (#2351
844 // item 3).
845 #[cfg(feature = "reporting")]
846 let callbacks_run = crate::capsule::boundary::carry_detached(callbacks_run);
847 Some(tokio::task::spawn(
848 TX_TIMEOUTS.scope(timeouts, callbacks_run),
849 ))
850}
851
852fn after_commit_panic_message(payload: &(dyn Any + Send)) -> String {
853 if let Some(message) = payload.downcast_ref::<&'static str>() {
854 return (*message).to_owned();
855 }
856
857 if let Some(message) = payload.downcast_ref::<String>() {
858 return message.clone();
859 }
860
861 "non-string panic payload".to_owned()
862}
863
864/// Register a callback to run after the current database transaction commits.
865///
866/// If called inside a [`Db::tx`] block, the callback is deferred until the
867/// transaction commits successfully. On rollback the callback is dropped
868/// without being called.
869///
870/// The deferred callback is process-local work spawned after commit. It avoids
871/// side effects for rolled-back transactions, but it is not a crash-safe
872/// delivery mechanism. For side effects that must survive process exit, write a
873/// durable outbox or queue row inside the same database transaction and use
874/// this callback only as an optional wake-up hint.
875///
876/// If called **outside** any active transaction, the callback runs immediately
877/// (eager execution) with a `debug`-level log note.
878///
879/// # Panics
880///
881/// Panics if the internal registry mutex is poisoned (only possible if a
882/// previous thread holding the lock panicked, which should not occur in normal
883/// operation).
884///
885/// # Example
886///
887/// ```rust,ignore
888/// db.tx(move |conn| {
889/// scoped_boxed(async move {
890/// diesel::insert_into(users::table).values(&new_user).execute(conn).await?;
891/// autumn_web::db::register_after_commit(|| async {
892/// welcome_email_job.enqueue("user_id", user_id).await
893/// }).await;
894/// Ok(())
895/// })
896/// }).await?;
897/// ```
898pub async fn register_after_commit<F, Fut>(f: F)
899where
900 F: FnOnce() -> Fut + Send + 'static,
901 Fut: Future<Output = crate::AutumnResult<()>> + Send + 'static,
902{
903 let mut f_opt = Some(f);
904 AFTER_COMMIT_REGISTRY
905 .try_with(|registry| {
906 let f = f_opt.take().expect("closure only entered once");
907 // Counted until it runs, or until a rollback drops it.
908 #[cfg(feature = "reporting")]
909 let pending = crate::capsule::boundary::PendingAfterCommit::register();
910 let boxed: CommitCallback = Box::new(move || {
911 #[cfg(feature = "reporting")]
912 pending.started();
913 Box::pin(f())
914 });
915 registry.lock().expect("registry lock").push(boxed);
916 })
917 .ok();
918 // If still Some, the task-local wasn't set — we're outside a tx; run eagerly.
919 if let Some(f) = f_opt {
920 tracing::debug!("register_after_commit: no active transaction; running callback eagerly");
921 if let Err(e) = f().await {
922 let failures_total = record_after_commit_failure();
923 tracing::error!(
924 autumn.after_commit.failures_total = failures_total,
925 "register_after_commit eager callback failed: {e}"
926 );
927 }
928 }
929}
930
931/// Trait to abstract the state requirement for the `Db` extractor.
932/// This breaks the circular dependency between the database extractor
933/// and the central `AppState`.
934/// Process-wide handle to the real system clock, cloned by
935/// [`DbState::clock`]'s default body and by the state-less checkout paths.
936///
937/// A fresh `Arc::new(SystemClock)` per DB extraction would heap-allocate for a
938/// zero-sized type on every request; cloning one shared handle is a relaxed
939/// refcount bump.
940static DEFAULT_SYSTEM_CLOCK: std::sync::LazyLock<std::sync::Arc<dyn crate::time::ClockSource>> =
941 std::sync::LazyLock::new(|| std::sync::Arc::new(crate::time::SystemClock));
942
943pub trait DbState {
944 /// Returns the database connection pool, if configured.
945 fn pool(&self) -> Option<&Pool<RuntimeConnection>>;
946
947 /// Returns the metrics collector, if configured.
948 fn metrics(&self) -> Option<&crate::middleware::MetricsCollector> {
949 None
950 }
951
952 /// Returns the read/replica connection pool, if configured.
953 fn replica_pool(&self) -> Option<&Pool<RuntimeConnection>> {
954 None
955 }
956
957 /// Returns the pool used for read-only work.
958 ///
959 /// Defaults to the replica role when present, otherwise the primary role.
960 fn read_pool(&self) -> Option<&Pool<RuntimeConnection>> {
961 self.replica_pool().or_else(|| self.pool())
962 }
963
964 /// Returns the configured shard set, when `[[database.shards]]`
965 /// entries exist. Defaults to `None` so unsharded states need no
966 /// changes.
967 fn shards(&self) -> Option<&crate::sharding::ShardSet> {
968 None
969 }
970
971 /// Returns any registered database connection checkout interceptors.
972 fn db_interceptors(
973 &self,
974 ) -> Vec<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>> {
975 Vec::new()
976 }
977
978 /// Why failure-capsule capture cannot record this app's database traffic,
979 /// when its pool topology carries a gap (see
980 /// [`DatabaseTopology::capture_gap`]). Defaults to `None`: the pools
981 /// record, or capture is off entirely.
982 #[cfg(feature = "reporting")]
983 fn db_capture_gap(&self) -> Option<std::sync::Arc<str>> {
984 None
985 }
986 /// Returns the global statement timeout, if configured.
987 fn statement_timeout(&self) -> Option<std::time::Duration> {
988 None
989 }
990
991 /// Returns the global `idle_in_transaction_session_timeout`, if
992 /// configured (#3057).
993 fn idle_in_transaction_timeout(&self) -> Option<std::time::Duration> {
994 None
995 }
996
997 /// Returns the app's injected clock, used to time connection checkouts and
998 /// individual statements.
999 ///
1000 /// Defaults to the real [`crate::time::SystemClock`], which is exactly what
1001 /// this timing read was before the clock became injectable — so an existing
1002 /// `impl DbState` needs no change. [`crate::state::AppState`] overrides it
1003 /// with the clock the app was built with, which is what makes DB timings
1004 /// virtual (and reproducible) under a `#[sim_test]`.
1005 fn clock(&self) -> std::sync::Arc<dyn crate::time::ClockSource> {
1006 std::sync::Arc::clone(&DEFAULT_SYSTEM_CLOCK)
1007 }
1008
1009 /// Returns the slow query threshold.
1010 fn slow_query_threshold(&self) -> std::time::Duration {
1011 std::time::Duration::from_millis(500)
1012 }
1013}
1014
1015// ── SQL telemetry helpers ─────────────────────────────────────────────────────
1016
1017/// Scrub a SQL string to remove literal parameter values.
1018///
1019/// Replaces values with `?` placeholders to prevent PII leakage in
1020/// slow-query logs while still surfacing the query shape for performance
1021/// analysis.
1022///
1023/// Rules:
1024/// - Single-quoted string literals `'...'` → `'?'`
1025/// - Unquoted integer/float literals → `?`
1026/// - Postgres `$N` positional parameters are left untouched
1027///
1028/// # Examples
1029///
1030/// ```
1031/// use autumn_web::db::scrub_sql;
1032///
1033/// assert_eq!(scrub_sql("SELECT * FROM users WHERE name = 'Alice'"),
1034/// "SELECT * FROM users WHERE name = '?'");
1035/// assert_eq!(scrub_sql("SELECT * FROM orders WHERE id = 42"),
1036/// "SELECT * FROM orders WHERE id = ?");
1037/// assert_eq!(scrub_sql("SELECT * FROM t WHERE x = $1"),
1038/// "SELECT * FROM t WHERE x = $1");
1039/// ```
1040/// Consumes the body of an E-string escape literal and its closing `'`.
1041///
1042/// Called after the opening `'` has already been consumed. Handles
1043/// `\'` backslash-escaped quotes so they do not prematurely close the string.
1044fn consume_estring_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
1045 loop {
1046 match chars.next() {
1047 None => break,
1048 Some('\'') => {
1049 if chars.peek() == Some(&'\'') {
1050 chars.next(); // consume the doubled quote
1051 } else {
1052 break;
1053 }
1054 }
1055 Some('\\') => {
1056 chars.next(); // skip the character after the backslash
1057 }
1058 Some(_) => {}
1059 }
1060 }
1061}
1062
1063/// Consumes the body of a dollar-quoted string and its closing `$tag$`.
1064///
1065/// Called after the opening `$tag$` delimiter has already been consumed.
1066/// Uses a simple sliding-window match — sufficient for valid SQL.
1067fn consume_dollar_quoted_body(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, tag: &str) {
1068 let closing: Vec<char> = format!("${tag}$").chars().collect();
1069 let clen = closing.len();
1070 let mut match_count = 0usize;
1071 for sc in chars.by_ref() {
1072 if sc == closing[match_count] {
1073 match_count += 1;
1074 if match_count == clen {
1075 break; // Found the closing delimiter.
1076 }
1077 } else {
1078 match_count = 0;
1079 // The current char may start a new partial match.
1080 if sc == closing[0] {
1081 match_count = 1;
1082 }
1083 }
1084 }
1085}
1086
1087/// Returns true for every char that can legally precede a bare numeric
1088/// literal in SQL — whitespace, comparison, arithmetic, and structural chars.
1089#[inline]
1090const fn is_separator(c: char) -> bool {
1091 matches!(
1092 c,
1093 ' ' | '\t' | '\n' // whitespace
1094 | '=' | '<' | '>' // comparison
1095 | '!' | '+' | '-' // arithmetic / negation (signed literals)
1096 | '*' | '/' | '%' // arithmetic operators
1097 | '(' | ',' // structure
1098 )
1099}
1100
1101#[must_use]
1102pub fn scrub_sql(sql: &str) -> String {
1103 let mut out = String::with_capacity(sql.len());
1104 // Tracks whether the last character written was a separator, so a digit
1105 // at the current position starts a standalone literal rather than being
1106 // part of an identifier like `table1` or `col2`.
1107 let mut prev_is_sep = true; // treat start-of-input as a separator boundary
1108
1109 let mut chars = sql.chars().peekable();
1110
1111 while let Some(c) = chars.next() {
1112 // ── E-string literal E'...' / e'...' (backslash-escape aware) ──
1113 // Must be checked before the single-quote handler so we consume the
1114 // `E` prefix and don't leave it in the fingerprint.
1115 if (c == 'E' || c == 'e') && chars.peek() == Some(&'\'') {
1116 chars.next(); // consume the opening '
1117 out.push_str("'?'");
1118 prev_is_sep = false;
1119 consume_estring_body(&mut chars);
1120 continue;
1121 }
1122
1123 // ── Single-quoted string literal ─────────────────────────────────
1124 if c == '\'' {
1125 out.push_str("'?'");
1126 prev_is_sep = false;
1127 loop {
1128 match chars.next() {
1129 None => break,
1130 Some('\'') => {
1131 if chars.peek() == Some(&'\'') {
1132 // Escaped quote ('') — consume both, stay inside string
1133 chars.next();
1134 } else {
1135 // Closing quote
1136 break;
1137 }
1138 }
1139 Some(_) => {}
1140 }
1141 }
1142 continue;
1143 }
1144
1145 // ── Dollar sign: positional parameter or dollar-quoted string ─────
1146 if c == '$' {
1147 let next_ch = chars.peek().copied();
1148
1149 // Positional parameter $N — pass through verbatim.
1150 if next_ch.is_some_and(|nc| nc.is_ascii_digit()) {
1151 out.push('$');
1152 prev_is_sep = false;
1153 while chars.peek().is_some_and(char::is_ascii_digit) {
1154 if let Some(d) = chars.next() {
1155 out.push(d);
1156 }
1157 }
1158 continue;
1159 }
1160
1161 // Dollar-quoted string: $$ (anonymous) or $tag$ (tagged).
1162 // Collect the optional tag, looking for the second `$`.
1163 let mut tag = String::new();
1164 let mut found_closing_dollar = false;
1165
1166 if next_ch == Some('$') {
1167 // Anonymous $$: consume the second `$`.
1168 chars.next();
1169 found_closing_dollar = true;
1170 } else if next_ch.is_some_and(|nc| nc.is_alphabetic() || nc == '_') {
1171 // Accumulate tag chars until we hit `$` or a non-identifier char.
1172 while let Some(&tc) = chars.peek() {
1173 if tc == '$' {
1174 chars.next(); // consume the closing `$` of the opening tag
1175 found_closing_dollar = true;
1176 break;
1177 } else if tc.is_alphanumeric() || tc == '_' {
1178 tag.push(tc);
1179 chars.next();
1180 } else {
1181 // Not a valid tag character — not a dollar-quoted string.
1182 break;
1183 }
1184 }
1185 }
1186
1187 if found_closing_dollar {
1188 out.push_str("'?'");
1189 prev_is_sep = false;
1190 consume_dollar_quoted_body(&mut chars, &tag);
1191 } else {
1192 // Not a recognisable dollar form — emit $ and any partial tag.
1193 out.push('$');
1194 out.push_str(&tag);
1195 prev_is_sep = false;
1196 }
1197 continue;
1198 }
1199
1200 // ── Unquoted numeric literal ──────────────────────────────────────
1201 // Only scrub when preceded by a separator to avoid stomping on
1202 // identifiers like `table1`, `col2`, or `alias99`.
1203 let is_leading_dot =
1204 c == '.' && prev_is_sep && chars.peek().is_some_and(char::is_ascii_digit);
1205 if (c.is_ascii_digit() && prev_is_sep) || is_leading_dot {
1206 out.push('?');
1207 if is_leading_dot {
1208 chars.next(); // consume the leading dot
1209 }
1210 // Consume integer/decimal digits, underscores, and dots.
1211 while chars
1212 .peek()
1213 .is_some_and(|d| d.is_ascii_digit() || *d == '.' || *d == '_')
1214 {
1215 chars.next();
1216 }
1217 // Consume optional scientific-notation exponent: e/E [+/-] <digits>.
1218 if chars.peek().is_some_and(|e| *e == 'e' || *e == 'E') {
1219 chars.next(); // consume 'e'/'E'
1220 if chars.peek().is_some_and(|s| *s == '+' || *s == '-') {
1221 chars.next(); // consume optional sign
1222 }
1223 while chars.peek().is_some_and(char::is_ascii_digit) {
1224 chars.next();
1225 }
1226 }
1227 prev_is_sep = false;
1228 continue;
1229 }
1230
1231 // ── Regular character ─────────────────────────────────────────────
1232 out.push(c);
1233 prev_is_sep = is_separator(c);
1234 }
1235
1236 out
1237}
1238
1239/// Instrument a database query: time it, log slow queries with a scrubbed SQL
1240/// fingerprint, record metrics, and map Postgres `57014` (statement timeout)
1241/// to [`AutumnError::query_timeout`].
1242///
1243/// Times the query on the **real** system clock. Prefer
1244/// [`run_instrumented_with_clock`] where an injected clock is reachable, so the
1245/// recorded latency follows virtual time under a
1246/// [`#[sim_test]`](crate::sim_test).
1247///
1248/// # Parameters
1249/// - `sql`: The raw SQL string for slow-query fingerprinting (scrubbed before logging).
1250/// - `route_key`: Label string used for metrics, e.g. `"GET /users"`.
1251/// - `slow_threshold`: Queries taking longer than this emit a `WARN` log.
1252/// - `metrics`: The [`crate::middleware::MetricsCollector`] to record into.
1253/// - `query`: The async closure that actually executes the query.
1254///
1255/// # Returns
1256/// The result of `query()`, with Postgres `57014` mapped to
1257/// [`AutumnError::query_timeout`].
1258///
1259/// # Errors
1260/// Returns [`AutumnError`] from the underlying query, or [`AutumnError::query_timeout`]
1261/// when Postgres cancels the statement due to `statement_timeout`.
1262pub async fn run_instrumented<F, Fut, T>(
1263 sql: &str,
1264 route_key: &str,
1265 slow_threshold: std::time::Duration,
1266 metrics: &crate::middleware::metrics::MetricsCollector,
1267 query: F,
1268) -> Result<T, AutumnError>
1269where
1270 F: FnOnce() -> Fut,
1271 Fut: std::future::Future<Output = Result<T, diesel::result::Error>>,
1272{
1273 // No state here, so the ambient clock (issue #2967): a running `Sim`
1274 // controls it.
1275 run_instrumented_with_clock(
1276 &crate::time::AmbientClock,
1277 sql,
1278 route_key,
1279 slow_threshold,
1280 metrics,
1281 query,
1282 )
1283 .await
1284}
1285
1286/// [`run_instrumented`], timing the query on an injected clock.
1287///
1288/// The determinism-seam twin (#1797): pass `state.clock()` and the recorded
1289/// latency follows virtual time under a [`#[sim_test]`](crate::sim_test)
1290/// instead of the real machine clock.
1291///
1292/// ```rust,ignore
1293/// let rows = autumn_web::db::run_instrumented_with_clock(
1294/// state.clock(),
1295/// "SELECT …",
1296/// "GET /users",
1297/// slow_threshold,
1298/// state.metrics(),
1299/// || users::table.load(&mut conn),
1300/// )
1301/// .await?;
1302/// ```
1303///
1304/// # Parameters
1305///
1306/// As [`run_instrumented`], plus `clock`: the source both timing readings are
1307/// taken from. Borrowed rather than owned so
1308/// [`AppState::clock`](crate::state::AppState::clock) — which hands out a
1309/// `&dyn ClockSource` — is directly usable; no `Arc` handle is needed.
1310///
1311/// # Errors
1312///
1313/// As [`run_instrumented`].
1314pub async fn run_instrumented_with_clock<F, Fut, T>(
1315 clock: &dyn crate::time::ClockSource,
1316 sql: &str,
1317 route_key: &str,
1318 slow_threshold: std::time::Duration,
1319 metrics: &crate::middleware::metrics::MetricsCollector,
1320 query: F,
1321) -> Result<T, AutumnError>
1322where
1323 F: FnOnce() -> Fut,
1324 Fut: std::future::Future<Output = Result<T, diesel::result::Error>>,
1325{
1326 let start = clock.monotonic();
1327 let result = query().await;
1328 let elapsed = clock.monotonic().saturating_duration_since(start);
1329 let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
1330
1331 // Record metrics regardless of success/failure
1332 let verb = sql.split_whitespace().next().unwrap_or("?");
1333 // Composed on the stack rather than into a `String` per query. Kept to
1334 // this one statement: `fmt::Arguments` is `!Send` and must not live across
1335 // an `.await` of this future.
1336 metrics.record_db_query_fmt(format_args!("{route_key} {verb}"), elapsed_ms);
1337 // NOTE: deliberately *not* recorded into the per-request Server-Timing
1338 // accumulator. The connection-level `RequestQueryTimer` instrumentation
1339 // (installed at `Db::checkout`) already brackets every executed statement
1340 // — including any query run through this helper — so recording here too
1341 // would double-count it in `db;dur` and `desc="N queries"`.
1342
1343 // Log slow queries with scrubbed SQL
1344 if elapsed >= slow_threshold {
1345 let fingerprint = scrub_sql(sql);
1346 tracing::warn!(
1347 route = %route_key,
1348 sql = %fingerprint,
1349 duration_ms = elapsed_ms,
1350 "slow database query"
1351 );
1352 }
1353
1354 // Map result — translate Postgres 57014 to query_timeout
1355 result.map_err(|db_err| {
1356 if is_query_canceled(&db_err) {
1357 tracing::warn!(
1358 route = %route_key,
1359 duration_ms = elapsed_ms,
1360 "database query cancelled: statement_timeout exceeded"
1361 );
1362 AutumnError::query_timeout(format!(
1363 "Database query timed out after {elapsed_ms}ms (statement_timeout exceeded)"
1364 ))
1365 } else {
1366 AutumnError::from(db_err)
1367 }
1368 })
1369}
1370
1371/// Walk `err`'s source chain looking for a `tokio_postgres` SQLSTATE matching
1372/// `predicate`, downcasting each link to [`tokio_postgres::Error`] and
1373/// [`tokio_postgres::error::DbError`] in turn. Shared by [`is_query_canceled`]
1374/// and [`is_retryable_txn_error`] — both need the same downcast-through-the-
1375/// chain strategy, only with a different SQLSTATE to look for.
1376#[cfg(feature = "postgres")]
1377fn source_chain_has_sqlstate(
1378 err: &(dyn std::error::Error + 'static),
1379 predicate: impl Fn(&tokio_postgres::error::SqlState) -> bool,
1380) -> bool {
1381 let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
1382 while let Some(e) = source {
1383 if e.downcast_ref::<tokio_postgres::Error>()
1384 .and_then(tokio_postgres::Error::code)
1385 .is_some_and(&predicate)
1386 {
1387 return true;
1388 }
1389 if e.downcast_ref::<tokio_postgres::error::DbError>()
1390 .is_some_and(|db_err| predicate(db_err.code()))
1391 {
1392 return true;
1393 }
1394 source = e.source();
1395 }
1396 false
1397}
1398
1399/// Check whether a Diesel error wraps a Postgres `57014` `query_canceled` error.
1400///
1401/// Prefers downcasting through the source chain to find a
1402/// [`tokio_postgres::Error`] and checking its SQL state code directly,
1403/// which is more robust than string-matching error messages.
1404fn is_query_canceled(err: &diesel::result::Error) -> bool {
1405 // Robust string-matching first to catch wrapped/unwrapped representations
1406 let err_str = err.to_string().to_lowercase();
1407 if err_str.contains("57014")
1408 || err_str.contains("query_canceled")
1409 || err_str.contains("canceling statement due to statement timeout")
1410 || err_str.contains("statement timeout")
1411 || err_str.contains("query canceled")
1412 {
1413 return true;
1414 }
1415
1416 // Without the Postgres client there is no SQLSTATE to read.
1417 #[cfg(feature = "postgres")]
1418 {
1419 source_chain_has_sqlstate(err, |state| {
1420 *state == tokio_postgres::error::SqlState::QUERY_CANCELED
1421 })
1422 }
1423 #[cfg(not(feature = "postgres"))]
1424 {
1425 false
1426 }
1427}
1428
1429/// Error type for pool creation failures.
1430///
1431/// Returned by [`create_pool`] (and the other topology builders) when a pool
1432/// cannot be constructed. Historically this was a bare alias for deadpool's
1433/// `BuildError`; it now also carries the boot-time refusal emitted when the
1434/// configured backend is not the one this build serves — a `SQLite` target in a
1435/// default build, or a Postgres target under `--features sqlite` (issue #1614)
1436/// — so callers fail fast at pool construction with an actionable message
1437/// instead of at the first query. The [`Build`](PoolError::Build) variant delegates its `Display` to the
1438/// underlying `BuildError`, so the Postgres path's error text is unchanged.
1439#[derive(Debug, thiserror::Error)]
1440#[non_exhaustive]
1441pub enum PoolError {
1442 /// The underlying deadpool builder failed (e.g., timeouts configured
1443 /// without a runtime, or an invalid max-size configuration).
1444 #[error(transparent)]
1445 Build(#[from] diesel_async::pooled_connection::deadpool::BuildError),
1446
1447 /// A recognized backend that this build does not serve was configured. The
1448 /// message names the feature that serves it. See
1449 /// [`DatabaseBackend`](crate::config::DatabaseBackend).
1450 #[error("{0}")]
1451 UnsupportedBackend(String),
1452}
1453
1454/// Primary plus optional read-replica database pools.
1455#[derive(Clone)]
1456pub struct DatabaseTopology {
1457 primary: Pool<RuntimeConnection>,
1458 replica: Option<Pool<RuntimeConnection>>,
1459 /// Connection URL to target with startup migrations, when the provider
1460 /// resolved one at runtime that the static config doesn't carry (e.g. the
1461 /// managed-Postgres provider whose socket URL is only known after boot).
1462 /// Scoping it to the topology keeps it per-app instead of a process global.
1463 migration_url: Option<String>,
1464 /// Why failure-capsule capture cannot record this topology's traffic, when
1465 /// the pool factory had to step aside (a TLS-required URL, a custom
1466 /// provider). Scoped to the topology for the same reason as
1467 /// `migration_url`: two apps in one process can disagree, and one app's
1468 /// gap must never mark the other app's capsules truncated.
1469 #[cfg(feature = "reporting")]
1470 capture_gap: Option<String>,
1471}
1472
1473impl DatabaseTopology {
1474 /// Build a topology from explicit primary and optional replica pools.
1475 ///
1476 /// This is useful for custom [`DatabasePoolProvider`] implementations that
1477 /// need to create or decorate both roles themselves.
1478 #[must_use]
1479 pub const fn from_pools(
1480 primary: Pool<RuntimeConnection>,
1481 replica: Option<Pool<RuntimeConnection>>,
1482 ) -> Self {
1483 Self {
1484 primary,
1485 replica,
1486 migration_url: None,
1487 #[cfg(feature = "reporting")]
1488 capture_gap: None,
1489 }
1490 }
1491
1492 /// Build a topology from a primary pool only.
1493 #[must_use]
1494 pub const fn primary_only(primary: Pool<RuntimeConnection>) -> Self {
1495 Self {
1496 primary,
1497 replica: None,
1498 migration_url: None,
1499 #[cfg(feature = "reporting")]
1500 capture_gap: None,
1501 }
1502 }
1503
1504 /// Attach a runtime-resolved migration URL (see [`Self::migration_url`]).
1505 ///
1506 /// Providers whose primary URL isn't present in the static config — such as
1507 /// the managed-Postgres provider — call this so startup migrations target
1508 /// the pool that was actually built, without publishing the URL to a
1509 /// process-global shared across every app instance.
1510 #[must_use]
1511 pub fn with_migration_url(mut self, url: Option<String>) -> Self {
1512 self.migration_url = url;
1513 self
1514 }
1515
1516 /// The runtime-resolved migration URL, if the provider supplied one.
1517 #[must_use]
1518 pub fn migration_url(&self) -> Option<&str> {
1519 self.migration_url.as_deref()
1520 }
1521
1522 /// Record why failure-capsule capture cannot record this topology's
1523 /// traffic (see [`Self::capture_gap`]).
1524 #[cfg(feature = "reporting")]
1525 #[must_use]
1526 pub fn with_capture_gap(mut self, reason: Option<String>) -> Self {
1527 self.capture_gap = reason;
1528 self
1529 }
1530
1531 /// Why failure-capsule capture cannot record this topology's traffic, or
1532 /// `None` when it can (or capture is off).
1533 ///
1534 /// Carried on the topology — not in process state — so two apps in one
1535 /// process each note their own gap on their own capsules.
1536 #[cfg(feature = "reporting")]
1537 #[must_use]
1538 pub fn capture_gap(&self) -> Option<&str> {
1539 self.capture_gap.as_deref()
1540 }
1541
1542 /// Primary/write role pool.
1543 #[must_use]
1544 pub const fn primary(&self) -> &Pool<RuntimeConnection> {
1545 &self.primary
1546 }
1547
1548 /// Optional read/replica role pool.
1549 #[must_use]
1550 pub const fn replica(&self) -> Option<&Pool<RuntimeConnection>> {
1551 self.replica.as_ref()
1552 }
1553
1554 /// Pool used for read-only work.
1555 #[must_use]
1556 pub fn read(&self) -> &Pool<RuntimeConnection> {
1557 self.replica.as_ref().unwrap_or(&self.primary)
1558 }
1559}
1560
1561/// Whether continuous `SQLite` replication (#1628) is active in this process.
1562///
1563/// Process-global on purpose. The per-connection pragma batch below is installed
1564/// by a `custom_setup` callback that has no access to `AutumnConfig`, and
1565/// replication is inherently a single-process, single-database concern — #1614's
1566/// `SQLite` tier is single-host and single-writer, and continuous replication
1567/// *depends* on exactly one component being allowed to checkpoint.
1568static SQLITE_REPLICATION_ACTIVE: std::sync::atomic::AtomicBool =
1569 std::sync::atomic::AtomicBool::new(false);
1570
1571/// The per-connection pragma batch a pooled `SQLite` connection is set up with.
1572///
1573/// Pure over its two inputs so the decision is unit-testable without a pool, a
1574/// database, or the process-global replication latch.
1575///
1576/// * A **read-only** target gets the non-writing pragmas only: `journal_mode =
1577/// WAL` and `synchronous` both write, and would fail connection setup with
1578/// "attempt to write a readonly database", taking the whole pool 503.
1579/// * When **replication is active** (#1628), `wal_autocheckpoint = 0` is added so
1580/// the replicator is the only component that ever checkpoints. An
1581/// auto-checkpoint rewrites the main database file, which would tear a base
1582/// snapshot mid-copy, and restarts the WAL under a new salt, which would force
1583/// an expensive fresh generation every 1000 pages.
1584/// * The `busy_timeout` pragma is applied unconditionally, but it does **not**
1585/// cover `cache=shared` table-lock conflicts: those return `SQLITE_LOCKED`
1586/// without invoking the busy handler at all (the pool does not wire
1587/// `sqlite3_unlock_notify`), so the pragma is inert for that one lock class —
1588/// harmless, not harmful (issue #2881).
1589#[cfg_attr(not(feature = "sqlite"), allow(dead_code))]
1590pub(crate) const fn sqlite_connection_pragmas(read_only: bool, replicating: bool) -> &'static str {
1591 if read_only {
1592 "PRAGMA busy_timeout = 5000; \
1593 PRAGMA foreign_keys = ON;"
1594 } else if replicating {
1595 "PRAGMA busy_timeout = 5000; \
1596 PRAGMA journal_mode = WAL; \
1597 PRAGMA wal_autocheckpoint = 0; \
1598 PRAGMA synchronous = NORMAL; \
1599 PRAGMA foreign_keys = ON;"
1600 } else {
1601 "PRAGMA busy_timeout = 5000; \
1602 PRAGMA journal_mode = WAL; \
1603 PRAGMA synchronous = NORMAL; \
1604 PRAGMA foreign_keys = ON;"
1605 }
1606}
1607
1608/// Declare that continuous replication is active in this process.
1609///
1610/// Once set, every `SQLite` connection this process's pools create is given
1611/// `PRAGMA wal_autocheckpoint = 0`, so `SQLite` never restarts the write-ahead
1612/// log on its own. That matters twice over: an auto-checkpoint rewrites the
1613/// **main database file**, which would tear a base snapshot the replicator is
1614/// copying, and it restarts the WAL, which would force an expensive fresh
1615/// generation every 1000 pages. The replicator checkpoints deliberately
1616/// instead, and only once everything in the WAL is already offsite.
1617///
1618/// **Latch-only, and read per connection.** It can be turned on but never off,
1619/// because the pragma is installed by a `custom_setup` callback that runs each
1620/// time deadpool *creates* a connection — lazily, and again after a recycle —
1621/// for the whole life of the process. A second app booted in the same process
1622/// (the workspace's own consolidated test binary is exactly that) must not be
1623/// able to clear the flag out from under a replicating app's pool and let its
1624/// connections auto-checkpoint behind the replicator's back.
1625pub(crate) fn set_sqlite_replication_active() {
1626 SQLITE_REPLICATION_ACTIVE.store(true, std::sync::atomic::Ordering::Relaxed);
1627}
1628
1629/// Whether [`set_sqlite_replication_active`] has been called in this process.
1630#[cfg_attr(not(feature = "sqlite"), allow(dead_code))]
1631pub(crate) fn sqlite_replication_active() -> bool {
1632 SQLITE_REPLICATION_ACTIVE.load(std::sync::atomic::Ordering::Relaxed)
1633}
1634
1635/// Build the manager config for a Postgres pool.
1636///
1637/// Uses [`RecyclingMethod::Fast`]. Every checkout already runs `SET
1638/// statement_timeout`: see [`Db::checkout`] and the `#[repository]`-generated
1639/// acquire path. That statement is a round trip to Postgres. It already
1640/// proves the connection is alive. Deadpool's default `Verified` method
1641/// sends a second, redundant `SELECT 1` to prove the same thing. `Fast`
1642/// removes that extra round trip. A dead connection still fails fast — at
1643/// the `SET statement_timeout` call, not at the pool's own ping. (issue
1644/// #2485)
1645///
1646/// Also plugs in a TLS setup callback when the URL asks for TLS: diesel-async's
1647/// default establish path hardcodes `NoTls`, which cannot satisfy
1648/// `sslmode=require`. `sslmode` absent/`disable`/`prefer` keeps the default
1649/// (`NoTls`) path, so existing configurations behave exactly as before. See
1650/// [`tls`] for the full posture table.
1651#[cfg(not(feature = "sqlite"))]
1652fn pg_manager_config(
1653 url: &str,
1654) -> diesel_async::pooled_connection::ManagerConfig<AsyncPgConnection> {
1655 let mut config = diesel_async::pooled_connection::ManagerConfig::<AsyncPgConnection>::default();
1656 config.recycling_method = RecyclingMethod::Fast;
1657 let posture = tls::TlsPosture::from_database_url(url);
1658 if posture != tls::TlsPosture::Off {
1659 config.custom_setup = tls::setup_callback(posture);
1660 }
1661 config
1662}
1663
1664fn build_pool(
1665 url: &str,
1666 pool_size: usize,
1667 connect_timeout_secs: u64,
1668) -> Result<Pool<RuntimeConnection>, PoolError> {
1669 // Under the `sqlite` feature `RuntimeConnection` is a SQLite connection, so
1670 // the pool must be built over `SyncConnectionWrapper<SqliteConnection>`
1671 // rather than the Postgres manager below. Route to the dedicated SQLite
1672 // builder (PR2, issue #1614). This block is the whole function body under
1673 // the feature (the Postgres arms below are cfg'd out), so it is the tail
1674 // expression — no `return` needed.
1675 #[cfg(feature = "sqlite")]
1676 {
1677 build_sqlite_pool(url, pool_size, connect_timeout_secs)
1678 }
1679
1680 // Default (Postgres) build. A SQLite target is recognized here but its
1681 // runtime pool only exists in a `--features sqlite` build — the pool below
1682 // is a Postgres pool (`Pool<AsyncPgConnection>`). Refuse at pool-build
1683 // (boot) time with an actionable message so a SQLite misconfiguration fails
1684 // fast rather than reaching a confusing first-query failure. A Postgres
1685 // target skips this branch entirely.
1686 #[cfg(not(feature = "sqlite"))]
1687 if crate::config::DatabaseBackend::detect(url) == Some(crate::config::DatabaseBackend::Sqlite) {
1688 return Err(PoolError::UnsupportedBackend(format!(
1689 "SQLite is a recognized database backend but its runtime pool is only available in \
1690 a build of autumn-web compiled with `--features sqlite`; this is a default \
1691 (Postgres) build (target: {target:?})",
1692 target = crate::db_url::redact_target(url)
1693 )));
1694 }
1695
1696 #[cfg(not(feature = "sqlite"))]
1697 {
1698 let _ = crate::lock::pooler::warn_if_pooled(url);
1699 let timeout = Duration::from_secs(connect_timeout_secs);
1700 let config = pg_manager_config(url);
1701 let manager =
1702 AsyncDieselConnectionManager::<AsyncPgConnection>::new_with_config(url, config);
1703 Ok(Pool::builder(manager)
1704 .max_size(pool_size.max(1))
1705 .wait_timeout(Some(timeout))
1706 .create_timeout(Some(timeout))
1707 .runtime(deadpool::Runtime::Tokio1)
1708 .build()?)
1709 }
1710}
1711
1712/// Normalize a configured `SQLite` target into the filename token diesel's
1713/// `SqliteConnection` understands.
1714///
1715/// diesel's `SQLite` backend passes the string straight to `sqlite3_open`, which
1716/// understands a filesystem path, a `file:` URI, or the special `:memory:`
1717/// token — but **not** a `sqlite:` URL scheme. Strip the recognized `SQLite` URL
1718/// spellings down to that: `sqlite::memory:`, `sqlite://:memory:`, and an empty
1719/// `sqlite://` all become an in-memory database; `sqlite:///path` /
1720/// `sqlite://path` / `sqlite:path` reduce to their path; a `file:` URI passes
1721/// through unchanged.
1722///
1723/// A bare path passes through this function unchanged too, but no configured
1724/// target reaches it that way: [`build_sqlite_pool`] refuses anything
1725/// [`DatabaseBackend::detect`](crate::config::DatabaseBackend::detect) does not
1726/// classify as `SQLite`, and a bare path is deliberately unclassified. The two
1727/// scheme-less in-memory spellings (`:memory:` and the empty string) are the
1728/// exception the pool admits explicitly.
1729#[cfg(feature = "sqlite")]
1730fn normalize_sqlite_target(url: &str) -> String {
1731 if url.starts_with("file:") {
1732 return url.to_owned();
1733 }
1734 let rest = url
1735 .strip_prefix("sqlite://")
1736 .or_else(|| url.strip_prefix("sqlite:"))
1737 .unwrap_or(url);
1738 if rest.is_empty() || rest == ":memory:" {
1739 return String::from(":memory:");
1740 }
1741 rest.to_owned()
1742}
1743
1744/// Whether a normalized `SQLite` target names a **private** in-memory database
1745/// (each such connection is its own private database, so the pool must be
1746/// single-slot to stay consistent — see [`build_sqlite_pool`]).
1747///
1748/// Covers every in-memory spelling `SQLite` accepts through this pool: the bare
1749/// `:memory:` token, the `file:` URI form `file::memory:` (with or without a
1750/// query string — addresses Codex P1: the multi-slot default would otherwise
1751/// hand out connections that each see a different, empty in-memory database),
1752/// and any `file:` URI that asks for `mode=memory`.
1753///
1754/// A **shared-cache** in-memory database (`cache=shared`) is the deliberate
1755/// exception: it IS shareable across the pool's connections within one process,
1756/// so it must NOT be forced single-slot and returns `false` here.
1757#[cfg(feature = "sqlite")]
1758fn sqlite_target_is_memory(target: &str) -> bool {
1759 if sqlite_target_is_shared_cache(target) {
1760 return false;
1761 }
1762 target == ":memory:"
1763 || target == "file::memory:"
1764 || target.starts_with("file::memory:?")
1765 || target.contains("mode=memory")
1766}
1767
1768/// Whether a `SQLite` database URL (any accepted spelling) resolves to **any**
1769/// in-memory target — the private spellings (`sqlite::memory:` / `:memory:` /
1770/// `file::memory:`) AND the shared-cache in-memory form
1771/// (`file::memory:?cache=shared`, `file:app?mode=memory&cache=shared`).
1772///
1773/// This is deliberately broader than [`sqlite_target_is_memory`] (the pool
1774/// *sizing* predicate): it does NOT exempt `cache=shared`. It is the predicate
1775/// the startup-migration reject uses, because **no** in-memory target — private
1776/// or shared-cache — can retain a registered migration for the runtime pool. The
1777/// migration runs on a transient synchronous connection; `SQLite` destroys a
1778/// shared in-memory database the moment its *last* connection closes, and the
1779/// runtime deadpool is created lazily (it may not have checked out a connection
1780/// yet), so the pool's first checkout opens a fresh, empty in-memory database and
1781/// every DB-backed request then 500s with "no such table". Only a **file-backed**
1782/// database survives the migration connection closing, so that is the sole
1783/// supported remedy.
1784///
1785/// Pool *sizing* deliberately keeps using [`sqlite_target_is_memory`] instead:
1786/// a shared-cache in-memory database IS shareable across the pool's connections
1787/// within one live process, so it must NOT be forced single-slot. The two
1788/// predicates answer different questions ("share across the pool?" vs. "survive
1789/// the migration connection closing?") and must not be conflated.
1790#[cfg(feature = "sqlite")]
1791pub(crate) fn sqlite_target_is_any_in_memory(url: &str) -> bool {
1792 let target = normalize_sqlite_target(url);
1793 target == ":memory:"
1794 || target == "file::memory:"
1795 || target.starts_with("file::memory:?")
1796 || target.contains("mode=memory")
1797}
1798
1799/// Whether a normalized `SQLite` target names a **read-only** database via its
1800/// URI query string (`mode=ro`, or `immutable=1`/`immutable=true`).
1801///
1802/// A read-only target rejects any write, so the per-connection setup batch must
1803/// skip the write-affecting pragmas (`journal_mode = WAL`) that would otherwise
1804/// fail with "attempt to write a readonly database" and take the whole pool 503
1805/// (see [`build_sqlite_pool`]). The query string is parsed key-by-key
1806/// (case-insensitive keys and values) rather than substring-matched, so an
1807/// unrelated value that merely contains `ro` never trips it.
1808///
1809/// A plain file path, an in-memory target (`mode=memory`, which is not
1810/// read-only), and a `cache=shared` target all return `false`.
1811#[cfg(feature = "sqlite")]
1812fn sqlite_target_is_read_only(target: &str) -> bool {
1813 let Some((_, query)) = target.split_once('?') else {
1814 return false;
1815 };
1816 for pair in query.split('&') {
1817 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
1818 let (key, value) = (key.trim(), value.trim());
1819 if key.eq_ignore_ascii_case("mode") && value.eq_ignore_ascii_case("ro") {
1820 return true;
1821 }
1822 if key.eq_ignore_ascii_case("immutable")
1823 && (value == "1" || value.eq_ignore_ascii_case("true"))
1824 {
1825 return true;
1826 }
1827 }
1828 false
1829}
1830
1831/// Whether a normalized `SQLite` target enables **shared-cache mode**
1832/// (`cache=shared` in the URI query string).
1833///
1834/// Shared cache multiplexes several connections over one in-memory (or file)
1835/// database inside the process, but its table-lock protocol makes concurrent
1836/// deferred read→write transactions deadlock permanently: the deferred lock
1837/// upgrade fails with `SQLITE_LOCKED` / `SQLITE_BUSY_SNAPSHOT`, which bypasses
1838/// the busy-timeout handler, so no amount of retrying unblocks it (issue
1839/// #2885). `SQLite`'s own docs call shared-cache mode "obsolete" and
1840/// "discouraged", recommending WAL mode instead — and WAL does **not** fix this
1841/// deadlock class (the table-lock protocol is orthogonal to the journal mode).
1842///
1843/// [`Db::tx_immediate`] only stops the participating writers from all reading
1844/// before their upgrade attempts — the first `BEGIN IMMEDIATE` takes the write
1845/// transaction up front. It is not a completion guarantee (a concurrent
1846/// reader's table lock can still fail that writer's write with
1847/// `SQLITE_LOCKED_SHAREDCACHE`), and it does **not** make the other writers
1848/// wait: under shared cache their `BEGIN IMMEDIATE` fails immediately,
1849/// since `SQLite` never invokes the busy handler for `SQLITE_LOCKED` (this pool
1850/// does not wire `sqlite3_unlock_notify`). Concurrent shared-cache writers
1851/// must therefore still be serialized, or retried with backoff (each attempt
1852/// through `tx_immediate`) by the application; the real fix is a WAL-mode file
1853/// database, where `tx_immediate` does queue on the busy timeout.
1854///
1855/// Autumn keeps supporting shared cache (the test suite uses it deliberately —
1856/// see [`crate::test_urls`]), so this is a warning, not a refusal.
1857/// [`build_sqlite_pool`] logs a loud boot warning when it sees this so the
1858/// deadlock mode is never a surprise.
1859///
1860/// The same bypass means the unconditional `PRAGMA busy_timeout = 5000` from
1861/// [`sqlite_connection_pragmas`] does **not** bound shared-cache lock waits,
1862/// so the `statement_timeout` rejection message is scoped by this predicate
1863/// (issue #2881).
1864///
1865/// Only an exact `cache=shared` query pair counts; a path or another
1866/// parameter merely containing that text does not.
1867#[cfg(feature = "sqlite")]
1868pub(crate) fn sqlite_target_is_shared_cache(target: &str) -> bool {
1869 sqlite_uri_has_query_pair(target, "cache", "shared")
1870}
1871
1872/// Whether `target` is a `SQLite` URI filename whose effective `key` query
1873/// parameter is exactly `value`, read the way `SQLite` reads it: only a `file:`
1874/// URI has query parameters (a plain path containing `?` is just a filename),
1875/// the `#fragment` is ignored, names and values are percent-decoded before the
1876/// case-sensitive comparison, and a repeated parameter takes its last value.
1877/// A `%00` ends the name or value it is in. `target` may be a raw configured
1878/// URL (`sqlite:file:...`) or an already normalized one.
1879#[cfg(feature = "sqlite")]
1880fn sqlite_uri_has_query_pair(target: &str, key: &str, value: &str) -> bool {
1881 let target = normalize_sqlite_target(target);
1882 if !target.starts_with("file:") {
1883 return false;
1884 }
1885 let without_fragment = target
1886 .split_once('#')
1887 .map_or(target.as_str(), |(head, _)| head);
1888 let Some((_, query)) = without_fragment.split_once('?') else {
1889 return false;
1890 };
1891 query
1892 .split('&')
1893 .map(|pair| pair.split_once('=').unwrap_or((pair, "")))
1894 .rfind(|(k, _)| percent_decode(k) == key.as_bytes())
1895 .is_some_and(|(_, v)| percent_decode(v) == value.as_bytes())
1896}
1897
1898/// Decode `%XX` escapes the way `SQLite`'s URI parser does; a `%` not followed
1899/// by two hex digits is kept literally. A decoded NUL (`%00`) stops the
1900/// decoding. `SQLite` drops the rest of that name or value (issue #3032).
1901#[cfg(feature = "sqlite")]
1902fn percent_decode(input: &str) -> Vec<u8> {
1903 let bytes = input.as_bytes();
1904 let mut out = Vec::with_capacity(bytes.len());
1905 let mut i = 0;
1906 while i < bytes.len() {
1907 if bytes[i] == b'%'
1908 && let (Some(hi), Some(lo)) = (
1909 bytes.get(i + 1).and_then(|b| (*b as char).to_digit(16)),
1910 bytes.get(i + 2).and_then(|b| (*b as char).to_digit(16)),
1911 )
1912 {
1913 if hi == 0 && lo == 0 {
1914 break;
1915 }
1916 // Two hex digits always fit in a byte.
1917 out.push(u8::try_from(hi * 16 + lo).unwrap_or(b'%'));
1918 i += 3;
1919 } else {
1920 out.push(bytes[i]);
1921 i += 1;
1922 }
1923 }
1924 out
1925}
1926
1927/// Build a deadpool pool over `SyncConnectionWrapper<SqliteConnection>` for a
1928/// `SQLite` target (issue #1614, PR2).
1929///
1930/// `SyncConnectionWrapper` runs synchronous diesel `SqliteConnection` calls on
1931/// Tokio's blocking pool, so this integrates with the async runtime like the
1932/// Postgres path. Pool sizing is deliberately conservative: `SQLite` is
1933/// single-writer, so a large pool only multiplies `SQLITE_BUSY` contention, and
1934/// an in-memory database is **private per connection** — a multi-slot pool over
1935/// `:memory:` would hand out connections that each see a different, empty
1936/// database (so migrations applied on one would be invisible on another).
1937/// In-memory targets are therefore forced to a single slot; file targets
1938/// respect the configured size (still small by convention).
1939#[cfg(feature = "sqlite")]
1940fn build_sqlite_pool(
1941 url: &str,
1942 pool_size: usize,
1943 connect_timeout_secs: u64,
1944) -> Result<Pool<RuntimeConnection>, PoolError> {
1945 build_sqlite_pool_with(
1946 url,
1947 pool_size,
1948 connect_timeout_secs,
1949 SqliteCheckpointOwner::ProcessLatch,
1950 )
1951}
1952
1953/// Who checkpoints the WAL of a `SQLite` pool's database.
1954#[cfg(feature = "sqlite")]
1955#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1956pub(crate) enum SqliteCheckpointOwner {
1957 /// Follow [`sqlite_replication_active`], read as each connection is
1958 /// created: the control database, which the process replicator owns.
1959 ProcessLatch,
1960 /// A fleet database (ADR 0019): `true` when a fleet replicator owns its
1961 /// checkpoints, `false` when `SQLite` auto-checkpoints. Never the process
1962 /// latch — a fleet database the control replicator does not ship must
1963 /// not lose auto-checkpointing, or its `-wal` grows without bound.
1964 Fleet(bool),
1965}
1966
1967#[cfg(feature = "sqlite")]
1968impl SqliteCheckpointOwner {
1969 fn replicating(self) -> bool {
1970 match self {
1971 Self::ProcessLatch => sqlite_replication_active(),
1972 Self::Fleet(replicating) => replicating,
1973 }
1974 }
1975}
1976
1977/// [`build_sqlite_pool`] with an explicit [`SqliteCheckpointOwner`].
1978#[cfg(feature = "sqlite")]
1979pub(crate) fn build_sqlite_pool_with(
1980 url: &str,
1981 pool_size: usize,
1982 connect_timeout_secs: u64,
1983 checkpoint_owner: SqliteCheckpointOwner,
1984) -> Result<Pool<RuntimeConnection>, PoolError> {
1985 // Under the `sqlite` feature the runtime targets SQLite, so the URL must
1986 // actually NAME a SQLite target. Everything past this point treats the
1987 // string as a filename: `normalize_sqlite_target` strips the `sqlite:` /
1988 // `sqlite://` schemes and passes anything else through verbatim, so an
1989 // unguarded target is opened as a file whose name happens to be that
1990 // string. Refuse anything `DatabaseBackend::detect` does not classify as
1991 // SQLite, with a message that names the offending target and the fix.
1992 //
1993 // Two shapes reach this guard and both used to fail open:
1994 //
1995 // * A Postgres target — a build/config mismatch, and the message says so
1996 // specifically rather than talking about schemes.
1997 // * Anything `detect` returns `None` for: another backend's URL
1998 // (`mysql://…`), a typo of the scheme (`sqllite:///app.db`), or a bare
1999 // filesystem path (`/var/lib/app.db`), which `DatabaseBackend`
2000 // deliberately does not recognize. `DatabaseConfig::validate` rejects
2001 // these too, so the ordinary `AutumnConfig::load` boot path never
2002 // reaches this arm — but `create_pool`, `create_topology` and
2003 // `create_shard_topology` are public and a programmatically-built
2004 // `DatabaseConfig` or a custom `DatabasePoolProvider` gets here without
2005 // that screen. Accepting them would split the runtime from every other
2006 // consumer of the same URL — `autumn doctor`, the generator's DDL
2007 // mapping and `autumn migrate` all classify through `detect` and would
2008 // report "no recognized backend" while this pool quietly served a
2009 // database out of a junk file.
2010 //
2011 // The bare in-memory spellings are the one accepted target `detect` does
2012 // NOT classify (it recognizes schemes, and these carry none), so they are
2013 // admitted explicitly rather than by scheme: `normalize_sqlite_target` has
2014 // a dedicated branch mapping `""` and `:memory:` to `:memory:`
2015 // (`rest.is_empty() || rest == ":memory:"`), `run_pending_sqlite` names
2016 // `:memory:` among the spellings it accepts, and `TestApp::with_transactional_db`
2017 // routes whatever URL it is handed straight through `create_pool`. They also
2018 // cannot become a stray file, which is the failure this guard exists to
2019 // prevent. Refusing them would be a regression, not a tightening.
2020 match crate::config::DatabaseBackend::detect(url) {
2021 _ if crate::db_url::is_bare_in_memory_sqlite(url) => {}
2022 Some(crate::config::DatabaseBackend::Sqlite) => {}
2023 Some(crate::config::DatabaseBackend::Postgres) => {
2024 return Err(PoolError::UnsupportedBackend(format!(
2025 "this build of autumn-web targets SQLite (compiled with `--features sqlite`) but \
2026 the configured database URL is a Postgres target; configure a `sqlite:` URL \
2027 instead (target: {target:?})",
2028 target = crate::db_url::redact_target(url)
2029 )));
2030 }
2031 None => {
2032 return Err(PoolError::UnsupportedBackend(format!(
2033 "this build of autumn-web targets SQLite (compiled with `--features sqlite`) but \
2034 the configured database URL names no recognized database backend; configure a \
2035 SQLite target spelled `sqlite:<path>`, `sqlite://<path>`, or `file:<path>` — a \
2036 bare filesystem path is not accepted (target: {target:?})",
2037 target = crate::db_url::redact_target(url)
2038 )));
2039 }
2040 }
2041
2042 let timeout = Duration::from_secs(connect_timeout_secs);
2043 let target = normalize_sqlite_target(url);
2044 // Issue #2885: shared-cache mode's table-lock protocol turns concurrent
2045 // deferred read→write transactions into a *permanent* deadlock (the lock
2046 // upgrade fails with SQLITE_LOCKED, bypassing the busy-timeout handler),
2047 // and WAL mode does not fix it. This is a warning, not a refusal — the
2048 // test suite itself uses shared-cache in-memory targets on purpose — but
2049 // it must be loud: operators hitting this in production otherwise debug a
2050 // hang, not a configuration choice.
2051 if sqlite_target_is_shared_cache(&target) {
2052 tracing::warn!(
2053 target = %crate::db_url::redact_target(url),
2054 "SQLite shared-cache mode (`cache=shared`) is enabled on this pool. \
2055 Concurrent deferred read→write transactions can deadlock permanently \
2056 under shared cache: the lock upgrade fails with SQLITE_LOCKED / \
2057 SQLITE_BUSY_SNAPSHOT, which bypasses the busy-timeout handler, and \
2058 WAL mode does not fix this deadlock class (issue #2885). An up-front \
2059 BEGIN IMMEDIATE (Db::tx_immediate) stops writers from all reading \
2060 before they upgrade, but it guarantees no winner: other writers, and \
2061 a writer blocked by a concurrent reader's table lock, still get \
2062 SQLITE_LOCKED without consulting the busy timeout, so they fail fast \
2063 rather than queue. Prefer a WAL-mode file database; if shared \
2064 cache is required, serialize writers or retry each one through \
2065 Db::tx_immediate with backoff. SQLite itself discourages \
2066 shared-cache mode: https://www.sqlite.org/sharedcache.html"
2067 );
2068 }
2069 let max_size = if sqlite_target_is_memory(&target) {
2070 1
2071 } else {
2072 pool_size.max(1)
2073 };
2074 // SQLite starts every connection with `foreign_keys` OFF, so a bare manager would
2075 // hand out pooled connections that silently ignore `REFERENCES` constraints, making
2076 // orphan rows and referential-integrity violations possible app-wide. It also uses a
2077 // default busy handler that returns `SQLITE_BUSY` immediately when another pooled
2078 // connection holds the single writer lock, so ordinary overlapping writes on a
2079 // multi-slot file pool fail as 5xx instead of waiting briefly. Install both pragmas —
2080 // plus a deliberate WAL journal mode with `synchronous = NORMAL` for better write
2081 // concurrency — during every pooled connection's setup, mirroring how the sync store
2082 // configures its own connection (`crate::sync::store`: `busy_timeout = 5000`,
2083 // `journal_mode = WAL`, `synchronous = NORMAL`, `foreign_keys = ON`). `busy_timeout`
2084 // is set first, so the statements it covers queue on the timeout instead of failing
2085 // on a held lock; `journal_mode = WAL` is a harmless no-op for a pure `:memory:`
2086 // database, and `apply_sqlite_pragmas` covers the one lock it does not.
2087 // A `custom_setup` callback runs once per newly-created connection, the same
2088 // per-connection hook the Postgres path uses to install TLS.
2089 //
2090 // A read-only URI target (`mode=ro`, `immutable`, e.g.
2091 // `sqlite://file:/srv/reference.db?mode=ro`) is the exception: `journal_mode = WAL`
2092 // writes to the database — it rewrites the file header and creates the `-wal`/`-shm`
2093 // sidecars — so it fails with "attempt to write a readonly database", `custom_setup`
2094 // propagates that as a connection-setup error, and the pool could not service even
2095 // read-only queries (`Db` routes 503). Such targets get only the non-writing
2096 // per-connection pragmas (`busy_timeout`, `foreign_keys`), so a read-only pool builds
2097 // and serves reads. In-memory targets are not read-only and keep the full batch.
2098 let mut config = diesel_async::pooled_connection::ManagerConfig::<RuntimeConnection>::default();
2099 config.custom_setup = Box::new(move |url: &str| {
2100 use diesel_async::{AsyncConnection as _, SimpleAsyncConnection as _};
2101 let url = url.to_owned();
2102 async move {
2103 let mut conn = RuntimeConnection::establish(&url).await?;
2104 let pragmas = sqlite_connection_pragmas(
2105 sqlite_target_is_read_only(&url),
2106 checkpoint_owner.replicating(),
2107 );
2108 apply_sqlite_pragmas(&mut conn, pragmas)
2109 .await
2110 .map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
2111 // #1910 FTS5 capability probe. Searchable repositories emit FTS5 virtual
2112 // tables and `bm25()` ranking, and the `AddSearch` migration's `CREATE
2113 // VIRTUAL TABLE ... USING fts5(...)` is the hard stop that fails loudly at
2114 // boot if the linked SQLite lacks FTS5. Probe it here — create and drop a
2115 // throwaway FTS5 table in the always-writable `temp` database, harmless on
2116 // read-only main targets — so the failure is a clear diagnostic naming FTS5
2117 // and the fix, rather than a bare "no such module: fts5" from a migration.
2118 // There is no silent fallback to LIKE: full-text search requires FTS5.
2119 if let Err(e) = conn
2120 .batch_execute(
2121 "CREATE VIRTUAL TABLE temp.__autumn_fts5_probe USING fts5(x); \
2122 DROP TABLE temp.__autumn_fts5_probe;",
2123 )
2124 .await
2125 {
2126 return Err(diesel::ConnectionError::CouldntSetupConfiguration(
2127 diesel::result::Error::QueryBuilderError(
2128 format!(
2129 "SQLite FTS5 is not available in the linked SQLite library, but \
2130 autumn-web full-text search (searchable repositories / the \
2131 `--search` scaffold, issue #1910) requires it. Build with the \
2132 bundled, FTS5-enabled SQLite by enabling autumn-web's `sqlite` \
2133 feature (it turns on `libsqlite3-sys/bundled`, whose amalgamation \
2134 defines SQLITE_ENABLE_FTS5). Underlying probe error: {e}"
2135 )
2136 .into(),
2137 ),
2138 ));
2139 }
2140 Ok(conn)
2141 }
2142 .boxed()
2143 });
2144 let manager =
2145 AsyncDieselConnectionManager::<RuntimeConnection>::new_with_config(target, config);
2146 Ok(Pool::builder(manager)
2147 .max_size(max_size)
2148 .wait_timeout(Some(timeout))
2149 .create_timeout(Some(timeout))
2150 .runtime(deadpool::Runtime::Tokio1)
2151 .build()?)
2152}
2153
2154/// Apply the per-connection pragmas, retrying while the database is locked.
2155///
2156/// `PRAGMA journal_mode = WAL` takes an exclusive lock, and `SQLite` does not run
2157/// the busy handler for it, so the `busy_timeout` set one statement earlier does
2158/// not cover it. Connections created at the same time against a brand-new file
2159/// therefore race, and every loser fails setup with "database is locked" — a 5xx
2160/// on any request that needed one (#1907).
2161///
2162/// The journal mode is a persistent property of the file, so one winner settles
2163/// it and each retry is then a no-op. The whole retry budget is well inside the
2164/// pool's `create_timeout`.
2165#[cfg(feature = "sqlite")]
2166async fn apply_sqlite_pragmas(
2167 conn: &mut RuntimeConnection,
2168 pragmas: &str,
2169) -> Result<(), diesel::result::Error> {
2170 use diesel_async::SimpleAsyncConnection as _;
2171
2172 /// Attempts in total, the first one included.
2173 const ATTEMPTS: u32 = 10;
2174 /// Wait between attempts. The lock is held for one statement, not a query.
2175 const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(25);
2176
2177 let mut attempt = 1;
2178 loop {
2179 match conn.batch_execute(pragmas).await {
2180 Ok(()) => return Ok(()),
2181 Err(error) if attempt < ATTEMPTS && sqlite_error_is_locked(&error) => {
2182 attempt += 1;
2183 tokio::time::sleep(RETRY_DELAY).await;
2184 }
2185 Err(error) => return Err(error),
2186 }
2187 }
2188}
2189
2190/// Whether a `SQLite` error is the busy/locked one worth retrying.
2191///
2192/// `SQLITE_BUSY` and `SQLITE_LOCKED` both reach diesel as a message; there is no
2193/// typed code to match on.
2194#[cfg(feature = "sqlite")]
2195fn sqlite_error_is_locked(error: &diesel::result::Error) -> bool {
2196 let message = error.to_string();
2197 message.contains("database is locked") || message.contains("database table is locked")
2198}
2199
2200/// Create a connection pool from the database configuration.
2201///
2202/// Returns `Ok(None)` if no primary database URL is configured
2203/// (`database.primary_url` and the legacy `database.url` are absent or `null`
2204/// in `autumn.toml`).
2205///
2206/// # Errors
2207///
2208/// Returns [`PoolError`] if the pool cannot be built (e.g., invalid
2209/// max-size configuration).
2210pub fn create_pool(config: &DatabaseConfig) -> Result<Option<Pool<RuntimeConnection>>, PoolError> {
2211 let Some(url) = config.effective_primary_url() else {
2212 return Ok(None);
2213 };
2214
2215 #[cfg(feature = "sqlite")]
2216 reject_sqlite_unsupported_timeouts(config, url)?;
2217
2218 let pool = build_pool(
2219 url,
2220 config.effective_primary_pool_size(),
2221 config.connect_timeout_secs,
2222 )?;
2223
2224 Ok(Some(pool))
2225}
2226
2227/// Reject a configured `database.statement_timeout` under the `SQLite` backend.
2228///
2229/// `SQLite` cannot enforce a per-statement wall-clock timeout through the async
2230/// connection wrapper: diesel's `SqliteConnection` exposes no
2231/// interrupt/progress-handler hook (nor the raw `sqlite3` handle) through
2232/// `SyncConnectionWrapper`, so a runaway query cannot be aborted mid-flight.
2233/// Rather than silently ignore a correctness guarantee we cannot honor, this
2234/// fails the boot fast with an actionable message (fail-closed, issue #1996).
2235///
2236/// A `None` or zero timeout (the default) asks for no guarantee and boots
2237/// cleanly, so ordinary `SQLite` apps are unaffected — only an operator who
2238/// explicitly configured a timeout we cannot meet is stopped. This is the single
2239/// choke point for every pool-construction entry point: the built-in
2240/// `create_pool`, `create_topology`, and `create_shard_topology` factories all
2241/// call it before the pool is built, and `setup_database` calls it once more at
2242/// the pool-provider dispatch boundary so a custom
2243/// [`DatabasePoolProvider`](crate::db::DatabasePoolProvider) — whose
2244/// `create_topology`/`create_shard_topology` need not route through those
2245/// factories — cannot bypass the guard. No configured timeout can reach a live
2246/// `SQLite` pool. `busy_timeout` bounds lock waits only for lock modes that
2247/// consult the busy handler; it does **not** bound `cache=shared` table-lock
2248/// waits, which return `SQLITE_LOCKED` without invoking the busy handler at
2249/// all (issue #2881). A real per-statement timeout is tracked by
2250/// #1996/#1910.
2251///
2252/// `target` is the pool's database target (the raw configured URL is fine —
2253/// [`sqlite_target_is_shared_cache`] only inspects the query string) and is
2254/// used solely to scope the lock-wait claim in the error message so it does
2255/// not lie for `cache=shared` targets.
2256///
2257/// # Errors
2258///
2259/// Returns [`PoolError::UnsupportedBackend`] when `statement_timeout` is `Some`
2260/// and non-zero.
2261#[cfg(feature = "sqlite")]
2262pub(crate) fn reject_sqlite_statement_timeout(
2263 statement_timeout: Option<Duration>,
2264 target: &str,
2265) -> Result<(), PoolError> {
2266 let Some(timeout) = statement_timeout.filter(|t| !t.is_zero()) else {
2267 return Ok(());
2268 };
2269 // `busy_timeout` bounds lock waits only for lock modes that consult the
2270 // busy handler. A `cache=shared` target's shared-cache table-lock
2271 // conflicts return `SQLITE_LOCKED` without invoking the busy handler at
2272 // all (this pool does not wire `sqlite3_unlock_notify`), so stating the
2273 // unqualified claim for those targets would be false (issue #2881).
2274 let wait_clause = if sqlite_target_is_shared_cache(target) {
2275 "and note that `cache=shared` table-lock conflicts return SQLITE_LOCKED \
2276 immediately, without consulting the busy handler (this pool does not \
2277 wire sqlite3_unlock_notify), so `busy_timeout` does NOT bound those \
2278 waits (issue #2881)"
2279 } else {
2280 "(`busy_timeout` already bounds lock waits)"
2281 };
2282 Err(PoolError::UnsupportedBackend(format!(
2283 "SQLite backend cannot enforce database.statement_timeout ({}ms): diesel's \
2284 SqliteConnection exposes no interrupt/progress-handler hook through the async \
2285 connection wrapper, so a runaway query cannot be aborted. Unset \
2286 database.statement_timeout for the SQLite backend {wait_clause}, or run on Postgres. \
2287 Tracking issue: #1996/#1910.",
2288 timeout.as_millis()
2289 )))
2290}
2291
2292/// Reject every timeout `SQLite` cannot enforce: `database.statement_timeout`
2293/// and `database.idle_in_transaction_timeout` (#3057). Each pool factory and
2294/// provider path calls this one guard, so none can miss a setting.
2295///
2296/// # Errors
2297///
2298/// Returns [`PoolError::UnsupportedBackend`] for the first nonzero setting.
2299#[cfg(feature = "sqlite")]
2300pub(crate) fn reject_sqlite_unsupported_timeouts(
2301 config: &DatabaseConfig,
2302 target: &str,
2303) -> Result<(), PoolError> {
2304 reject_sqlite_statement_timeout(config.statement_timeout, target)?;
2305 reject_sqlite_idle_in_transaction_timeout(config.idle_in_transaction_timeout)
2306}
2307
2308/// Reject a configured `database.idle_in_transaction_timeout` under the
2309/// `SQLite` backend (#3057). `SQLite` has no such setting, so a nonzero value
2310/// would look enforced and not be. `None` and zero are allowed.
2311///
2312/// # Errors
2313///
2314/// Returns [`PoolError::UnsupportedBackend`] for a nonzero value.
2315#[cfg(feature = "sqlite")]
2316pub(crate) fn reject_sqlite_idle_in_transaction_timeout(
2317 timeout: Option<Duration>,
2318) -> Result<(), PoolError> {
2319 let Some(timeout) = timeout.filter(|t| !t.is_zero()) else {
2320 return Ok(());
2321 };
2322 Err(PoolError::UnsupportedBackend(format!(
2323 "SQLite backend cannot enforce database.idle_in_transaction_timeout ({}ms). \
2324 Unset it for the SQLite backend, or run on Postgres.",
2325 timeout.as_millis()
2326 )))
2327}
2328
2329/// Reject a `SQLite` `replica_url` that cannot act as a real read replica for the
2330/// given `primary_url`.
2331///
2332/// Under the `sqlite` feature a configured replica cannot replicate: `SQLite` has
2333/// no primary/replica replication in this pool architecture. Two single-slot
2334/// `:memory:` pools are two *private* empty databases, and a distinct replica
2335/// *file* has nothing replicating into it, so read-routed queries would hit an
2336/// empty replica after writes and schema setup went to the primary. Reject an
2337/// in-memory or distinct-file replica with an actionable boot error (addresses
2338/// Codex P2). A replica that normalizes to the SAME file as the primary is the
2339/// same database — harmless — so it is allowed. `normalize_sqlite_target` is
2340/// reused so "same file" matches how the pool actually opens the file, and
2341/// `sqlite_target_is_memory` so pool sizing and this guard agree on what
2342/// "in-memory" means. Shared by the control-database and per-shard topologies so
2343/// the two rejection rules cannot drift.
2344#[cfg(feature = "sqlite")]
2345fn reject_unusable_sqlite_replica(primary_url: &str, replica_url: &str) -> Result<(), PoolError> {
2346 let replica_target = normalize_sqlite_target(replica_url);
2347 let primary_target = normalize_sqlite_target(primary_url);
2348 if sqlite_target_is_memory(&replica_target) || replica_target != primary_target {
2349 return Err(PoolError::UnsupportedBackend(format!(
2350 "SQLite does not support a separate read replica: replica_url {replica:?} \
2351 is in-memory or differs from primary_url {primary:?}. Configure only a \
2352 primary, or point the replica at the same database file as the primary.",
2353 replica = crate::db_url::redact_target(replica_url),
2354 primary = crate::db_url::redact_target(primary_url)
2355 )));
2356 }
2357 Ok(())
2358}
2359
2360/// Create primary and optional replica pools from the database configuration.
2361///
2362/// Returns `Ok(None)` when neither `database.primary_url` nor the legacy
2363/// `database.url` compatibility field is configured.
2364///
2365/// # Errors
2366///
2367/// Returns [`PoolError`] if either configured role cannot be built.
2368pub fn create_topology(config: &DatabaseConfig) -> Result<Option<DatabaseTopology>, PoolError> {
2369 let Some(primary_url) = config.effective_primary_url() else {
2370 return Ok(None);
2371 };
2372
2373 #[cfg(feature = "sqlite")]
2374 reject_sqlite_unsupported_timeouts(config, primary_url)?;
2375
2376 let primary = build_pool(
2377 primary_url,
2378 config.effective_primary_pool_size(),
2379 config.connect_timeout_secs,
2380 )?;
2381
2382 #[cfg(feature = "sqlite")]
2383 if let Some(replica_url) = config.replica_url.as_deref() {
2384 reject_unusable_sqlite_replica(primary_url, replica_url)?;
2385 }
2386
2387 let replica = config
2388 .replica_url
2389 .as_deref()
2390 .map(|url| {
2391 build_pool(
2392 url,
2393 config.effective_replica_pool_size(),
2394 config.connect_timeout_secs,
2395 )
2396 })
2397 .transpose()?;
2398
2399 Ok(Some(DatabaseTopology::from_pools(primary, replica)))
2400}
2401
2402/// Create one shard's primary and optional replica pools, applying the
2403/// shard's pool-size and timeout fallbacks to the `[database]` defaults.
2404///
2405/// # Errors
2406///
2407/// Returns [`PoolError`] if either configured role cannot be built.
2408pub fn create_shard_topology(
2409 shard: &crate::config::ShardConfig,
2410 defaults: &DatabaseConfig,
2411) -> Result<DatabaseTopology, PoolError> {
2412 // A SQLite `[[database.shards]]` deployment reaches this via the ungated
2413 // `sharding::create_shard_set` → `create_shard_topology` path (the
2414 // transactional test-harness variant `create_shard_set_transactional` is
2415 // Postgres-only), so the same fail-closed guard applies per shard. The shard
2416 // inherits the `[database]` `statement_timeout`.
2417 #[cfg(feature = "sqlite")]
2418 reject_sqlite_unsupported_timeouts(defaults, &shard.primary_url)?;
2419
2420 let primary = build_pool(
2421 &shard.primary_url,
2422 shard.effective_primary_pool_size(defaults),
2423 defaults.connect_timeout_secs,
2424 )?;
2425
2426 // A per-shard `replica_url` is subject to the same SQLite-replica rule as the
2427 // control database: without it a SQLite `[[database.shards]]` entry with a
2428 // distinct or in-memory replica would boot and route reads to an unrelated,
2429 // empty database. Reuse the exact same helper so the two rejections cannot
2430 // drift (addresses Codex P2).
2431 #[cfg(feature = "sqlite")]
2432 if let Some(replica_url) = shard.replica_url.as_deref() {
2433 reject_unusable_sqlite_replica(&shard.primary_url, replica_url)?;
2434 }
2435
2436 let replica = shard
2437 .replica_url
2438 .as_deref()
2439 .map(|url| {
2440 build_pool(
2441 url,
2442 shard.effective_replica_pool_size(defaults),
2443 defaults.connect_timeout_secs,
2444 )
2445 })
2446 .transpose()?;
2447
2448 Ok(DatabaseTopology::from_pools(primary, replica))
2449}
2450
2451// ── Transaction options, retry, and isolation (issue #1202) ──────────────────
2452
2453/// Postgres transaction isolation level, requested per call via [`TxOptions`].
2454///
2455/// `ReadCommitted` is Postgres' default and Autumn's default; the stronger
2456/// levels are opt-in on [`Db::tx_with`]. See the transactions guide for what
2457/// each level buys and costs.
2458#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2459pub enum IsolationLevel {
2460 /// Postgres default. Each statement sees rows committed before it began.
2461 #[default]
2462 ReadCommitted,
2463 /// A snapshot fixed at the first query; no non-repeatable or phantom reads.
2464 RepeatableRead,
2465 /// Full serializability (SSI). Transactions behave as if run one at a time;
2466 /// conflicts surface as `40001` and are retried by [`Db::tx_with`].
2467 Serializable,
2468}
2469
2470impl IsolationLevel {
2471 /// The SQL fragment used in tracing (`db.isolation`).
2472 const fn as_str(self) -> &'static str {
2473 match self {
2474 Self::ReadCommitted => "read_committed",
2475 Self::RepeatableRead => "repeatable_read",
2476 Self::Serializable => "serializable",
2477 }
2478 }
2479}
2480
2481/// Default number of attempts (including the first) for the retrying isolation
2482/// levels. Chosen to match the jobs system's default retry budget.
2483const DEFAULT_TX_MAX_ATTEMPTS: u32 = 5;
2484
2485/// Options for [`Db::tx_with`]: isolation level, access mode, and the automatic
2486/// retry policy for transient serialization failures.
2487///
2488/// [`TxOptions::default`] is byte-for-byte equivalent to today's [`Db::tx`]:
2489/// READ COMMITTED, read-write, a single attempt, and no retry.
2490///
2491/// ```rust
2492/// use autumn_web::db::{TxOptions, IsolationLevel};
2493///
2494/// let opts = TxOptions::serializable().read_only().max_attempts(8);
2495/// assert_eq!(opts.isolation, IsolationLevel::Serializable);
2496/// assert!(opts.read_only);
2497/// assert_eq!(opts.max_attempts, 8);
2498/// ```
2499#[derive(Debug, Clone, Copy)]
2500pub struct TxOptions {
2501 /// Requested isolation level.
2502 pub isolation: IsolationLevel,
2503 /// Run the transaction `READ ONLY`.
2504 pub read_only: bool,
2505 /// Add `DEFERRABLE` (only meaningful for `SERIALIZABLE READ ONLY`).
2506 pub deferrable: bool,
2507 /// Total attempts including the first. `1` disables retry.
2508 pub max_attempts: u32,
2509 /// Backoff before the first retry; doubles each subsequent retry.
2510 pub initial_backoff: Duration,
2511 /// Upper bound on any single backoff delay.
2512 pub max_backoff: Duration,
2513}
2514
2515impl Default for TxOptions {
2516 fn default() -> Self {
2517 Self {
2518 isolation: IsolationLevel::ReadCommitted,
2519 read_only: false,
2520 deferrable: false,
2521 max_attempts: 1,
2522 initial_backoff: Duration::from_millis(5),
2523 max_backoff: Duration::from_millis(500),
2524 }
2525 }
2526}
2527
2528impl TxOptions {
2529 /// READ COMMITTED, no retry — identical to [`Db::tx`].
2530 #[must_use]
2531 pub fn new() -> Self {
2532 Self::default()
2533 }
2534
2535 /// READ COMMITTED (the default), no retry.
2536 #[must_use]
2537 pub fn read_committed() -> Self {
2538 Self {
2539 isolation: IsolationLevel::ReadCommitted,
2540 ..Self::default()
2541 }
2542 }
2543
2544 /// REPEATABLE READ with automatic retry (`serialization_failure` can occur
2545 /// at this level too).
2546 #[must_use]
2547 pub fn repeatable_read() -> Self {
2548 Self {
2549 isolation: IsolationLevel::RepeatableRead,
2550 max_attempts: DEFAULT_TX_MAX_ATTEMPTS,
2551 ..Self::default()
2552 }
2553 }
2554
2555 /// SERIALIZABLE with automatic retry. This is the correctness-critical
2556 /// default: conflicts surface as `40001` and are retried transparently.
2557 #[must_use]
2558 pub fn serializable() -> Self {
2559 Self {
2560 isolation: IsolationLevel::Serializable,
2561 max_attempts: DEFAULT_TX_MAX_ATTEMPTS,
2562 ..Self::default()
2563 }
2564 }
2565
2566 /// Set the isolation level, keeping the other options.
2567 #[must_use]
2568 pub const fn isolation(mut self, level: IsolationLevel) -> Self {
2569 self.isolation = level;
2570 self
2571 }
2572
2573 /// Run the transaction `READ ONLY`.
2574 #[must_use]
2575 pub const fn read_only(mut self) -> Self {
2576 self.read_only = true;
2577 self
2578 }
2579
2580 /// Add `DEFERRABLE` (for `SERIALIZABLE READ ONLY`).
2581 #[must_use]
2582 pub const fn deferrable(mut self) -> Self {
2583 self.deferrable = true;
2584 self
2585 }
2586
2587 /// Set the maximum number of attempts (clamped to at least 1). A value of
2588 /// `1` disables retry.
2589 #[must_use]
2590 pub const fn max_attempts(mut self, attempts: u32) -> Self {
2591 self.max_attempts = if attempts < 1 { 1 } else { attempts };
2592 self
2593 }
2594
2595 /// `max_attempts`, clamped to at least 1.
2596 ///
2597 /// The `max_attempts` field is public (struct-literal update syntax is a
2598 /// supported way to build a `TxOptions`), so a value of `0` can reach the
2599 /// struct without going through [`TxOptions::max_attempts`]'s clamp. The
2600 /// retry loop calls this instead of reading the field directly, so `0`
2601 /// always behaves like `1` (run the closure once, no retry) rather than
2602 /// skipping the closure entirely.
2603 // Only the Postgres retry loop consults this; unused in a `--features
2604 // sqlite` library build (still unit-tested, so keep it compiled).
2605 #[cfg_attr(feature = "sqlite", allow(dead_code))]
2606 const fn effective_max_attempts(&self) -> u32 {
2607 if self.max_attempts < 1 {
2608 1
2609 } else {
2610 self.max_attempts
2611 }
2612 }
2613
2614 /// Set the backoff before the first retry.
2615 #[must_use]
2616 pub const fn initial_backoff(mut self, delay: Duration) -> Self {
2617 self.initial_backoff = delay;
2618 self
2619 }
2620
2621 /// Set the ceiling on any single backoff delay.
2622 #[must_use]
2623 pub const fn max_backoff(mut self, delay: Duration) -> Self {
2624 self.max_backoff = delay;
2625 self
2626 }
2627}
2628
2629/// Whether the retry loop should re-run the closure or return the error.
2630// The whole serialization-failure retry machinery is Postgres-only (see
2631// `Db::tx_with`); these items are unused in a `--features sqlite` library build
2632// but remain unit-tested, so keep them compiled and silence dead-code under the
2633// feature.
2634#[cfg_attr(feature = "sqlite", allow(dead_code))]
2635#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2636enum RetryDecision {
2637 Retry,
2638 Stop,
2639}
2640
2641/// Decide whether a just-failed attempt should be retried.
2642///
2643/// `attempt` is the 1-indexed number of the attempt that just failed. A retry
2644/// happens only when the error is retryable **and** attempts remain.
2645#[cfg_attr(feature = "sqlite", allow(dead_code))]
2646const fn retry_decision(attempt: u32, max_attempts: u32, retryable: bool) -> RetryDecision {
2647 if retryable && attempt < max_attempts {
2648 RetryDecision::Retry
2649 } else {
2650 RetryDecision::Stop
2651 }
2652}
2653
2654/// Deterministic capped exponential backoff base: `initial * 2^(attempt-1)`,
2655/// saturating on overflow and capped at `max`.
2656///
2657/// Same exponential shape and cap as [`crate::backoff::ceiling_ms`]. Kept
2658/// jitter-free so it is unit-testable. [`retry_backoff_delay`] adds the jitter.
2659#[cfg_attr(feature = "sqlite", allow(dead_code))]
2660fn retry_backoff_base(initial: Duration, max: Duration, attempt: u32) -> Duration {
2661 // Cap the shift so `2^shift` never overflows and huge attempts saturate.
2662 let shift = attempt.saturating_sub(1).min(31);
2663 let multiplier = 1u32 << shift;
2664 initial.checked_mul(multiplier).unwrap_or(max).min(max)
2665}
2666
2667/// [`retry_backoff_base`] with +/-20% jitter applied, never exceeding `max`.
2668///
2669/// Jitter decorrelates a thundering herd of transactions all retrying after the
2670/// same conflict. Delegates to [`crate::cache::jittered_ttl`] (rather than
2671/// re-implementing the RNG/fallback logic) so there's one jitter
2672/// implementation in the crate, including its `getrandom`-failure fallback to
2673/// `SystemTime` entropy instead of silently degrading to no jitter.
2674#[cfg_attr(feature = "sqlite", allow(dead_code))]
2675fn retry_backoff_delay(initial: Duration, max: Duration, attempt: u32) -> Duration {
2676 let base = retry_backoff_base(initial, max, attempt);
2677 crate::cache::jittered_ttl(base, 0.2).min(max)
2678}
2679
2680/// Whether `err` wraps a Postgres serialization failure (`40001`) or deadlock
2681/// (`40P01`) — the two transient errors that are safe to retry by re-running
2682/// the whole transaction.
2683///
2684/// Classification is structural and authoritative, never based on scanning an
2685/// arbitrary error's displayed message: a `diesel::result::Error` anywhere in
2686/// `err`'s source chain (not just the top-level wrapped error — a custom `E`
2687/// that wraps a diesel error via `#[source]`/`#[from]` is still found) is
2688/// classified by its `DatabaseErrorKind`. `diesel-async`'s Postgres backend
2689/// maps `40001` to `SerializationFailure` directly from the real SQLSTATE (see
2690/// `diesel_async::pg::error_helper::from_tokio_postgres_error`) — fully
2691/// reliable, no message inspection involved. Any *other* kind (e.g.
2692/// `UniqueViolation`) is trusted as non-retryable outright — never
2693/// string-matched, so a constraint name or key value that happens to contain
2694/// `"40001"` cannot misclassify it.
2695///
2696/// `40P01` (deadlock) has no dedicated `DatabaseErrorKind` and always surfaces
2697/// as `Unknown`. Worse: the wrapper type diesel-async uses to carry Postgres's
2698/// error fields for an `Unknown`-kind error (`PostgresDbErrorWrapper`, private
2699/// to diesel-async) implements only `DatabaseErrorInformation`, not
2700/// `std::error::Error` — so `source_chain_has_sqlstate`'s downcast walk can
2701/// **never** reach the real SQLSTATE for it; there is no structural path to a
2702/// deadlock's code at all through this crate boundary. The only signal
2703/// available is the message, so this checks it — but with an **exact**
2704/// match, not a substring: Postgres's deadlock detector always raises the
2705/// primary message as precisely `"deadlock detected"` with nothing else (the
2706/// context lives in DETAIL/HINT, not here), so an app's own `RAISE EXCEPTION`
2707/// would have to reproduce that exact string as its *entire* message to
2708/// collide — unlike a substring check, which a longer business message
2709/// merely mentioning the phrase would already trip.
2710///
2711/// When no `diesel::result::Error` is found anywhere in the chain, this checks
2712/// for a raw `tokio_postgres` SQLSTATE directly (a custom `E` that bypasses
2713/// diesel). If neither is found, the error is **not** retried — there is
2714/// deliberately no generic message-substring fallback beyond the one exact
2715/// match above: retrying based on bare text risks misclassifying an unrelated
2716/// domain/validation error as a transient conflict, silently re-running a
2717/// non-idempotent closure and delaying the response.
2718#[cfg_attr(feature = "sqlite", allow(dead_code))]
2719#[must_use]
2720pub fn is_retryable_txn_error(err: &AutumnError) -> bool {
2721 if let Some(diesel_err) = err.downcast_chain_ref::<diesel::result::Error>() {
2722 return match diesel_err {
2723 // Fast path: diesel-async maps 40001 to SerializationFailure from
2724 // the real SQLSTATE. Fully structural, no message involved.
2725 diesel::result::Error::DatabaseError(
2726 diesel::result::DatabaseErrorKind::SerializationFailure,
2727 _,
2728 ) => true,
2729 // 40P01 (deadlock) has no dedicated kind and surfaces as Unknown.
2730 // The chain walk is kept for forward-compatibility (a future
2731 // diesel/diesel-async release, or another backend, could start
2732 // exposing the real SQLSTATE through the source chain) but is
2733 // currently unreachable for diesel-async's own Postgres backend
2734 // — see the doc comment above. The message check that follows is
2735 // therefore the only thing that actually fires deadlock retries
2736 // today, hence the exact (not substring) match.
2737 diesel::result::Error::DatabaseError(
2738 diesel::result::DatabaseErrorKind::Unknown,
2739 info,
2740 ) => {
2741 chain_has_retryable_sqlstate(diesel_err)
2742 || info
2743 .message()
2744 .trim()
2745 .eq_ignore_ascii_case("deadlock detected")
2746 }
2747 // Any other kind (UniqueViolation, NotNullViolation, ...) is
2748 // authoritatively non-retryable — no string matching against its
2749 // message/constraint text.
2750 _ => chain_has_retryable_sqlstate(diesel_err),
2751 };
2752 }
2753
2754 raw_error_has_retryable_sqlstate(err)
2755}
2756
2757/// Whether a Postgres `40001` or `40P01` SQLSTATE is in `err`'s source chain.
2758#[cfg(feature = "postgres")]
2759fn chain_has_retryable_sqlstate(err: &(dyn std::error::Error + 'static)) -> bool {
2760 source_chain_has_sqlstate(err, is_retryable_sqlstate)
2761}
2762
2763/// Without the Postgres client there is no SQLSTATE to read.
2764#[cfg(not(feature = "postgres"))]
2765fn chain_has_retryable_sqlstate(_err: &(dyn std::error::Error + 'static)) -> bool {
2766 false
2767}
2768
2769#[cfg(feature = "postgres")]
2770fn is_retryable_sqlstate(state: &tokio_postgres::error::SqlState) -> bool {
2771 use tokio_postgres::error::SqlState;
2772 *state == SqlState::T_R_SERIALIZATION_FAILURE || *state == SqlState::T_R_DEADLOCK_DETECTED
2773}
2774
2775/// A raw `tokio_postgres` error with a retryable SQLSTATE (no diesel error in
2776/// the chain).
2777#[cfg(feature = "postgres")]
2778fn raw_error_has_retryable_sqlstate(err: &AutumnError) -> bool {
2779 // No text-based fallback; see `is_retryable_txn_error` for why.
2780 err.downcast_chain_ref::<tokio_postgres::Error>()
2781 .and_then(tokio_postgres::Error::code)
2782 .is_some_and(is_retryable_sqlstate)
2783 || err
2784 .downcast_chain_ref::<tokio_postgres::error::DbError>()
2785 .is_some_and(|db| is_retryable_sqlstate(db.code()))
2786}
2787
2788#[cfg(not(feature = "postgres"))]
2789const fn raw_error_has_retryable_sqlstate(_err: &AutumnError) -> bool {
2790 false
2791}
2792
2793/// Run `f` inside a transaction on `conn`, adapting the `ScopedBoxFuture`
2794/// callback shape used throughout Autumn's generated code to the
2795/// `AsyncFnOnce` callback [`diesel_async::AsyncConnection::transaction`]
2796/// expects since diesel-async 0.9.
2797///
2798/// This is a runtime support function for code generated by Autumn proc
2799/// macros. It is semver-exempt; do not call it directly.
2800///
2801/// # Errors
2802///
2803/// Returns the error from `f`, or a `diesel::result::Error` from starting,
2804/// committing, or rolling back the transaction.
2805#[doc(hidden)]
2806pub async fn scoped_transaction<'a, T, E, C, F>(conn: &'a mut C, f: F) -> Result<T, E>
2807where
2808 C: diesel_async::AsyncConnection + Send,
2809 T: Send + 'a,
2810 E: From<diesel::result::Error> + Send + 'a,
2811 F: for<'r> FnOnce(&'r mut C) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2812 + Send
2813 + 'a,
2814{
2815 // Mirrors the default body of `TransactionManager::transaction` in
2816 // diesel-async 0.9, but drives the boxed callback future directly instead
2817 // of going through the `AsyncFnOnce` bounds (which reject the boxed
2818 // `ScopedBoxFuture` callback shape).
2819 use diesel_async::TransactionManager as _;
2820
2821 C::TransactionManager::begin_transaction(conn).await?;
2822 if let Err(set_error) = apply_tx_timeouts(conn).await {
2823 return match C::TransactionManager::rollback_transaction(conn).await {
2824 Ok(()) | Err(diesel::result::Error::BrokenTransactionManager) => Err(set_error.into()),
2825 Err(rollback_error) => Err(rollback_error.into()),
2826 };
2827 }
2828 match f(&mut *conn).await {
2829 Ok(value) => {
2830 C::TransactionManager::commit_transaction(conn).await?;
2831 Ok(value)
2832 }
2833 Err(user_error) => match C::TransactionManager::rollback_transaction(conn).await {
2834 // A broken transaction manager means the rollback error is a
2835 // consequence of the original error; surface the original.
2836 Ok(()) | Err(diesel::result::Error::BrokenTransactionManager) => Err(user_error),
2837 Err(rollback_error) => Err(rollback_error.into()),
2838 },
2839 }
2840}
2841
2842/// Run a write read-modify-write closure inside a transaction that takes the
2843/// `SQLite` write lock up front (`BEGIN IMMEDIATE`).
2844///
2845/// On Postgres this delegates to [`scoped_transaction`] unchanged. On `SQLite` it
2846/// begins the transaction with `BEGIN IMMEDIATE` before running `f`, so a
2847/// concurrent writer queues on the connection's `busy_timeout` instead of
2848/// failing its deferred read→write snapshot upgrade with `SQLITE_BUSY_SNAPSHOT`
2849/// (which bypasses the busy handler). It is the transaction primitive for
2850/// generated write-RMW paths (`with_lock`, `update`, `delete_by_id`,
2851/// `find_or_create_by`); read-only transactions, [`Db::tx`], and [`savepoint`]
2852/// deliberately stay on the deferred [`scoped_transaction`] so read-only user
2853/// transactions keep their read concurrency. User code that wants this mode
2854/// explicitly should call [`Db::tx_immediate`], not this function.
2855///
2856/// This is a runtime support function for code generated by Autumn proc macros.
2857/// It is semver-exempt; do not call it directly.
2858///
2859/// # `SQLite` nesting parity
2860///
2861/// The `BEGIN IMMEDIATE` is issued **through** diesel's `AnsiTransactionManager`
2862/// (`begin_transaction_sql`) rather than as a raw statement, so the manager's
2863/// depth counter is synchronized (0 → 1). A nested [`savepoint`] or a
2864/// `TransactionManager`-driven `.transaction()` inside `f` therefore emits a
2865/// `SAVEPOINT` (matching Postgres) instead of a raw `BEGIN` that would fail with
2866/// "cannot start a transaction within a transaction". Commit and rollback also
2867/// route through the manager, so its depth/status bookkeeping stays correct. If
2868/// a `COMMIT` fails, the manager leaves the connection marked in-transaction,
2869/// so deadpool sees a broken transaction manager and discards the connection
2870/// rather than recycling it with an open write transaction.
2871///
2872/// # Errors
2873///
2874/// Returns the error from `f`, or a `diesel::result::Error` from starting or
2875/// committing the transaction. On `SQLite` a panic inside `f` rolls the
2876/// transaction back (through the transaction manager) before resuming the
2877/// unwind, so the pooled connection is never recycled with an open write
2878/// transaction.
2879/// Run `f` on `conn`, wrapped in a [`scoped_immediate_transaction`] only when
2880/// `wrap` is true.
2881///
2882/// This exists for the counter-cache write paths (#1325). Several no-hooks
2883/// mutation paths are deliberately transaction-free: they issue exactly one
2884/// statement, so a `BEGIN`/`COMMIT` round trip would be pure overhead. A counter
2885/// cache makes that one statement two, which must commit or roll back together
2886/// — so those paths need a transaction if, and only if, the model actually
2887/// declares a counter cache.
2888///
2889/// `#[model]` and `#[repository]` are separate proc-macro invocations, so the
2890/// repository macro cannot see the model's `#[belongs_to(..., counter_cache)]`
2891/// attributes; it learns about them from the `const HAS_COUNTER_CACHES` the
2892/// model emits. That const is what callers pass as `wrap`.
2893///
2894/// Previously the macro branched on that const and emitted the **whole mutation
2895/// body twice** — once inside a transaction closure, once bare — at seven sites
2896/// per repository. That is free at runtime (the const folds) but not at compile
2897/// time: both copies are parsed, name-resolved, type-checked and borrow-checked
2898/// on every build, and it accounted for ~13.5% of every generated repository.
2899/// Routing both cases through one closure here keeps the runtime behaviour
2900/// exactly as it was — no transaction is opened when `wrap` is false — while the
2901/// body is emitted once.
2902///
2903/// This is a runtime support function for code generated by Autumn proc macros.
2904/// It is semver-exempt; do not call it directly.
2905///
2906/// # Errors
2907///
2908/// Returns the error from `f`, or a `diesel::result::Error` from starting or
2909/// committing the transaction when one is opened.
2910#[doc(hidden)]
2911pub async fn maybe_immediate_transaction<'a, T, E, F>(
2912 conn: &'a mut RuntimeConnection,
2913 wrap: bool,
2914 f: F,
2915) -> Result<T, E>
2916where
2917 T: Send + 'a,
2918 E: From<diesel::result::Error> + Send + 'a,
2919 F: for<'r> FnOnce(
2920 &'r mut RuntimeConnection,
2921 ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2922 + Send
2923 + 'a,
2924{
2925 if wrap {
2926 scoped_immediate_transaction(conn, f).await
2927 } else {
2928 f(conn).await
2929 }
2930}
2931
2932#[doc(hidden)]
2933pub async fn scoped_immediate_transaction<'a, T, E, F>(
2934 conn: &'a mut RuntimeConnection,
2935 f: F,
2936) -> Result<T, E>
2937where
2938 T: Send + 'a,
2939 E: From<diesel::result::Error> + Send + 'a,
2940 F: for<'r> FnOnce(
2941 &'r mut RuntimeConnection,
2942 ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
2943 + Send
2944 + 'a,
2945{
2946 crate::backend_select! {
2947 pg => { scoped_transaction(conn, f).await },
2948 sqlite => {{
2949 // Begin the immediate transaction THROUGH the transaction manager so
2950 // its depth counter is kept in sync (depth 0 → 1). Reaching the inner
2951 // `SqliteConnection` for `begin_transaction_sql` requires the concrete
2952 // `SyncConnectionWrapper<SqliteConnection>` (`spawn_blocking`), which
2953 // is why this helper takes `&mut RuntimeConnection` rather than a
2954 // generic connection.
2955 use diesel::connection::{AnsiTransactionManager, TransactionManager};
2956
2957 // Take the write lock up front. A concurrent writer queues here on
2958 // `busy_timeout` instead of failing a deferred snapshot upgrade. With
2959 // the depth counter at 1, a nested transaction/savepoint inside `f`
2960 // becomes a SAVEPOINT (Postgres parity) instead of a raw nested BEGIN.
2961 conn.spawn_blocking(|inner| {
2962 AnsiTransactionManager::begin_transaction_sql(inner, "BEGIN IMMEDIATE")
2963 })
2964 .await
2965 .map_err(E::from)?;
2966
2967 // `catch_unwind` is mandatory: a panic that unwound without a rollback
2968 // would leave deadpool free to recycle this connection with an open,
2969 // uncommitted write transaction.
2970 let outcome = AssertUnwindSafe(f(&mut *conn)).catch_unwind().await;
2971 match outcome {
2972 Ok(Ok(value)) => {
2973 // Commit through the manager. At depth 1 it runs `COMMIT`. If
2974 // that fails (e.g. a deferred-FK violation) the manager leaves
2975 // the connection marked in-transaction, so its
2976 // `is_broken_transaction_manager` reports broken and deadpool
2977 // discards the connection on return rather than recycling it
2978 // with an open write transaction — the pool never hands back a
2979 // dirty connection.
2980 conn.spawn_blocking(|inner| {
2981 <AnsiTransactionManager as TransactionManager<
2982 diesel::SqliteConnection,
2983 >>::commit_transaction(inner)
2984 })
2985 .await
2986 .map_err(E::from)?;
2987 Ok(value)
2988 }
2989 Ok(Err(user_error)) => {
2990 if let Err(e) = conn
2991 .spawn_blocking(|inner| {
2992 <AnsiTransactionManager as TransactionManager<
2993 diesel::SqliteConnection,
2994 >>::rollback_transaction(inner)
2995 })
2996 .await
2997 {
2998 tracing::warn!(
2999 "failed to roll back immediate transaction after error: {e}"
3000 );
3001 }
3002 Err(user_error)
3003 }
3004 Err(panic) => {
3005 if let Err(e) = conn
3006 .spawn_blocking(|inner| {
3007 <AnsiTransactionManager as TransactionManager<
3008 diesel::SqliteConnection,
3009 >>::rollback_transaction(inner)
3010 })
3011 .await
3012 {
3013 tracing::error!(
3014 "failed to roll back immediate transaction during panic: {e}"
3015 );
3016 }
3017 std::panic::resume_unwind(panic);
3018 }
3019 }
3020 }},
3021 }
3022}
3023
3024/// Run `f` inside a Postgres `SAVEPOINT` on a connection already inside a
3025/// transaction.
3026///
3027/// Pass the `conn` handed to a [`Db::tx`] / [`Db::tx_with`] closure. The
3028/// savepoint is released when `f` returns `Ok`, or rolled back (`ROLLBACK TO
3029/// SAVEPOINT`) when it returns `Err` — leaving the surrounding transaction
3030/// intact.
3031///
3032/// This is the supported way to get a nested, partially-rollbackable unit of
3033/// work: `Db::tx` itself cannot be re-entered on the same connection (its
3034/// closure receives `&mut PooledConnection`, not `&mut Db`), so a same-connection
3035/// savepoint can only live here, inside the closure.
3036///
3037/// # Caveat
3038///
3039/// After-commit callbacks registered inside the savepoint via
3040/// [`register_after_commit`] fire when the **outer** transaction commits,
3041/// regardless of whether this savepoint rolled back — the callback registry is
3042/// transaction-scoped, not savepoint-scoped.
3043///
3044/// # Errors
3045///
3046/// Returns the error from `f`, or a `diesel::result::Error` if the savepoint
3047/// itself cannot be established or released.
3048pub async fn savepoint<'a, C, T, E, F>(conn: &'a mut C, f: F) -> Result<T, E>
3049where
3050 // Generic over the connection so it works with the `&mut PooledConnection`
3051 // handed to a `tx` closure and the `&mut AsyncPgConnection` handed to a
3052 // `tx_with` closure alike.
3053 C: diesel_async::AsyncConnection + Send,
3054 T: Send + 'a,
3055 E: From<diesel::result::Error> + Send + 'a,
3056 F: for<'r> FnOnce(&'r mut C) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
3057 + Send
3058 + 'a,
3059{
3060 // `scoped_transaction` drives `C::TransactionManager` exactly like
3061 // `conn.transaction` does. diesel-async issues SAVEPOINT (not BEGIN)
3062 // because `conn` is already in a transaction, and RELEASE / ROLLBACK TO on
3063 // Ok / Err respectively.
3064 scoped_transaction(conn, f).await
3065}
3066
3067// ── Db extractor ─────────────────────────────────────────────
3068
3069/// Connection type managed by the deadpool pool.
3070pub type PooledConnection = diesel_async::pooled_connection::deadpool::Object<RuntimeConnection>;
3071
3072/// Replica lag query (issue #3065).
3073///
3074/// - Not in recovery (a primary, or a plain database): lag is `0`.
3075/// - The WAL receiver runs, it got a message from the primary within the lag
3076/// limit (`$1`, in ms), and all received WAL is replayed: lag is `0`. An
3077/// idle primary writes no new transactions, so the replay timestamp alone
3078/// would grow without limit. A disconnected or stalled receiver keeps its
3079/// last receive LSN, so the LSN check alone would read as fresh. A message
3080/// older than the limit proves nothing within the limit, so then the branch
3081/// below applies. A role
3082/// without `pg_read_all_stats` sees `last_msg_receipt_time` as `NULL`. That
3083/// is not proof of freshness, so the branch below applies.
3084/// - Else: time since the last replayed transaction. `NULL` when nothing has
3085/// been replayed yet, which means "unknown". The `NULL` check comes before
3086/// `GREATEST`, because `GREATEST(0, NULL)` is `0` in Postgres.
3087#[cfg(not(feature = "sqlite"))]
3088const REPLICA_LAG_SQL: &str = "SELECT CASE \
3089 WHEN NOT pg_is_in_recovery() THEN 0::BIGINT \
3090 WHEN EXISTS (SELECT 1 FROM pg_stat_wal_receiver \
3091 WHERE last_msg_receipt_time > clock_timestamp() - make_interval(secs => $1::DOUBLE PRECISION / 1000)) \
3092 AND pg_last_wal_receive_lsn() IS NOT NULL \
3093 AND pg_last_wal_replay_lsn() >= pg_last_wal_receive_lsn() THEN 0::BIGINT \
3094 WHEN pg_last_xact_replay_timestamp() IS NULL THEN NULL \
3095 ELSE GREATEST(0, FLOOR(EXTRACT(EPOCH FROM \
3096 (clock_timestamp() - pg_last_xact_replay_timestamp())) * 1000))::BIGINT \
3097 END AS lag_ms";
3098
3099/// Measure the replica lag on `conn`, for the lag limit `max_lag`. See
3100/// [`REPLICA_LAG_SQL`].
3101#[cfg(not(feature = "sqlite"))]
3102pub(crate) async fn measure_replica_lag(
3103 conn: &mut PooledConnection,
3104 max_lag: std::time::Duration,
3105) -> Result<std::time::Duration, String> {
3106 use diesel_async::RunQueryDsl as _;
3107
3108 #[derive(diesel::QueryableByName)]
3109 struct LagRow {
3110 #[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::BigInt>)]
3111 lag_ms: Option<i64>,
3112 }
3113
3114 let window_ms = i64::try_from(max_lag.as_millis()).unwrap_or(i64::MAX);
3115 let row: LagRow = diesel::sql_query(REPLICA_LAG_SQL)
3116 .bind::<diesel::sql_types::BigInt, _>(window_ms)
3117 .get_result(&mut **conn)
3118 .await
3119 .map_err(|error| error.to_string())?;
3120 let lag_ms = row
3121 .lag_ms
3122 .ok_or_else(|| "replica has not replayed a transaction yet".to_owned())?;
3123 Ok(std::time::Duration::from_millis(
3124 u64::try_from(lag_ms).unwrap_or(0),
3125 ))
3126}
3127
3128#[cfg(all(test, not(feature = "sqlite")))]
3129mod replica_lag_sql_tests {
3130 #[test]
3131 fn the_receiver_window_is_the_lag_limit() {
3132 let sql = super::REPLICA_LAG_SQL;
3133 assert!(
3134 !sql.contains("60 seconds"),
3135 "a fixed window lets a stalled receiver read as fresh past the limit"
3136 );
3137 assert!(
3138 sql.contains("last_msg_receipt_time > clock_timestamp() - make_interval(secs => $1"),
3139 "the receiver must have heard from the primary within the lag limit: {sql}"
3140 );
3141 }
3142
3143 #[test]
3144 fn unknown_replay_time_stays_null() {
3145 let sql = super::REPLICA_LAG_SQL;
3146 let null_check = sql
3147 .find("pg_last_xact_replay_timestamp() IS NULL THEN NULL")
3148 .expect("a replica with no replayed transaction must read as unknown");
3149 let greatest = sql.find("GREATEST(").expect("negative lag is clamped");
3150 assert!(
3151 null_check < greatest,
3152 "GREATEST(0, NULL) is 0, so the NULL check must come first"
3153 );
3154 }
3155}
3156
3157/// `SQLite` has no replicas, so the lag is always `0`.
3158#[cfg(feature = "sqlite")]
3159#[allow(
3160 clippy::unused_async,
3161 reason = "same signature as the Postgres variant"
3162)]
3163pub(crate) async fn measure_replica_lag(
3164 _conn: &mut PooledConnection,
3165 _max_lag: std::time::Duration,
3166) -> Result<std::time::Duration, String> {
3167 Ok(std::time::Duration::ZERO)
3168}
3169
3170struct TxDepthGuard<'a> {
3171 depth: &'a mut usize,
3172 poisoned: &'a mut bool,
3173 disarmed: bool,
3174}
3175
3176impl Drop for TxDepthGuard<'_> {
3177 fn drop(&mut self) {
3178 *self.depth -= 1;
3179 if !self.disarmed {
3180 *self.poisoned = true;
3181 }
3182 }
3183}
3184
3185/// Async database connection extractor.
3186///
3187/// Declare `db: Db` in a handler signature to get a pooled database
3188/// connection. The connection is returned to the pool when `Db` is dropped
3189/// at the end of the request.
3190///
3191/// `Db` implements [`Deref`](std::ops::Deref) and
3192/// [`DerefMut`](std::ops::DerefMut) to [`RuntimeConnection`], so you can use it
3193/// directly with Diesel query methods. That alias is
3194/// `diesel_async::AsyncPgConnection` by default and a `SQLite` connection under
3195/// the crate's `sqlite` feature — one extractor, two backends.
3196///
3197/// If no database is configured (i.e., `database.primary_url` and legacy
3198/// `database.url` are absent),
3199/// requests that use `Db` will receive a `503 Service Unavailable`
3200/// response.
3201///
3202/// A handler that also takes a body extractor (`Form`, `Json`, `Multipart`,
3203/// ...) should take [`LazyDb`] instead. `Db` checks out a connection before
3204/// axum reads the body. It holds that connection for as long as the client
3205/// takes to send the body.
3206///
3207/// # Examples
3208///
3209/// ```rust,no_run
3210/// use autumn_web::prelude::*;
3211///
3212/// #[get("/ping-db")]
3213/// async fn ping_db(db: Db) -> AutumnResult<&'static str> {
3214/// // `db` dereferences to the active RuntimeConnection
3215/// Ok("database is reachable")
3216/// }
3217/// ```
3218/// Extension/extractor struct for route-level statement timeout override.
3219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3220pub struct StatementTimeout(pub std::time::Duration);
3221
3222pub struct Db {
3223 conn: PooledConnection,
3224 /// Span covering the full checkout-to-release window. Dropped when
3225 /// `Db` is dropped at the end of the request, so span duration
3226 /// reflects real connection hold time rather than just `pool.get()`
3227 /// latency. Exposed via [`Db::span`] so handlers can attach
3228 /// per-query spans as children with
3229 /// [`tracing::Instrument::instrument`].
3230 span: tracing::Span,
3231 tx_depth: usize,
3232 tx_poisoned: bool,
3233 route_key: Option<RouteKey>,
3234 metrics: Option<crate::middleware::MetricsCollector>,
3235 slow_query_threshold: std::time::Duration,
3236 /// Checkout instant on the injected clock's monotonic timeline; `Drop`
3237 /// subtracts it from a fresh reading to get the connection-hold duration.
3238 start_time: crate::time::MonotonicInstant,
3239 /// The app's injected clock, kept so `Drop` can take the closing reading
3240 /// from the same timeline `start_time` came from.
3241 clock: std::sync::Arc<dyn crate::time::ClockSource>,
3242 is_test_tx: bool,
3243 /// Set with `SET LOCAL` at the start of each transaction (#3057).
3244 tx_timeouts: TxTimeouts,
3245 /// The tenant's `tenancy.max_db_connections` permit (#3072). Last, so it
3246 /// drops after the connection.
3247 _tenant_permit: Option<crate::bulkhead::TenantPermit>,
3248}
3249
3250impl Db {
3251 /// Connection-scoped span. Instrument a query future with this to
3252 /// emit a child span tagged under the connection checkout window.
3253 ///
3254 /// ```rust,no_run
3255 /// use autumn_web::prelude::*;
3256 /// use tracing::Instrument as _;
3257 ///
3258 /// # async fn example(mut db: Db) -> AutumnResult<()> {
3259 /// let span = db.span().clone();
3260 /// // run a Diesel query here, e.g. users::table.load(&mut *db)
3261 /// async {
3262 /// // ... diesel_async query ...
3263 /// Ok::<_, AutumnError>(())
3264 /// }
3265 /// .instrument(span)
3266 /// .await
3267 /// # }
3268 /// ```
3269 #[must_use]
3270 pub const fn span(&self) -> &tracing::Span {
3271 &self.span
3272 }
3273
3274 /// Run an async closure inside a database transaction at the default
3275 /// isolation level (READ COMMITTED).
3276 ///
3277 /// Commits when the closure returns `Ok(_)`, rolls back when it returns
3278 /// `Err(_)`. For a stronger isolation level and/or automatic
3279 /// serialization-failure retry, use [`Db::tx_with`]. For a write-heavy
3280 /// closure on `SQLite` — where a deferred read→write lock upgrade can fail
3281 /// with `SQLITE_BUSY_SNAPSHOT` instead of queueing — use
3282 /// [`Db::tx_immediate`], which takes the write lock up front
3283 /// (`BEGIN IMMEDIATE`). This method itself deliberately stays deferred so
3284 /// read-only transactions keep their read concurrency.
3285 ///
3286 /// # Errors
3287 ///
3288 /// Returns [`AutumnError`] when:
3289 ///
3290 /// - the underlying transaction returns an error,
3291 /// - the closure returns an error that converts into `AutumnError`,
3292 /// - this `Db` is already inside a transaction,
3293 /// - this `Db` has been poisoned by a previously cancelled/dropped
3294 /// transaction future.
3295 ///
3296 /// # Panics
3297 ///
3298 /// Panics if the internal after-commit registry mutex is poisoned (only
3299 /// possible if a previous thread holding the lock panicked).
3300 pub async fn tx<'a, T, E, F>(&'a mut self, f: F) -> Result<T, crate::error::AutumnError>
3301 where
3302 T: Send + 'a,
3303 E: From<diesel::result::Error> + Send + Sync + 'a,
3304 crate::error::AutumnError: From<E>,
3305 F: for<'r> FnOnce(
3306 &'r mut PooledConnection,
3307 ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
3308 + Send
3309 + 'a,
3310 {
3311 if self.tx_poisoned {
3312 return Err(crate::error::AutumnError::service_unavailable_msg(
3313 "Database connection is in an invalid transaction state",
3314 ));
3315 }
3316 if self.tx_depth > 0 {
3317 return Err(crate::error::AutumnError::bad_request_msg(
3318 NESTED_TX_MESSAGE,
3319 ));
3320 }
3321 reject_ambient_after_commit_registry_for_tx()?;
3322 self.tx_depth += 1;
3323 let mut guard = TxDepthGuard {
3324 depth: &mut self.tx_depth,
3325 poisoned: &mut self.tx_poisoned,
3326 disarmed: false,
3327 };
3328
3329 // Each tx gets its own callback registry shared with the task-local so
3330 // that code running inside the closure (jobs, mailer, hooks) can push
3331 // callbacks without having access to `Db` directly. The `Arc` lets us
3332 // read the registry after the `scope` future completes.
3333 let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
3334
3335 // NOTE: `tx` keeps its own body rather than delegating to `tx_with`
3336 // because the two hand out different connection types. `transaction()`
3337 // runs on the pooled `Object` (closure sees `&mut PooledConnection`),
3338 // whereas `tx_with` must use `build_transaction()` — only available on
3339 // `AsyncPgConnection` — so its closure sees `&mut AsyncPgConnection`.
3340 // `scoped_transaction` adapts the public `ScopedBoxFuture` callback
3341 // shape to the transaction API diesel-async 0.9 expects.
3342 let result = AFTER_COMMIT_REGISTRY
3343 .scope(
3344 registry.clone(),
3345 self.tx_timeouts
3346 .scope(scoped_transaction::<T, E, _, _>(&mut self.conn, f)),
3347 )
3348 .await
3349 .map_err(Into::into);
3350
3351 guard.disarmed = true;
3352
3353 // On commit: spawn the registered callbacks outside the transaction
3354 // connection, but await them sequentially inside that task so callback
3355 // dependencies observe registration order.
3356 // Errors are counted and logged; they do NOT affect the committed tx.
3357 // In transactional tests (outer transaction is rolled back), we suppress
3358 // spawning these callbacks to prevent observing uncommitted side effects.
3359 if result.is_ok() {
3360 let callbacks: Vec<CommitCallback> = {
3361 let mut reg = registry.lock().expect("registry lock");
3362 std::mem::take(&mut *reg)
3363 };
3364
3365 if !callbacks.is_empty() && !self.is_test_tx {
3366 let _ = spawn_committed_after_commit_callbacks(callbacks);
3367 }
3368 }
3369
3370 result
3371 }
3372
3373 /// Run an async closure inside a database transaction that takes the
3374 /// `SQLite` write lock up front (`BEGIN IMMEDIATE`).
3375 ///
3376 /// This is the explicit, user-facing counterpart to [`Db::tx`] for
3377 /// write-heavy transactions. Commits when the closure returns `Ok(_)`,
3378 /// rolls back when it returns `Err(_)`. For a stronger isolation level
3379 /// and/or automatic serialization-failure retry, use [`Db::tx_with`].
3380 ///
3381 /// # When to use this instead of [`Db::tx`]
3382 ///
3383 /// On Postgres this behaves exactly like [`Db::tx`]. On `SQLite` it begins
3384 /// the transaction with `BEGIN IMMEDIATE`, taking the database write lock
3385 /// before the closure runs. A concurrent writer then queues on the
3386 /// connection's `busy_timeout` instead of failing its deferred read→write
3387 /// snapshot upgrade with `SQLITE_BUSY_SNAPSHOT` (which bypasses the busy
3388 /// handler — see issue #2885). Reach for this when the closure is
3389 /// write-heavy: read-modify-write cycles, queue claims, session writes,
3390 /// outbox/idempotency inserts.
3391 ///
3392 /// On a shared-cache target (`cache=shared`) it only stops the
3393 /// participating writers from all reading before their read→write upgrade
3394 /// attempts. It is not a completion guarantee — a concurrent reader's
3395 /// table lock can still fail the write with `SQLITE_LOCKED_SHAREDCACHE` —
3396 /// and it does **not** make other writers queue: their `BEGIN IMMEDIATE`
3397 /// fails at once, because `SQLite` never consults the busy handler for
3398 /// `SQLITE_LOCKED`. Serialize shared-cache
3399 /// writers in the application or retry each through `tx_immediate` with
3400 /// backoff — or move to a WAL-mode file database.
3401 ///
3402 /// The tradeoff is deliberate: an immediate transaction holds the write
3403 /// lock for its whole lifetime, so a long-running `tx_immediate` serializes
3404 /// other writers for longer than the equivalent deferred transaction
3405 /// would. Keep the closure short, and keep pure reads on [`Db::tx`], which
3406 /// stays deferred precisely so read-only transactions keep their read
3407 /// concurrency.
3408 ///
3409 /// The `BEGIN IMMEDIATE` is issued through diesel's transaction manager
3410 /// (see [`scoped_immediate_transaction`]), so nested [`savepoint`] calls
3411 /// inside the closure become `SAVEPOINT`s — matching Postgres — rather than
3412 /// failing with "cannot start a transaction within a transaction".
3413 ///
3414 /// The closure receives `&mut RuntimeConnection` (the bare runtime
3415 /// connection), like the generated immediate-transaction paths — not the
3416 /// `&mut PooledConnection` that [`Db::tx`] hands out. Repository methods
3417 /// take `&mut RuntimeConnection`, so call sites read the same either way.
3418 ///
3419 /// # Errors
3420 ///
3421 /// Returns [`AutumnError`] when:
3422 ///
3423 /// - the underlying transaction returns an error,
3424 /// - the closure returns an error that converts into `AutumnError`,
3425 /// - this `Db` is already inside a transaction,
3426 /// - this `Db` has been poisoned by a previously cancelled/dropped
3427 /// transaction future.
3428 ///
3429 /// # Panics
3430 ///
3431 /// Panics if the internal after-commit registry mutex is poisoned (only
3432 /// possible if a previous thread holding the lock panicked).
3433 pub async fn tx_immediate<'a, T, E, F>(
3434 &'a mut self,
3435 f: F,
3436 ) -> Result<T, crate::error::AutumnError>
3437 where
3438 T: Send + 'a,
3439 E: From<diesel::result::Error> + Send + Sync + 'a,
3440 crate::error::AutumnError: From<E>,
3441 F: for<'r> FnOnce(
3442 &'r mut RuntimeConnection,
3443 ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
3444 + Send
3445 + 'a,
3446 {
3447 if self.tx_poisoned {
3448 return Err(crate::error::AutumnError::service_unavailable_msg(
3449 "Database connection is in an invalid transaction state",
3450 ));
3451 }
3452 if self.tx_depth > 0 {
3453 return Err(crate::error::AutumnError::bad_request_msg(
3454 NESTED_TX_MESSAGE,
3455 ));
3456 }
3457 reject_ambient_after_commit_registry_for_tx()?;
3458 self.tx_depth += 1;
3459 let mut guard = TxDepthGuard {
3460 depth: &mut self.tx_depth,
3461 poisoned: &mut self.tx_poisoned,
3462 disarmed: false,
3463 };
3464
3465 // Each tx gets its own callback registry shared with the task-local so
3466 // that code running inside the closure (jobs, mailer, hooks) can push
3467 // callbacks without having access to `Db` directly. The `Arc` lets us
3468 // read the registry after the `scope` future completes.
3469 let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
3470
3471 // Mirrors `tx`, but the transaction begins IMMEDIATE (through the
3472 // transaction manager, so the depth counter syncs and nested savepoints
3473 // keep working) instead of deferred. `&mut self.conn` derefs from the
3474 // pooled connection to the bare `RuntimeConnection` the helper takes.
3475 let result = AFTER_COMMIT_REGISTRY
3476 .scope(
3477 registry.clone(),
3478 self.tx_timeouts
3479 .scope(scoped_immediate_transaction(&mut self.conn, f)),
3480 )
3481 .await
3482 .map_err(Into::into);
3483
3484 guard.disarmed = true;
3485
3486 // On commit: spawn the registered callbacks outside the transaction
3487 // connection, but await them sequentially inside that task so callback
3488 // dependencies observe registration order.
3489 // Errors are counted and logged; they do NOT affect the committed tx.
3490 // In transactional tests (outer transaction is rolled back), we suppress
3491 // spawning these callbacks to prevent observing uncommitted side effects.
3492 if result.is_ok() {
3493 let callbacks: Vec<CommitCallback> = {
3494 let mut reg = registry.lock().expect("registry lock");
3495 std::mem::take(&mut *reg)
3496 };
3497
3498 if !callbacks.is_empty() && !self.is_test_tx {
3499 let _ = spawn_committed_after_commit_callbacks(callbacks);
3500 }
3501 }
3502
3503 result
3504 }
3505
3506 /// Run an async closure inside a database transaction with explicit
3507 /// [`TxOptions`] — isolation level, read-only/deferrable mode, and automatic
3508 /// retry of transient serialization failures (`40001`) and deadlocks
3509 /// (`40P01`).
3510 ///
3511 /// Commits when the closure returns `Ok(_)`, rolls back when it returns
3512 /// `Err(_)`. On a retryable failure with attempts remaining, the whole
3513 /// closure is re-run after a capped exponential backoff with jitter.
3514 ///
3515 /// # The closure must be re-runnable
3516 ///
3517 /// **Because the closure can run more than once, it must be free of
3518 /// side effects that are not themselves transactional (or must be
3519 /// idempotent).** Database work is rolled back between attempts, and
3520 /// after-commit callbacks from failed attempts are discarded — but any
3521 /// non-database side effect in the closure body (logging aside — external
3522 /// API calls, channel sends, in-memory mutation) will re-execute on each
3523 /// retry. Keep such effects out of the closure, or gate them on the final
3524 /// success.
3525 ///
3526 /// # Observability
3527 ///
3528 /// The transaction runs under a `db.transaction` span carrying
3529 /// `db.isolation` and the final `db.tx.attempts` count. Each retry also
3530 /// increments [`TX_RETRIES_TOTAL`]; an exhausted retry budget increments
3531 /// [`TX_RETRY_EXHAUSTED_TOTAL`].
3532 ///
3533 /// # Errors
3534 ///
3535 /// Returns [`AutumnError`] under the same conditions as [`Db::tx`]. A
3536 /// non-retryable error is returned immediately; when the retry budget is
3537 /// exhausted, the **final** underlying error is returned (never swallowed).
3538 ///
3539 /// Under the `sqlite` feature there is no transaction builder that can
3540 /// enforce `READ ONLY`, so a request carrying [`TxOptions::read_only`]
3541 /// returns an unsupported-options error **before the closure runs** rather
3542 /// than silently executing a writable transaction (which would let writes
3543 /// commit under a read-only contract). A normal read-write transaction is
3544 /// unaffected. The Postgres path enforces read-only via the transaction
3545 /// builder and never rejects.
3546 ///
3547 /// # Panics
3548 ///
3549 /// Panics if the internal after-commit registry mutex is poisoned (only
3550 /// possible if a previous thread holding the lock panicked).
3551 #[allow(clippy::too_many_lines)]
3552 // The Postgres path re-borrows `f` per retry attempt (`&mut f`); the SQLite
3553 // path consumes it once, so `mut` is unused there.
3554 #[cfg_attr(feature = "sqlite", allow(unused_mut))]
3555 pub async fn tx_with<'a, T, E, F>(
3556 &'a mut self,
3557 opts: TxOptions,
3558 mut f: F,
3559 ) -> Result<T, crate::error::AutumnError>
3560 where
3561 T: Send + 'a,
3562 E: From<diesel::result::Error> + Send + Sync + 'a,
3563 crate::error::AutumnError: From<E>,
3564 // The closure receives `&mut RuntimeConnection` (not `&mut PooledConnection`
3565 // as `tx` does): on Postgres, isolation levels require `build_transaction()`,
3566 // which is inherent on `AsyncPgConnection` (the default `RuntimeConnection`).
3567 // Diesel query methods work on either backend. Under the `sqlite` feature
3568 // `RuntimeConnection` is a SQLite connection and the isolation/retry path
3569 // below degrades to a single plain transaction.
3570 F: for<'r> FnMut(
3571 &'r mut RuntimeConnection,
3572 ) -> scoped_futures::ScopedBoxFuture<'a, 'r, Result<T, E>>
3573 + Send
3574 + 'a,
3575 {
3576 if self.tx_poisoned {
3577 return Err(crate::error::AutumnError::service_unavailable_msg(
3578 "Database connection is in an invalid transaction state",
3579 ));
3580 }
3581 if self.tx_depth > 0 {
3582 return Err(crate::error::AutumnError::bad_request_msg(
3583 NESTED_TX_MESSAGE,
3584 ));
3585 }
3586 reject_ambient_after_commit_registry_for_tx()?;
3587
3588 // Under the SQLite runtime there is no transaction builder that can enforce
3589 // `READ ONLY` semantics — the `sqlite` arm below runs a single plain, writable
3590 // transaction. Silently honoring a caller's `TxOptions::read_only()` by running a
3591 // writable transaction anyway would let writes succeed and commit under a
3592 // contract that promised none, a safety regression for any code relying on a
3593 // read-only transaction to prevent mutation. So reject the request up front,
3594 // before the closure can run, rather than pretend to honor it. Real `query_only`
3595 // enforcement is avoided deliberately: deadpool's `custom_setup` runs on create
3596 // only, so a leaked `PRAGMA query_only = ON` would poison a pooled connection for
3597 // its lifetime. The Postgres path enforces read-only via the transaction
3598 // builder's `read_only()` and is unaffected.
3599 #[cfg(feature = "sqlite")]
3600 if opts.read_only {
3601 return Err(crate::error::AutumnError::bad_request_msg(
3602 "SQLite runtime does not support read-only transactions \
3603 (TxOptions::read_only); this build cannot enforce read-only \
3604 semantics on SQLite. Remove the read_only option or run on \
3605 Postgres.",
3606 ));
3607 }
3608
3609 self.tx_depth += 1;
3610 let mut guard = TxDepthGuard {
3611 depth: &mut self.tx_depth,
3612 poisoned: &mut self.tx_poisoned,
3613 disarmed: false,
3614 };
3615
3616 let span = tracing::info_span!(
3617 "db.transaction",
3618 db.system = "postgresql",
3619 db.isolation = opts.isolation.as_str(),
3620 db.tx.attempts = tracing::field::Empty,
3621 );
3622
3623 if self.is_test_tx {
3624 // Under a transactional `TestApp` the connection is already inside the test
3625 // harness's outer transaction (`begin_test_transaction`), so issuing a
3626 // literal `BEGIN`/`SET TRANSACTION ISOLATION LEVEL` via `build_transaction()`
3627 // here would be invalid: Postgres rejects `SET TRANSACTION ISOLATION LEVEL`
3628 // inside a subtransaction, and the retry loop's per-attempt `&mut f` re-borrow
3629 // does not type-check against the plain `transaction()` method's lifetime
3630 // shape, whose bound is not scoped to a single call. So nest via `SAVEPOINT`
3631 // instead, exactly like `Db::tx`, running the closure once: the requested
3632 // isolation, read-only, deferrable, and retry options are inherited from — or
3633 // meaningless nested inside — the outer test transaction, so there is nothing
3634 // to retry against a single test-harness connection.
3635 let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
3636 let timeouts = self.tx_timeouts;
3637 let conn: &mut RuntimeConnection = &mut self.conn;
3638 let result = AFTER_COMMIT_REGISTRY
3639 .scope(
3640 registry,
3641 timeouts.scope(scoped_transaction::<T, E, _, _>(conn, f)),
3642 )
3643 .instrument(span.clone())
3644 .await
3645 .map_err(Into::into);
3646
3647 span.record("db.tx.attempts", 1u32);
3648 guard.disarmed = true;
3649 // `is_test_tx` always suppresses after-commit spawning (matching
3650 // `Db::tx`), so the registry is simply dropped without draining.
3651 return result;
3652 }
3653
3654 // SQLite has no `SET TRANSACTION ISOLATION LEVEL` / read-only /
3655 // deferrable transaction builder and no serialization-failure retry
3656 // semantics, so run the closure once inside a single plain transaction.
3657 // The requested isolation level, read-only, deferrable, and retry
3658 // options are Postgres-only and are ignored here (documented).
3659 // After-commit callbacks still fire on commit, exactly as on the
3660 // Postgres path.
3661 #[cfg(feature = "sqlite")]
3662 {
3663 let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
3664 let timeouts = self.tx_timeouts;
3665 let conn: &mut RuntimeConnection = &mut self.conn;
3666 let result: Result<T, E> = AFTER_COMMIT_REGISTRY
3667 .scope(
3668 registry.clone(),
3669 timeouts.scope(scoped_transaction::<T, E, _, _>(conn, f)),
3670 )
3671 .instrument(span.clone())
3672 .await;
3673 span.record("db.tx.attempts", 1u32);
3674 guard.disarmed = true;
3675 // This block is the whole remaining function body under the feature
3676 // (the Postgres retry loop below is cfg'd out), so the `match` is the
3677 // tail expression — no `return` needed.
3678 match result {
3679 Ok(value) => {
3680 let callbacks: Vec<CommitCallback> = {
3681 let mut reg = registry.lock().expect("registry lock");
3682 std::mem::take(&mut *reg)
3683 };
3684 if !callbacks.is_empty() {
3685 let _ = spawn_committed_after_commit_callbacks(callbacks);
3686 }
3687 Ok(value)
3688 }
3689 Err(user_error) => Err(crate::error::AutumnError::from(user_error)),
3690 }
3691 }
3692
3693 #[cfg(not(feature = "sqlite"))]
3694 {
3695 let max_attempts = opts.effective_max_attempts();
3696 let mut attempt: u32 = 0;
3697
3698 let outcome: Result<T, crate::error::AutumnError> = loop {
3699 attempt += 1;
3700
3701 // A fresh registry per attempt: a rolled-back attempt's after-commit
3702 // callbacks are discarded simply by dropping this `Arc` undrained.
3703 let registry: Arc<Mutex<Vec<CommitCallback>>> = Arc::new(Mutex::new(Vec::new()));
3704
3705 let attempt_result: Result<T, E> = {
3706 // *** LOAD-BEARING ORDER: borrow `f` BEFORE `build_transaction()`.
3707 // diesel-async's `TransactionBuilder::run` bounds the closure by
3708 // the connection-borrow lifetime; `&mut f`'s region must start
3709 // earlier than that borrow or it fails to compile. Do not reorder.
3710 let f_ref = &mut f;
3711
3712 let mut builder = self.conn.build_transaction();
3713 builder = match opts.isolation {
3714 IsolationLevel::ReadCommitted => builder.read_committed(),
3715 IsolationLevel::RepeatableRead => builder.repeatable_read(),
3716 IsolationLevel::Serializable => builder.serializable(),
3717 };
3718 if opts.read_only {
3719 builder = builder.read_only();
3720 }
3721 if opts.deferrable {
3722 builder = builder.deferrable();
3723 }
3724
3725 // The builder sends its own `BEGIN ...`, so the timeouts go
3726 // first inside the closure (#3057).
3727 let set_local = self.tx_timeouts.set_local_sql();
3728 AFTER_COMMIT_REGISTRY
3729 .scope(
3730 registry.clone(),
3731 builder.run::<T, E, _>(async move |conn| {
3732 if let Some(sql) = set_local {
3733 use diesel_async::SimpleAsyncConnection as _;
3734 conn.batch_execute(&sql).await?;
3735 }
3736 f_ref(conn).await
3737 }),
3738 )
3739 .instrument(span.clone())
3740 .await
3741 };
3742
3743 match attempt_result {
3744 Ok(value) => {
3745 // Commit path: drain THIS attempt's callbacks and spawn
3746 // them. (The `is_test_tx` case already returned above, so
3747 // spawning here is always live.)
3748 let callbacks: Vec<CommitCallback> = {
3749 let mut reg = registry.lock().expect("registry lock");
3750 std::mem::take(&mut *reg)
3751 };
3752 if !callbacks.is_empty() {
3753 let _ = spawn_committed_after_commit_callbacks(callbacks);
3754 }
3755 break Ok(value);
3756 }
3757 Err(e) => {
3758 // Convert to AutumnError first, then classify on it.
3759 let ae = crate::error::AutumnError::from(e);
3760 let retryable = is_retryable_txn_error(&ae);
3761 match retry_decision(attempt, max_attempts, retryable) {
3762 RetryDecision::Retry => {
3763 let retries_total = record_tx_retry();
3764 let delay = retry_backoff_delay(
3765 opts.initial_backoff,
3766 opts.max_backoff,
3767 attempt,
3768 );
3769 tracing::debug!(
3770 parent: &span,
3771 attempt,
3772 max_attempts,
3773 delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
3774 autumn.tx.retries_total = retries_total,
3775 "retrying transaction after serialization/deadlock failure"
3776 );
3777 // `registry` drops here → rolled-back attempt's
3778 // after-commit callbacks are discarded; the loop
3779 // then re-runs the closure.
3780 tokio::time::sleep(delay).await;
3781 }
3782 RetryDecision::Stop => {
3783 if retryable {
3784 let exhausted_total = record_tx_retry_exhausted();
3785 tracing::warn!(
3786 parent: &span,
3787 attempt,
3788 max_attempts,
3789 autumn.tx.retry_exhausted_total = exhausted_total,
3790 "transaction retry budget exhausted; returning final error"
3791 );
3792 }
3793 break Err(ae);
3794 }
3795 }
3796 }
3797 }
3798 };
3799
3800 span.record("db.tx.attempts", attempt);
3801 guard.disarmed = true;
3802 outcome
3803 }
3804 }
3805}
3806
3807impl std::ops::Deref for Db {
3808 type Target = RuntimeConnection;
3809 fn deref(&self) -> &Self::Target {
3810 assert!(
3811 !self.tx_poisoned,
3812 "Db connection is poisoned due to a cancelled/dropped transaction"
3813 );
3814 &self.conn
3815 }
3816}
3817
3818impl std::ops::DerefMut for Db {
3819 fn deref_mut(&mut self) -> &mut Self::Target {
3820 assert!(
3821 !self.tx_poisoned,
3822 "Db connection is poisoned due to a cancelled/dropped transaction"
3823 );
3824 &mut self.conn
3825 }
3826}
3827
3828/// The per-route DB metrics key, `"METHOD /matched/path"`.
3829///
3830/// A matched route keeps the request's method and axum's shared
3831/// [`MatchedPath`](axum::extract::MatchedPath) (an `Arc<str>`) and is composed
3832/// only when written — at record time, into a stack buffer — instead of a fresh
3833/// `String` per `Db` extraction. Anything already composed (an unmatched
3834/// request's raw path, a shard-tagged label) is kept as-is.
3835#[derive(Clone)]
3836pub(crate) enum RouteKey {
3837 /// A route axum matched: the method plus its shared matched-path template.
3838 Matched {
3839 method: axum::http::Method,
3840 path: axum::extract::MatchedPath,
3841 },
3842 /// A key composed up front, written verbatim.
3843 Composed(String),
3844}
3845
3846impl std::fmt::Display for RouteKey {
3847 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3848 match self {
3849 Self::Matched { method, path } => write!(f, "{method} {}", path.as_str()),
3850 Self::Composed(key) => f.pad(key),
3851 }
3852 }
3853}
3854
3855/// Everything required to check out and instrument a pooled connection.
3856///
3857/// Shared by the plain [`Db`] extractor and shard-routed checkouts so that
3858/// every connection — regardless of which pool it came from — gets the same
3859/// span, interceptor, statement-timeout, and slow-query treatment.
3860pub(crate) struct DbCheckoutParams<'a> {
3861 /// Pool to check the connection out of.
3862 pub pool: &'a Pool<RuntimeConnection>,
3863 /// Role label surfaced to [`DbConnectionInterceptor`]s, e.g. `"primary"`
3864 /// or `"shard:<name>:primary"`.
3865 pub pool_name: &'a str,
3866 /// Shard name recorded on the `db.connection` span, when routed.
3867 pub shard: Option<&'a str>,
3868 /// Resolved statement timeout (route override already merged with the
3869 /// global config). `None` disables the timeout (`SET statement_timeout = 0`).
3870 pub statement_timeout: Option<std::time::Duration>,
3871 /// `idle_in_transaction_session_timeout` for this connection's
3872 /// transactions. `None` disables it.
3873 pub idle_in_transaction_timeout: Option<std::time::Duration>,
3874 /// `"METHOD /matched/path"` key used for per-route DB metrics.
3875 pub route_key: Option<RouteKey>,
3876 pub metrics: Option<crate::middleware::MetricsCollector>,
3877 pub slow_query_threshold: std::time::Duration,
3878 pub interceptors: Vec<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
3879 /// Why this app's pools cannot record capture traffic, from the state's
3880 /// topology (see [`DatabaseTopology::capture_gap`]); `None` when they can.
3881 #[cfg(all(feature = "reporting", not(feature = "sqlite")))]
3882 pub capture_gap: Option<std::sync::Arc<str>>,
3883 /// The app's injected clock, supplying the checkout-to-release timing and
3884 /// the per-statement query timer. Threaded through so connection latency is
3885 /// virtual (and reproducible) under a `#[sim_test]` instead of being read
3886 /// from a raw `std::time::Instant`.
3887 pub clock: std::sync::Arc<dyn crate::time::ClockSource>,
3888}
3889
3890/// The capsule attribution marker to merge into the checkout round trip, or
3891/// `None` when this checkout has nothing to attribute.
3892///
3893/// Scope presence is per-request, per-app truth: a scope only exists under a
3894/// capture-enabled router's `CaptureLayer`, so two apps with different capture
3895/// settings in one process cannot disturb each other. A scope-free checkout
3896/// sends nothing — stale bindings are cleared by the recording pool's own
3897/// create/recycle hooks, which run before any borrower's first statement
3898/// (#1598, F2). An id that would not survive interpolation yields `None`
3899/// rather than a quoted-string escape (F24).
3900///
3901/// `capture_gap` is the app's own pool truth: when the topology could not be
3902/// built with recording pools (TLS, a custom provider), the scope is noted
3903/// and truncated instead — there is no recorder on the wire for a marker to
3904/// reach.
3905#[cfg(not(feature = "sqlite"))]
3906fn capsule_checkout_marker(capture_gap: Option<&str>) -> Option<String> {
3907 #[cfg(feature = "reporting")]
3908 {
3909 let scope = crate::capsule::current_scope()?;
3910 if let Some(reason) = capture_gap {
3911 // Capture had to step aside for this app's database: say so in
3912 // the capsule rather than leaving a reader to wonder where the DB
3913 // tape went.
3914 crate::capsule::record_db::note_db_capture_unavailable(&scope, reason);
3915 return None;
3916 }
3917 Some(scope.id().to_owned())
3918 .filter(|id| crate::capsule::is_valid_scope_id(id))
3919 .and_then(|id| crate::capsule::wire::marker_set_sql(&id))
3920 }
3921 #[cfg(not(feature = "reporting"))]
3922 {
3923 let _ = capture_gap;
3924 None
3925 }
3926}
3927
3928/// Await a pool checkout and record its wait time (issue #3064). The wait runs
3929/// from the checkout request to a connection or an error. A checkout timeout
3930/// is the most important signal, so the code records a failed checkout too.
3931async fn timed_pool_wait<T>(
3932 clock: &dyn crate::time::ClockSource,
3933 metrics: Option<&crate::middleware::MetricsCollector>,
3934 checkout: impl std::future::Future<Output = T>,
3935) -> T {
3936 let start = clock.monotonic();
3937 let result = checkout.await;
3938 if let Some(metrics) = metrics {
3939 metrics.record_db_pool_wait(clock.monotonic().saturating_duration_since(start));
3940 }
3941 result
3942}
3943
3944impl Db {
3945 /// Check a connection out of `params.pool` with full instrumentation.
3946 ///
3947 /// This is the single code path behind the [`Db`] extractor and all
3948 /// shard-routed checkouts: span creation, checkout interceptors,
3949 /// `SET statement_timeout`, and the metrics captured for the
3950 /// slow-query warning on `Drop`.
3951 #[allow(
3952 clippy::too_many_lines,
3953 reason = "one checkout sequence; the deadline wait (#3058) is one more step"
3954 )]
3955 pub(crate) async fn checkout(params: DbCheckoutParams<'_>) -> Result<Self, AutumnError> {
3956 // `SET statement_timeout` (and its i32-cap arithmetic below) is a
3957 // Postgres session GUC. Under the `sqlite` feature the runtime backend
3958 // is entirely SQLite (the `RuntimeConnection` alias flips wholesale —
3959 // see `build_sqlite_pool`), which rejects the statement and would turn
3960 // every `Db`-using route into a 503. The `RunQueryDsl` import, the
3961 // timeout arithmetic, and the `SET` itself are therefore all gated off
3962 // on the SQLite build; the Postgres path is byte-identical.
3963 #[cfg(not(feature = "sqlite"))]
3964 use diesel_async::RunQueryDsl as _;
3965
3966 // Span covers the full time the connection is held — from
3967 // checkout through the end of the request — rather than just
3968 // `pool.get()`. Dropping `Db` closes the span, so span duration
3969 // reflects real connection hold time and `db.system=postgresql`
3970 // propagates to any query futures handlers instrument with
3971 // `db.span()`.
3972 let span = tracing::info_span!(
3973 "db.connection",
3974 otel.kind = "client",
3975 db.system = "postgresql",
3976 db.shard = tracing::field::Empty,
3977 );
3978 if let Some(shard) = params.shard {
3979 span.record("db.shard", shard);
3980 }
3981
3982 // Failure-capsule slice one records only the *control* topology's pools
3983 // (#1598): `[[database.shards]]` pools are built by `create_shard_set`,
3984 // never through the capture factory, so a request that reaches for a
3985 // shard generates database traffic no capsule can hold. Flag it here —
3986 // before the checkout, because whether the connection is obtained does
3987 // not change the fact that this request's tape is incomplete — so the
3988 // capsule says so and replay refuses it, rather than presenting a tape
3989 // that silently omits the shard's effects.
3990 #[cfg(all(feature = "reporting", not(feature = "sqlite")))]
3991 if params.shard.is_some() {
3992 crate::capsule::record_db::note_shard_capture_gap();
3993 }
3994
3995 // The same honesty rule for a backend that has no wire capture at all:
3996 // a `SQLite` build tees nothing (F18), so a capsule for a request that
3997 // used the database is missing that request's effects and must be
3998 // refused by replay rather than presented as complete.
3999 #[cfg(all(feature = "reporting", feature = "sqlite"))]
4000 crate::capsule::note_backend_capture_gap();
4001
4002 // The tenant's share of the pool (#3072). Take it before the pool
4003 // checkout, so a tenant at its cap does not hold a connection.
4004 let tenant_permit = crate::bulkhead::acquire_db_permit().inspect_err(|_| {
4005 if let Some(metrics) = ¶ms.metrics {
4006 metrics.record_tenant_db_rejection();
4007 }
4008 })?;
4009
4010 let pool = params.pool;
4011 let mut checkout_future: std::pin::Pin<
4012 Box<
4013 dyn std::future::Future<Output = Result<PooledConnection, AutumnError>> + Send + '_,
4014 >,
4015 > = Box::pin(async move {
4016 // Staging fault injection (#3071). Inert outside a fault scope.
4017 crate::fault_injection::inject(crate::fault_injection::FaultTarget::Database)
4018 .await
4019 .map_err(|fault| AutumnError::service_unavailable_msg(fault.to_string()))?;
4020 pool.get().await.map_err(|e| {
4021 tracing::error!("Failed to acquire database connection: {e}");
4022 AutumnError::service_unavailable_msg(e.to_string())
4023 })
4024 });
4025 for interceptor in ¶ms.interceptors {
4026 let ctx = crate::interceptor::DbCheckoutContext {
4027 pool_name: params.pool_name.to_string(),
4028 };
4029 checkout_future = interceptor.intercept_checkout(ctx, checkout_future);
4030 }
4031
4032 // The request deadline (issue #3058) limits the wait for a connection.
4033 // It does not change `statement_timeout`: that is a session setting,
4034 // so it would stay on the pooled connection for the next user.
4035 // `bounded` checks the deadline before each poll, so an idle
4036 // connection is not handed out once the deadline has passed.
4037 // `timed_pool_wait` records the whole wait, a deadline stop included.
4038 let mut conn = timed_pool_wait(
4039 params.clock.as_ref(),
4040 params.metrics.as_ref(),
4041 crate::deadline::bounded(checkout_future.instrument(span.clone())),
4042 )
4043 .await
4044 .map_err(|crate::deadline::DeadlineExceeded| {
4045 AutumnError::service_unavailable(crate::deadline::DeadlineStopped(
4046 "request deadline exceeded while waiting for a database connection",
4047 ))
4048 })??;
4049
4050 // Postgres statement_timeout is a signed 32-bit integer (milliseconds).
4051 // Cap at i32::MAX to avoid a confusing 503 for very large configured values.
4052 #[cfg(not(feature = "sqlite"))]
4053 let timeout_ms = pg_timeout_ms(params.statement_timeout);
4054
4055 // Install a fresh per-request query timer, but only when a query observer is
4056 // active: either a `REQUEST_DB_TIMINGS` scope (the `ServerTimingLayer`, enabled
4057 // by `[observability] server_timing`) or a `REQUEST_QUERY_CAPTURE` scope (the
4058 // test harness capturing the SQL list, which runs with `server_timing` off). The
4059 // timer feeds both lanes via `record_request_db_query`, and is installed before
4060 // the `SET statement_timeout` housekeeping statement below.
4061 //
4062 // `set_instrumentation` wholesale replaces the connection's instrumentation, so
4063 // it must not run unconditionally: an application that registered a global
4064 // default via `diesel::connection::set_default_instrumentation` — query logging,
4065 // tracing, metrics — would have it silently clobbered on the first checkout and
4066 // never restored, even with `server_timing` disabled. Gating on
4067 // `request_db_lane_active()` preserves the app's instrumentation whenever no
4068 // lane is scoped, the production default, and overwrites it only while a query
4069 // observer is active. Cost metering (`[cost] enabled`) is the third lane.
4070 //
4071 // Installing a fresh timer on every observed checkout also clears any stale
4072 // `RequestQueryTimer` a pooled connection carried from a prior request —
4073 // diesel-async's deadpool manager never resets instrumentation on recycle — so a
4074 // stale timer can never record the upcoming housekeeping `SET`, or later app
4075 // queries, into another request's accumulator. Installing before the `SET`, which
4076 // `is_uncounted_statement` classifies as housekeeping, keeps that statement out
4077 // of the `Server-Timing` `db` count regardless. A stale timer left on a
4078 // connection later reused by an opted-out request is a cheap no-op: `on_start`
4079 // probes both lanes before formatting or recording anything.
4080 #[cfg(feature = "db")]
4081 {
4082 use diesel_async::AsyncConnection as _;
4083 if request_db_lane_active() {
4084 conn.set_instrumentation(RequestQueryTimer::with_clock(std::sync::Arc::clone(
4085 ¶ms.clock,
4086 )));
4087 }
4088 }
4089
4090 // Postgres-only per-checkout initialization; see the gating note above.
4091 // SQLite builds skip it entirely (it would 503 every `Db`-using route).
4092 //
4093 // When failure-capsule capture is armed (#1598), the capsule
4094 // attribution marker rides along in this SAME round trip as one simple
4095 // batch, so recording costs no extra latency on the checkout path. With
4096 // capture off — the default — the statement issued here is byte-for-byte
4097 // what it has always been.
4098 #[cfg(not(feature = "sqlite"))]
4099 {
4100 #[cfg(feature = "reporting")]
4101 let capture_gap = params.capture_gap.as_deref();
4102 #[cfg(not(feature = "reporting"))]
4103 let capture_gap = None;
4104 let outcome = match capsule_checkout_marker(capture_gap) {
4105 Some(marker) => {
4106 use diesel_async::SimpleAsyncConnection as _;
4107 conn.batch_execute(&format!("SET statement_timeout = {timeout_ms}; {marker}"))
4108 .await
4109 }
4110 None => diesel::sql_query(format!("SET statement_timeout = {timeout_ms}"))
4111 .execute(&mut conn)
4112 .await
4113 .map(|_| ()),
4114 };
4115 outcome.map_err(|e| {
4116 tracing::error!("Failed to set database statement_timeout to {timeout_ms}ms: {e}");
4117 AutumnError::service_unavailable_msg(format!("Database initialization error: {e}"))
4118 })?;
4119 }
4120
4121 let start_time = params.clock.monotonic();
4122 let is_test_tx = params
4123 .interceptors
4124 .iter()
4125 .any(|i| i.is_transactional_test());
4126
4127 Ok(Self {
4128 conn,
4129 span,
4130 tx_depth: 0,
4131 tx_poisoned: false,
4132 route_key: params.route_key,
4133 metrics: params.metrics,
4134 slow_query_threshold: params.slow_query_threshold,
4135 start_time,
4136 clock: params.clock,
4137 is_test_tx,
4138 tx_timeouts: TxTimeouts::new(
4139 params.statement_timeout,
4140 params.idle_in_transaction_timeout,
4141 ),
4142 _tenant_permit: tenant_permit,
4143 })
4144 }
4145
4146 /// Check a plain, uninstrumented connection out of `pool` for use in tests.
4147 ///
4148 /// Unlike the request extractor, this applies no interceptors, statement
4149 /// timeout, or route metrics — it exists so integration tests can drive
4150 /// [`Db::tx`] / [`Db::tx_with`] directly against a [`crate::test::TestDb`]
4151 /// pool without spinning up a full `TestApp`.
4152 ///
4153 /// # Errors
4154 ///
4155 /// Returns [`AutumnError`] if a connection cannot be acquired from `pool`.
4156 #[cfg(feature = "test-support")]
4157 pub async fn connect_for_test(pool: &Pool<RuntimeConnection>) -> Result<Self, AutumnError> {
4158 Self::checkout(DbCheckoutParams {
4159 pool,
4160 pool_name: "test",
4161 shard: None,
4162 statement_timeout: None,
4163 idle_in_transaction_timeout: None,
4164 route_key: None,
4165 metrics: None,
4166 slow_query_threshold: std::time::Duration::from_millis(500),
4167 interceptors: Vec::new(),
4168 #[cfg(all(feature = "reporting", not(feature = "sqlite")))]
4169 capture_gap: None,
4170 // No `AppState` here by construction — this helper exists so a test
4171 // can drive `Db::tx` against a bare pool. The real clock matches the
4172 // behaviour this path had before the clock became injectable.
4173 clock: std::sync::Arc::clone(&DEFAULT_SYSTEM_CLOCK),
4174 })
4175 .await
4176 }
4177}
4178
4179/// Request-derived context shared by every `Db`-producing extractor.
4180///
4181/// Captures the route-override statement timeout, the matched-path metrics
4182/// key, and the state-held instrumentation handles so shard-routed
4183/// checkouts behave identically to the plain [`Db`] extractor.
4184#[derive(Clone)]
4185pub(crate) struct RequestDbContext {
4186 pub statement_timeout: Option<std::time::Duration>,
4187 pub idle_in_transaction_timeout: Option<std::time::Duration>,
4188 pub route_key: Option<RouteKey>,
4189 pub metrics: Option<crate::middleware::MetricsCollector>,
4190 pub slow_query_threshold: std::time::Duration,
4191 pub interceptors: Vec<std::sync::Arc<dyn crate::interceptor::DbConnectionInterceptor>>,
4192 /// The app's injected clock, carried so a shard-routed checkout times on
4193 /// the same seam the plain `Db` extractor does.
4194 pub clock: std::sync::Arc<dyn crate::time::ClockSource>,
4195}
4196
4197impl RequestDbContext {
4198 pub(crate) fn from_parts<S: DbState>(parts: &axum::http::request::Parts, state: &S) -> Self {
4199 note_route_statement_timeout(parts);
4200 let timeout_override = parts.extensions.get::<StatementTimeout>().copied();
4201 // A matched route keeps the method and axum's shared `MatchedPath`: no
4202 // `String` per extraction. Only a request no route matched (a fallback)
4203 // composes its raw path, exactly as before.
4204 let route_key = parts
4205 .extensions
4206 .get::<axum::extract::MatchedPath>()
4207 .map_or_else(
4208 || RouteKey::Composed(format!("{} {}", parts.method, parts.uri.path())),
4209 |path| RouteKey::Matched {
4210 method: parts.method.clone(),
4211 path: path.clone(),
4212 },
4213 );
4214 Self {
4215 statement_timeout: timeout_override
4216 .map(|t| t.0)
4217 .or_else(|| state.statement_timeout()),
4218 idle_in_transaction_timeout: state.idle_in_transaction_timeout(),
4219 route_key: Some(route_key),
4220 metrics: state.metrics().cloned(),
4221 slow_query_threshold: state.slow_query_threshold(),
4222 interceptors: state.db_interceptors(),
4223 clock: state.clock(),
4224 }
4225 }
4226}
4227
4228/// A database checkout that is *prepared* but not yet *taken*.
4229///
4230/// `Db` is a `FromRequestParts` extractor. Axum runs it before the
4231/// `FromRequest` body extractor (`Form`, `Json`, `Multipart`, ...). A handler
4232/// that takes `Db` before a body extractor holds a pooled connection for as
4233/// long as the client takes to send its body. The client controls that
4234/// delay. A few slow uploads can pin every connection in `pool_size` and
4235/// stop all other database work.
4236///
4237/// `LazyDb` fixes this. Use it instead of `Db` in a handler that also takes a
4238/// body extractor, in the same argument position:
4239///
4240/// ```rust,no_run
4241/// use autumn_web::prelude::*;
4242///
4243/// #[post("/comments")]
4244/// async fn post_comment(
4245/// lazy_db: LazyDb,
4246/// axum::extract::Form(form): axum::extract::Form<CommentForm>,
4247/// ) -> AutumnResult<&'static str> {
4248/// let mut db = lazy_db.checkout().await?;
4249/// save_comment(&mut db, &form.body).await?;
4250/// Ok("posted")
4251/// }
4252///
4253/// # #[derive(serde::Deserialize)]
4254/// # struct CommentForm { body: String }
4255/// # async fn save_comment(_db: &mut Db, _body: &str) -> AutumnResult<()> { Ok(()) }
4256/// ```
4257///
4258/// Axum still runs `LazyDb::from_request_parts` before the body is read. But
4259/// extraction only records what a checkout will need: the pool handle,
4260/// statement timeout, route key, metrics, interceptors, clock. No connection
4261/// is taken until the handler calls [`LazyDb::checkout`], after the body
4262/// extractor has already run.
4263///
4264/// That guarantee assumes the body extractor actually reads the body during
4265/// extraction — true for `Form` and `Json`, which buffer the whole body
4266/// before the handler runs. It is **not** true for [`Multipart`]: extracting
4267/// it does not read anything, and each field only streams in as the handler
4268/// calls [`Multipart::next_field`] and reads from the returned field. Calling
4269/// `checkout()` before that loop, the way the example above calls it right
4270/// after a `Form` extractor, checks out a connection and then holds it for
4271/// the loop's whole duration — exactly the pinning `LazyDb` exists to avoid.
4272/// Call `checkout()` only after every field this handler needs has been
4273/// fully read (buffered to memory or staged to disk), not before the loop
4274/// that reads them.
4275///
4276/// [`Multipart`]: crate::extract::Multipart
4277/// [`Multipart::next_field`]: crate::extract::Multipart::next_field
4278pub struct LazyDb {
4279 pool: Pool<RuntimeConnection>,
4280 ctx: RequestDbContext,
4281 #[cfg(all(feature = "reporting", not(feature = "sqlite")))]
4282 capture_gap: Option<std::sync::Arc<str>>,
4283}
4284
4285impl LazyDb {
4286 /// Take the connection. Call this once the request body has been read.
4287 ///
4288 /// # Errors
4289 ///
4290 /// Returns [`AutumnError`] when the pool cannot hand out a connection —
4291 /// the same failure [`Db`]'s own extractor would return, just reported
4292 /// here instead of at extraction time.
4293 pub async fn checkout(self) -> Result<Db, AutumnError> {
4294 let result = Db::checkout(DbCheckoutParams {
4295 pool: &self.pool,
4296 pool_name: "primary",
4297 shard: None,
4298 statement_timeout: self.ctx.statement_timeout,
4299 idle_in_transaction_timeout: self.ctx.idle_in_transaction_timeout,
4300 route_key: self.ctx.route_key,
4301 metrics: self.ctx.metrics,
4302 slow_query_threshold: self.ctx.slow_query_threshold,
4303 interceptors: self.ctx.interceptors,
4304 #[cfg(all(feature = "reporting", not(feature = "sqlite")))]
4305 capture_gap: self.capture_gap,
4306 clock: self.ctx.clock,
4307 })
4308 .await;
4309 // The same read-your-writes bookkeeping the eager extractor does, at
4310 // the moment the connection is actually taken.
4311 if result.is_ok() {
4312 crate::read_your_writes::mark_write();
4313 }
4314 result
4315 }
4316}
4317
4318impl<S> FromRequestParts<S> for LazyDb
4319where
4320 S: DbState + Send + Sync,
4321{
4322 type Rejection = AutumnError;
4323
4324 async fn from_request_parts(
4325 parts: &mut axum::http::request::Parts,
4326 state: &S,
4327 ) -> Result<Self, Self::Rejection> {
4328 // The pool is resolved here, so a misconfigured app still fails at
4329 // extraction exactly as it does with `Db`. Only the checkout moves.
4330 let pool = state
4331 .pool()
4332 .ok_or_else(|| AutumnError::service_unavailable_msg("Database not configured"))?
4333 .clone();
4334 Ok(Self {
4335 pool,
4336 ctx: RequestDbContext::from_parts(parts, state),
4337 #[cfg(all(feature = "reporting", not(feature = "sqlite")))]
4338 capture_gap: state.db_capture_gap(),
4339 })
4340 }
4341}
4342
4343impl<S> FromRequestParts<S> for Db
4344where
4345 S: DbState + Send + Sync,
4346{
4347 type Rejection = AutumnError;
4348
4349 async fn from_request_parts(
4350 parts: &mut axum::http::request::Parts,
4351 state: &S,
4352 ) -> Result<Self, Self::Rejection> {
4353 let pool = state
4354 .pool()
4355 .ok_or_else(|| AutumnError::service_unavailable_msg("Database not configured"))?;
4356 let ctx = RequestDbContext::from_parts(parts, state);
4357
4358 let result = Self::checkout(DbCheckoutParams {
4359 pool,
4360 pool_name: "primary",
4361 shard: None,
4362 statement_timeout: ctx.statement_timeout,
4363 idle_in_transaction_timeout: ctx.idle_in_transaction_timeout,
4364 route_key: ctx.route_key,
4365 metrics: ctx.metrics,
4366 slow_query_threshold: ctx.slow_query_threshold,
4367 interceptors: ctx.interceptors,
4368 #[cfg(all(feature = "reporting", not(feature = "sqlite")))]
4369 capture_gap: state.db_capture_gap(),
4370 clock: ctx.clock,
4371 })
4372 .await;
4373 // Notify the RYWW task-local that a primary connection was checked out.
4374 // No-op when read_your_writes = "off" (task-local absent).
4375 if result.is_ok() {
4376 crate::read_your_writes::mark_write();
4377 }
4378 result
4379 }
4380}
4381
4382impl Drop for Db {
4383 fn drop(&mut self) {
4384 if let (Some(route_key), Some(metrics)) = (&self.route_key, &self.metrics) {
4385 let elapsed = self
4386 .clock
4387 .monotonic()
4388 .saturating_duration_since(self.start_time);
4389 let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
4390
4391 // Record DB query metric. Composed on the stack: this runs once
4392 // per request that extracts `Db`.
4393 metrics.record_db_query_fmt(format_args!("{route_key} SELECT"), elapsed_ms);
4394 // Deliberately not recorded into the Server-Timing per-request
4395 // accumulator. `elapsed` here is the whole connection
4396 // checkout-to-release window (see `start_time` and the `span` doc
4397 // above), not a single query's wall time. Every request that extracts
4398 // `Db` would otherwise add its entire connection-hold time as one
4399 // bogus "query", inflating both `db;dur` and the `desc="N queries"`
4400 // count, and double-counting against real queries recorded by
4401 // `run_instrumented`. The accumulator must reflect genuine
4402 // instrumented queries only.
4403
4404 // Log slow query if it exceeds the threshold
4405 if elapsed >= self.slow_query_threshold {
4406 tracing::warn!(
4407 route = %route_key,
4408 sql = "SELECT ?",
4409 duration_ms = elapsed_ms,
4410 "slow database query"
4411 );
4412 }
4413 }
4414 }
4415}
4416
4417// ----------------------------------------------------------------------------
4418// DatabasePoolProvider — tier-1 boot-time replaceable pool factory
4419// ----------------------------------------------------------------------------
4420
4421/// Pluggable boot-time database pool factory.
4422///
4423/// Replace the default `deadpool + diesel-async` factory with a custom
4424/// strategy (custom metrics wrapper, circuit breaker, separate pools per
4425/// shard, etc.) by implementing this trait and installing it on the
4426/// [`AppBuilder`](crate::app::AppBuilder) via
4427/// [`with_pool_provider`](crate::app::AppBuilder::with_pool_provider).
4428///
4429/// The trait abstracts the *factory*, not the pool *type* — the return type is
4430/// fixed at `Pool<AsyncPgConnection>` for now. Swapping to a different backend
4431/// (e.g. `MySQL`, `SQLite`) would require generic `Pool<C>` propagation through
4432/// `Db` / `DbState` / `AppState` and is intentionally out of scope.
4433///
4434/// Providers that only implement [`DatabasePoolProvider::create_pool`] still
4435/// participate in primary/replica topology: the default
4436/// [`DatabasePoolProvider::create_topology`] uses the custom primary pool and
4437/// builds the configured replica role with Autumn's deadpool factory. Override
4438/// `create_topology` when both roles need custom construction.
4439///
4440/// # Example
4441///
4442/// ```rust,no_run
4443/// use autumn_web::config::DatabaseConfig;
4444/// use autumn_web::db::{DatabasePoolProvider, PoolError};
4445/// use diesel_async::AsyncPgConnection;
4446/// use diesel_async::pooled_connection::deadpool::Pool;
4447///
4448/// pub struct MetricsPoolProvider;
4449///
4450/// impl DatabasePoolProvider for MetricsPoolProvider {
4451/// async fn create_pool(
4452/// &self,
4453/// config: &DatabaseConfig,
4454/// ) -> Result<Option<Pool<AsyncPgConnection>>, PoolError> {
4455/// // Wrap the default pool with custom metrics, then return it.
4456/// autumn_web::db::create_pool(config)
4457/// }
4458/// }
4459/// ```
4460pub trait DatabasePoolProvider: Send + Sync + 'static {
4461 /// Create a connection pool from the resolved [`DatabaseConfig`].
4462 ///
4463 /// Returning `Ok(None)` signals that the application should run without a
4464 /// database — useful for static-site / API-gateway use cases or for
4465 /// disabling the DB in test contexts.
4466 fn create_pool(
4467 &self,
4468 config: &DatabaseConfig,
4469 ) -> impl std::future::Future<Output = Result<Option<Pool<RuntimeConnection>>, PoolError>> + Send;
4470
4471 /// Create primary and optional replica pools from the resolved
4472 /// [`DatabaseConfig`].
4473 ///
4474 /// The default implementation preserves the provider's custom primary pool
4475 /// and builds a replica pool when `database.replica_url` is configured.
4476 ///
4477 /// # Errors
4478 ///
4479 /// Returns [`PoolError`] if either configured role cannot be built.
4480 fn create_topology(
4481 &self,
4482 config: &DatabaseConfig,
4483 ) -> impl std::future::Future<Output = Result<Option<DatabaseTopology>, PoolError>> + Send {
4484 async move {
4485 let Some(primary) = self.create_pool(config).await? else {
4486 return Ok(None);
4487 };
4488
4489 // A custom provider that overrides only `create_pool` still gets its replica
4490 // built by this default, so the SQLite-replica rule must be enforced here
4491 // too. Otherwise a provider configuring a distinct or in-memory
4492 // `database.replica_url` would boot two unrelated SQLite databases and route
4493 // reads to an empty or stale replica. Reuse the same helper
4494 // `create_topology`/`create_shard_topology` use, so the rejection rule cannot
4495 // drift. The primary URL is whatever `effective_primary_url` resolves; a
4496 // provider returning a pool for a `None` primary URL has no URL to compare,
4497 // so skip then.
4498 #[cfg(feature = "sqlite")]
4499 if let (Some(primary_url), Some(replica_url)) = (
4500 config.effective_primary_url(),
4501 config.replica_url.as_deref(),
4502 ) {
4503 reject_unusable_sqlite_replica(primary_url, replica_url)?;
4504 }
4505
4506 let replica = config
4507 .replica_url
4508 .as_deref()
4509 .map(|url| {
4510 build_pool(
4511 url,
4512 config.effective_replica_pool_size(),
4513 config.connect_timeout_secs,
4514 )
4515 })
4516 .transpose()?;
4517
4518 Ok(Some(DatabaseTopology::from_pools(primary, replica)))
4519 }
4520 }
4521
4522 /// Create one shard's [`DatabaseTopology`] from its
4523 /// `[[database.shards]]` entry.
4524 ///
4525 /// The default implementation uses Autumn's deadpool factory for both
4526 /// roles. Override to decorate per-shard pools (metrics wrappers,
4527 /// circuit breakers) the same way `create_pool` decorates the control
4528 /// role.
4529 ///
4530 /// # Errors
4531 ///
4532 /// Returns [`PoolError`] if either configured role cannot be built.
4533 fn create_shard_topology(
4534 &self,
4535 shard: &crate::config::ShardConfig,
4536 defaults: &DatabaseConfig,
4537 ) -> impl std::future::Future<Output = Result<DatabaseTopology, PoolError>> + Send {
4538 async move { create_shard_topology(shard, defaults) }
4539 }
4540}
4541
4542/// Default [`DatabasePoolProvider`] — the `deadpool + diesel-async` factory.
4543///
4544/// Delegates to the free function [`create_pool`]. This is the provider used
4545/// when no override is installed via
4546/// [`with_pool_provider`](crate::app::AppBuilder::with_pool_provider).
4547#[derive(Debug, Default, Clone, Copy)]
4548pub struct DieselDeadpoolPoolProvider;
4549
4550impl DieselDeadpoolPoolProvider {
4551 /// Construct a new default provider.
4552 #[must_use]
4553 pub const fn new() -> Self {
4554 Self
4555 }
4556}
4557
4558impl DatabasePoolProvider for DieselDeadpoolPoolProvider {
4559 async fn create_pool(
4560 &self,
4561 config: &DatabaseConfig,
4562 ) -> Result<Option<Pool<RuntimeConnection>>, PoolError> {
4563 create_pool(config)
4564 }
4565}
4566
4567#[cfg(test)]
4568mod tests {
4569 /// An unset statement timeout keeps the session's `0` from checkout; an
4570 /// unset idle timeout keeps the session value through `DEFAULT`.
4571 #[test]
4572 fn tx_timeouts_send_zero_for_an_unset_statement_timeout() {
4573 use std::time::Duration;
4574 let sql = |statement, idle| super::TxTimeouts::new(statement, idle).set_local_sql();
4575 assert_eq!(sql(None, None), None);
4576 assert_eq!(
4577 sql(None, Some(Duration::from_secs(60))).as_deref(),
4578 Some(
4579 "SET LOCAL statement_timeout = 0; \
4580 SET LOCAL idle_in_transaction_session_timeout = 60000"
4581 )
4582 );
4583 assert_eq!(
4584 sql(Some(Duration::from_secs(30)), None).as_deref(),
4585 Some(
4586 "SET LOCAL statement_timeout = 30000; \
4587 SET LOCAL idle_in_transaction_session_timeout = DEFAULT"
4588 )
4589 );
4590 }
4591
4592 /// A `database` fault fails the checkout before the pool dials.
4593 #[cfg(not(feature = "sqlite"))]
4594 #[tokio::test]
4595 async fn an_injected_database_fault_fails_the_checkout() {
4596 use crate::fault_injection::{FaultKind, FaultRule, FaultTarget, with_faults};
4597
4598 let config = crate::config::DatabaseConfig {
4599 primary_url: Some("postgres://127.0.0.1:1/faults".to_owned()),
4600 connect_timeout_secs: 1,
4601 ..Default::default()
4602 };
4603 let pool = super::create_pool(&config)
4604 .expect("a lazy pool builds")
4605 .expect("a URL is set");
4606 let checkout = super::Db::checkout(super::DbCheckoutParams {
4607 pool: &pool,
4608 pool_name: "primary",
4609 shard: None,
4610 statement_timeout: None,
4611 idle_in_transaction_timeout: None,
4612 route_key: None,
4613 metrics: None,
4614 slow_query_threshold: std::time::Duration::from_millis(500),
4615 interceptors: Vec::new(),
4616 #[cfg(feature = "reporting")]
4617 capture_gap: None,
4618 clock: std::sync::Arc::clone(&super::DEFAULT_SYSTEM_CLOCK),
4619 });
4620 let rules = [FaultRule::new(FaultTarget::Database, FaultKind::Error, 1.0)];
4621 let Err(error) = with_faults(&rules, checkout).await else {
4622 panic!("the injected fault must fail the checkout");
4623 };
4624 assert!(error.to_string().contains("fault injection"), "{error}");
4625 }
4626
4627 /// A scheduled task or a job has no request scope. The background scope
4628 /// gives its framework transactions the app's configured timeouts.
4629 #[tokio::test]
4630 async fn background_work_gets_the_configured_tx_timeouts() {
4631 use std::time::Duration;
4632 struct Configured;
4633 impl super::DbState for Configured {
4634 fn pool(&self) -> Option<&super::Pool<super::RuntimeConnection>> {
4635 None
4636 }
4637 fn statement_timeout(&self) -> Option<Duration> {
4638 Some(Duration::from_secs(30))
4639 }
4640 fn idle_in_transaction_timeout(&self) -> Option<Duration> {
4641 Some(Duration::from_secs(60))
4642 }
4643 }
4644 assert_eq!(super::TxTimeouts::current(), None);
4645 let inside = super::scope_background_tx_timeouts(&Configured, async {
4646 super::TxTimeouts::current()
4647 })
4648 .await;
4649 assert_eq!(
4650 inside,
4651 Some(super::TxTimeouts::new(
4652 Some(Duration::from_secs(30)),
4653 Some(Duration::from_secs(60))
4654 ))
4655 );
4656 }
4657
4658 /// After-commit callbacks run on a spawned task, which inherits no
4659 /// task-locals. The spawner carries the timeouts in scope into it.
4660 #[tokio::test]
4661 async fn after_commit_callbacks_keep_the_tx_timeouts() {
4662 use std::time::Duration;
4663 struct Configured;
4664 impl super::DbState for Configured {
4665 fn pool(&self) -> Option<&super::Pool<super::RuntimeConnection>> {
4666 None
4667 }
4668 fn statement_timeout(&self) -> Option<Duration> {
4669 Some(Duration::from_secs(30))
4670 }
4671 }
4672 let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
4673 let seen_in_callback = std::sync::Arc::clone(&seen);
4674 let callback: super::CommitCallback = Box::new(move || {
4675 Box::pin(async move {
4676 *seen_in_callback.lock().expect("lock") = super::TxTimeouts::current();
4677 Ok(())
4678 })
4679 });
4680 let drain = super::scope_background_tx_timeouts(&Configured, async move {
4681 super::spawn_committed_after_commit_callbacks(vec![callback])
4682 })
4683 .await
4684 .expect("a callback was registered");
4685 drain.await.expect("callback task");
4686 assert_eq!(
4687 *seen.lock().expect("lock"),
4688 Some(super::TxTimeouts::new(Some(Duration::from_secs(30)), None))
4689 );
4690 }
4691
4692 /// #2351 item 3: a callback the application registers runs on a detached
4693 /// task. Capture cannot see its effects, so the capsule says so.
4694 #[cfg(feature = "reporting")]
4695 #[tokio::test]
4696 async fn a_registered_after_commit_callback_marks_the_capsule_incomplete() {
4697 let scope = std::sync::Arc::new(crate::capsule::CaptureScope::new(
4698 "after-commit".to_owned(),
4699 std::sync::Arc::new(crate::capsule::CaptureSettings::default()),
4700 std::sync::Arc::new(crate::log::filter::ParameterFilter::default()),
4701 ));
4702 let registry: std::sync::Arc<std::sync::Mutex<Vec<super::CommitCallback>>> =
4703 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
4704 crate::capsule::capture::with_capture_scope(
4705 std::sync::Arc::clone(&scope),
4706 super::AFTER_COMMIT_REGISTRY.scope(std::sync::Arc::clone(®istry), async {
4707 super::register_after_commit(|| async { Ok(()) }).await;
4708 }),
4709 )
4710 .await;
4711 // Codex review on #3222: a registration alone is not detached work.
4712 // A rolled-back transaction drops its callbacks.
4713 assert!(!scope.is_truncated());
4714 let callbacks = std::mem::take(&mut *registry.lock().expect("registry"));
4715 let drain =
4716 crate::capsule::capture::with_capture_scope(std::sync::Arc::clone(&scope), async {
4717 super::spawn_committed_after_commit_callbacks(callbacks)
4718 })
4719 .await;
4720 drain.expect("a callback was spawned").await.expect("runs");
4721 assert!(scope.is_truncated(), "the commit spawns detached work");
4722 }
4723
4724 /// Codex review on #3222: a callback is built only after the one before
4725 /// it completes, inside the drain task.
4726 #[tokio::test]
4727 async fn after_commit_callbacks_are_built_in_order_after_the_last_one_ran() {
4728 let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
4729 let callback = |name: &'static str| -> super::CommitCallback {
4730 let log = std::sync::Arc::clone(&log);
4731 Box::new(move || {
4732 log.lock().expect("log").push(format!("build {name}"));
4733 Box::pin(async move {
4734 tokio::task::yield_now().await;
4735 log.lock().expect("log").push(format!("run {name}"));
4736 Ok(())
4737 })
4738 })
4739 };
4740 let drain =
4741 super::spawn_committed_after_commit_callbacks(vec![callback("one"), callback("two")]);
4742 drain.expect("a drain task").await.expect("runs");
4743 assert_eq!(
4744 *log.lock().expect("log"),
4745 ["build one", "run one", "build two", "run two"]
4746 );
4747 }
4748
4749 /// Codex review on #3222: a rolled-back `register_after_commit` callback
4750 /// does not mark a later commit's capsule incomplete.
4751 #[cfg(feature = "reporting")]
4752 #[tokio::test]
4753 async fn a_rolled_back_registration_does_not_mark_a_later_commit() {
4754 let scope = std::sync::Arc::new(crate::capsule::CaptureScope::new(
4755 "after-commit".to_owned(),
4756 std::sync::Arc::new(crate::capsule::CaptureSettings::default()),
4757 std::sync::Arc::new(crate::log::filter::ParameterFilter::default()),
4758 ));
4759 let registry: std::sync::Arc<std::sync::Mutex<Vec<super::CommitCallback>>> =
4760 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
4761 crate::capsule::capture::with_capture_scope(
4762 std::sync::Arc::clone(&scope),
4763 super::AFTER_COMMIT_REGISTRY.scope(std::sync::Arc::clone(®istry), async {
4764 super::register_after_commit(|| async { Ok(()) }).await;
4765 }),
4766 )
4767 .await;
4768 // The rollback drops the callback.
4769 drop(std::mem::take(&mut *registry.lock().expect("registry")));
4770 let framework: super::CommitCallback = Box::new(|| Box::pin(async { Ok(()) }));
4771 let drain =
4772 crate::capsule::capture::with_capture_scope(std::sync::Arc::clone(&scope), async {
4773 super::spawn_committed_after_commit_callbacks(vec![framework])
4774 })
4775 .await;
4776 drain.expect("a drain task").await.expect("runs");
4777 assert!(!scope.is_truncated());
4778 }
4779
4780 /// Codex review on #3222: `Mailer::deliver_later` and the
4781 /// `*_after_commit` job calls record their effect at registration and
4782 /// push their callback directly. A commit with only such callbacks keeps
4783 /// the capsule complete.
4784 #[cfg(feature = "reporting")]
4785 #[tokio::test]
4786 async fn a_recorded_framework_callback_keeps_the_capsule_complete() {
4787 let scope = std::sync::Arc::new(crate::capsule::CaptureScope::new(
4788 "after-commit".to_owned(),
4789 std::sync::Arc::new(crate::capsule::CaptureSettings::default()),
4790 std::sync::Arc::new(crate::log::filter::ParameterFilter::default()),
4791 ));
4792 let ran = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4793 let ran_in_callback = std::sync::Arc::clone(&ran);
4794 let callback: super::CommitCallback = Box::new(move || {
4795 Box::pin(async move {
4796 ran_in_callback.store(true, std::sync::atomic::Ordering::SeqCst);
4797 Ok(())
4798 })
4799 });
4800 let drain =
4801 crate::capsule::capture::with_capture_scope(std::sync::Arc::clone(&scope), async {
4802 super::spawn_committed_after_commit_callbacks(vec![callback])
4803 })
4804 .await;
4805 drain.expect("a callback was spawned").await.expect("runs");
4806 assert!(ran.load(std::sync::atomic::Ordering::SeqCst));
4807 assert!(!scope.is_truncated());
4808 }
4809
4810 /// #2351 item 3: during a replay, the callbacks get the replay tape, so
4811 /// their effects are served or refused and never reach live services.
4812 #[cfg(feature = "reporting")]
4813 #[tokio::test]
4814 async fn after_commit_callbacks_keep_the_replay_tape() {
4815 let seen = std::sync::Arc::new(std::sync::Mutex::new(false));
4816 let seen_in_callback = std::sync::Arc::clone(&seen);
4817 let callback: super::CommitCallback = Box::new(move || {
4818 Box::pin(async move {
4819 *seen_in_callback.lock().expect("lock") =
4820 crate::capsule::effects::current_tape().is_some();
4821 Ok(())
4822 })
4823 });
4824 let tape = std::sync::Arc::new(crate::capsule::ReplayEffects::new(
4825 crate::capsule::CapsuleEffects::default(),
4826 ));
4827 let drain = crate::capsule::with_effect_tape(tape, async move {
4828 super::spawn_committed_after_commit_callbacks(vec![callback])
4829 })
4830 .await
4831 .expect("a callback was registered");
4832 drain.await.expect("callback task");
4833 assert!(*seen.lock().expect("lock"));
4834 }
4835
4836 #[test]
4837 fn replication_adds_the_auto_checkpoint_lock_to_the_pooled_pragmas() {
4838 // Continuous replication (#1628) needs the replicator to be the only
4839 // component that ever checkpoints.
4840 let plain = super::sqlite_connection_pragmas(false, false);
4841 let replicating = super::sqlite_connection_pragmas(false, true);
4842 assert!(!plain.contains("wal_autocheckpoint"));
4843 assert!(replicating.contains("PRAGMA wal_autocheckpoint = 0"));
4844 for pragmas in [plain, replicating] {
4845 assert!(pragmas.contains("journal_mode = WAL"));
4846 assert!(pragmas.contains("foreign_keys = ON"));
4847 assert!(pragmas.contains("busy_timeout = 5000"));
4848 }
4849
4850 // A read-only target still gets no writing pragmas — including under
4851 // replication, which cannot apply to a database nothing writes to.
4852 for replicating in [false, true] {
4853 let read_only = super::sqlite_connection_pragmas(true, replicating);
4854 assert!(!read_only.contains("journal_mode"));
4855 assert!(!read_only.contains("wal_autocheckpoint"));
4856 assert!(read_only.contains("foreign_keys = ON"));
4857 }
4858 }
4859
4860 use super::*;
4861 use crate::config::DatabaseConfig;
4862
4863 /// A failed checkout still records its pool wait (issue #3064): a
4864 /// checkout timeout is the overload signal operators need most.
4865 #[cfg(not(feature = "sqlite"))]
4866 #[tokio::test]
4867 async fn checkout_records_pool_wait_even_on_failure() {
4868 let manager = diesel_async::pooled_connection::AsyncDieselConnectionManager::<
4869 RuntimeConnection,
4870 >::new("postgres://autumn:autumn@127.0.0.1:1/nothing");
4871 let pool = Pool::builder(manager).max_size(1).build().unwrap();
4872 let metrics = crate::middleware::MetricsCollector::new();
4873
4874 let result = Db::checkout(DbCheckoutParams {
4875 pool: &pool,
4876 pool_name: "primary",
4877 shard: None,
4878 statement_timeout: None,
4879 idle_in_transaction_timeout: None,
4880 route_key: None,
4881 metrics: Some(metrics.clone()),
4882 slow_query_threshold: std::time::Duration::from_millis(500),
4883 interceptors: Vec::new(),
4884 #[cfg(feature = "reporting")]
4885 capture_gap: None,
4886 clock: std::sync::Arc::clone(&DEFAULT_SYSTEM_CLOCK),
4887 })
4888 .await;
4889
4890 assert!(result.is_err(), "nothing listens on port 1");
4891 assert_eq!(metrics.db_pool_wait().count(), 1);
4892 }
4893 use std::sync::Arc;
4894 use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
4895 use std::time::Duration;
4896
4897 // ── RequestQueryTimer / Server-Timing db accumulator tests ───
4898
4899 /// The connection instrumentation records one query per completed
4900 /// `StartQuery`/`FinishQuery` pair into the request accumulator, summing
4901 /// elapsed time. Drives the extracted `on_start`/`on_finish` accounting
4902 /// (the `InstrumentationEvent` itself is non-exhaustive and its
4903 /// constructors are gated behind an unstable diesel feature, so it cannot
4904 /// be built in a test). Deterministic: derives finish instants from the
4905 /// start via `Instant + Duration`, no sleeps.
4906 #[cfg(feature = "db")]
4907 #[tokio::test]
4908 async fn request_query_timer_accumulates_finished_queries() {
4909 let timings = Arc::new(RequestDbTimings::default());
4910 REQUEST_DB_TIMINGS
4911 .scope(Arc::clone(&timings), async {
4912 let mut timer = RequestQueryTimer::default();
4913
4914 // Query 1: 1200µs.
4915 let t0 = crate::time::MonotonicInstant::ORIGIN;
4916 timer.on_start(t0, || "SELECT 1".to_string());
4917 timer.on_finish(t0.saturating_add(Duration::from_micros(1_200)));
4918
4919 // Query 2: 800µs.
4920 let t1 = crate::time::MonotonicInstant::ORIGIN;
4921 timer.on_start(t1, || "UPDATE users SET name = $1".to_string());
4922 timer.on_finish(t1.saturating_add(Duration::from_micros(800)));
4923
4924 // A stray FinishQuery with no matching StartQuery is ignored.
4925 timer.on_finish(crate::time::MonotonicInstant::ORIGIN);
4926 })
4927 .await;
4928
4929 assert_eq!(
4930 timings.query_count.load(Ordering::Relaxed),
4931 2,
4932 "only completed StartQuery/FinishQuery pairs are counted"
4933 );
4934 assert_eq!(
4935 timings.total_us.load(Ordering::Relaxed),
4936 2_000,
4937 "elapsed times accumulate (1200µs + 800µs)"
4938 );
4939 }
4940
4941 /// Outside a `REQUEST_DB_TIMINGS` scope the timer records nothing and does
4942 /// not panic — the off-request / middleware-disabled path.
4943 #[cfg(feature = "db")]
4944 #[tokio::test]
4945 async fn request_query_timer_is_noop_off_request() {
4946 let mut timer = RequestQueryTimer::default();
4947 let t0 = crate::time::MonotonicInstant::ORIGIN;
4948 timer.on_start(t0, || "SELECT 1".to_string());
4949 // Must not panic even though no task-local accumulator is scoped.
4950 timer.on_finish(t0.saturating_add(Duration::from_micros(500)));
4951 }
4952
4953 /// Approach-(b) guarantee: when no `REQUEST_DB_TIMINGS` scope is active,
4954 /// `on_start` must be a cheap no-op — it must not invoke the (allocating)
4955 /// SQL-formatting closure, and must leave no in-flight statement. This is
4956 /// what makes it safe to leave a `RequestQueryTimer` installed on a pooled
4957 /// connection that a later opted-out request reuses.
4958 #[cfg(feature = "db")]
4959 #[tokio::test]
4960 async fn request_query_timer_on_start_is_cheap_noop_off_request() {
4961 let mut timer = RequestQueryTimer::default();
4962 let invoked = std::cell::Cell::new(false);
4963 let t0 = crate::time::MonotonicInstant::ORIGIN;
4964 timer.on_start(t0, || {
4965 invoked.set(true);
4966 "SELECT 1".to_string()
4967 });
4968 assert!(
4969 !invoked.get(),
4970 "off-request, on_start must not format the SQL (no allocation)"
4971 );
4972 // No in-flight statement was recorded, so on_finish is a no-op.
4973 timer.on_finish(t0.saturating_add(Duration::from_micros(500)));
4974 }
4975
4976 /// Regression for the stale-timer / housekeeping-`SET` bug: a pooled
4977 /// connection reused across requests keeps a `RequestQueryTimer` installed,
4978 /// and `Db::checkout` runs a `SET statement_timeout` before handing the
4979 /// connection over. That housekeeping `SET` must NOT be counted, or every
4980 /// `Server-Timing` request would report a bogus `+1 query` before any app
4981 /// SQL. After the `SET`, the first real `SELECT` increments the count to
4982 /// exactly 1 and only its latency accumulates.
4983 #[cfg(feature = "db")]
4984 #[tokio::test]
4985 async fn request_query_timer_excludes_checkout_statement_timeout_set() {
4986 let timings = Arc::new(RequestDbTimings::default());
4987 REQUEST_DB_TIMINGS
4988 .scope(Arc::clone(&timings), async {
4989 // A timer as it would be freshly installed at checkout.
4990 let mut timer = RequestQueryTimer::default();
4991
4992 // Checkout housekeeping `SET` — must not be counted.
4993 let t0 = crate::time::MonotonicInstant::ORIGIN;
4994 timer.on_start(t0, || "SET statement_timeout = 5000".to_string());
4995 timer.on_finish(t0.saturating_add(Duration::from_millis(3)));
4996
4997 assert_eq!(
4998 timings.query_count.load(Ordering::Relaxed),
4999 0,
5000 "the checkout SET statement_timeout must not be counted"
5001 );
5002 assert_eq!(
5003 timings.total_us.load(Ordering::Relaxed),
5004 0,
5005 "the checkout SET contributes no latency to db;dur"
5006 );
5007
5008 // First real application query: 600µs.
5009 let t1 = crate::time::MonotonicInstant::ORIGIN;
5010 timer.on_start(t1, || "SELECT * FROM users".to_string());
5011 timer.on_finish(t1.saturating_add(Duration::from_micros(600)));
5012
5013 assert_eq!(
5014 timings.query_count.load(Ordering::Relaxed),
5015 1,
5016 "the real SELECT is the first counted query"
5017 );
5018 assert_eq!(
5019 timings.total_us.load(Ordering::Relaxed),
5020 600,
5021 "only the SELECT's 600µs accumulates; the SET is excluded"
5022 );
5023 })
5024 .await;
5025 }
5026
5027 /// `request_db_timing_active` — the predicate `RequestQueryTimer::on_start`
5028 /// probes to decide whether to record — must report `false` outside a
5029 /// `REQUEST_DB_TIMINGS` scope (the production / `server_timing` disabled
5030 /// default) and `true` inside one (the `ServerTimingLayer`-scoped request).
5031 /// This predicate gates two things: whether `Db::checkout` installs the
5032 /// timer at all (skipped off-scope so an app's own instrumentation is not
5033 /// clobbered), and whether a stale timer left on a reused connection does
5034 /// any work — with the probe `false`, `on_start` is a cheap no-op.
5035 ///
5036 /// NOTE: the real `Db::checkout` path needs a live Postgres connection,
5037 /// which the sandbox cannot provide, so this exercises the predicate
5038 /// directly.
5039 #[cfg(feature = "db")]
5040 #[tokio::test]
5041 async fn request_db_timing_active_reflects_scope() {
5042 assert!(
5043 !request_db_timing_active(),
5044 "no REQUEST_DB_TIMINGS scope active outside the ServerTimingLayer: \
5045 the timer records nothing"
5046 );
5047
5048 let timings = Arc::new(RequestDbTimings::default());
5049 REQUEST_DB_TIMINGS
5050 .scope(Arc::clone(&timings), async {
5051 assert!(
5052 request_db_timing_active(),
5053 "inside a REQUEST_DB_TIMINGS scope the probe is active: \
5054 the timer records queries"
5055 );
5056 })
5057 .await;
5058
5059 // Scope has ended; the gate reads false again.
5060 assert!(
5061 !request_db_timing_active(),
5062 "the gate is false again once the scope is dropped"
5063 );
5064 }
5065
5066 /// A transaction wraps its inner statements in `BEGIN`/`COMMIT`, which
5067 /// diesel-async runs through `batch_execute` — emitting a
5068 /// `StartQuery`/`FinishQuery` pair with that transaction-control SQL, just
5069 /// like a real query. The timer must skip those so a transaction with one
5070 /// real `SELECT` reports `desc="1 query"`, not `"3 queries"`, and excludes
5071 /// begin/commit latency from `db;dur`.
5072 #[cfg(feature = "db")]
5073 #[tokio::test]
5074 async fn request_query_timer_excludes_transaction_control_statements() {
5075 let timings = Arc::new(RequestDbTimings::default());
5076 REQUEST_DB_TIMINGS
5077 .scope(Arc::clone(&timings), async {
5078 let mut timer = RequestQueryTimer::default();
5079
5080 // BEGIN — must not be counted (10_000µs, would dominate if it
5081 // leaked into the total).
5082 let t0 = crate::time::MonotonicInstant::ORIGIN;
5083 timer.on_start(t0, || "BEGIN".to_string());
5084 timer.on_finish(t0.saturating_add(Duration::from_millis(10)));
5085
5086 // The single real query: 700µs.
5087 let t1 = crate::time::MonotonicInstant::ORIGIN;
5088 timer.on_start(t1, || "SELECT * FROM users".to_string());
5089 timer.on_finish(t1.saturating_add(Duration::from_micros(700)));
5090
5091 // COMMIT — must not be counted.
5092 let t2 = crate::time::MonotonicInstant::ORIGIN;
5093 timer.on_start(t2, || "COMMIT".to_string());
5094 timer.on_finish(t2.saturating_add(Duration::from_millis(10)));
5095 })
5096 .await;
5097
5098 assert_eq!(
5099 timings.query_count.load(Ordering::Relaxed),
5100 1,
5101 "only the real SELECT is counted; BEGIN/COMMIT are excluded"
5102 );
5103 assert_eq!(
5104 timings.total_us.load(Ordering::Relaxed),
5105 700,
5106 "only the SELECT's 700µs accumulates; begin/commit latency is excluded"
5107 );
5108 }
5109
5110 /// The checkout install gate: `Db::checkout` calls `set_instrumentation`
5111 /// (which WHOLESALE REPLACES the connection's instrumentation — clobbering
5112 /// any app-registered `diesel::connection::set_default_instrumentation`)
5113 /// ONLY when `request_db_timing_active()` is true. Outside a
5114 /// `REQUEST_DB_TIMINGS` scope (the `server_timing`-disabled production
5115 /// default) the gate is false, so the guarded install is skipped and a
5116 /// pre-existing sentinel instrumentation is left intact. Inside a scope the
5117 /// timer is installed.
5118 ///
5119 /// NOTE: the real `Db::checkout` path needs a live Postgres connection the
5120 /// sandbox cannot provide, so this models the connection's single
5121 /// instrumentation slot and drives the exact gate expression from
5122 /// `Db::checkout`.
5123 #[cfg(feature = "db")]
5124 #[tokio::test]
5125 async fn checkout_installs_timer_only_when_scope_active() {
5126 // Models the connection's single instrumentation slot. "app" stands for
5127 // an application-registered default (`set_default_instrumentation`);
5128 // "timer" is autumn's `RequestQueryTimer`. Mirrors the guarded block in
5129 // `Db::checkout`: `if request_db_timing_active() { install timer }`.
5130 fn run_checkout_gate(slot: &mut &'static str) {
5131 if request_db_timing_active() {
5132 *slot = "timer";
5133 }
5134 }
5135
5136 // Off-scope (server_timing disabled / production default): the gate is
5137 // false, so the app's instrumentation must survive untouched.
5138 assert!(!request_db_timing_active());
5139 let mut slot = "app";
5140 run_checkout_gate(&mut slot);
5141 assert_eq!(
5142 slot, "app",
5143 "off-scope checkout must NOT clobber the app's instrumentation"
5144 );
5145
5146 // Inside a scope (server_timing enabled, handler wrapped by the layer):
5147 // the gate is true, so autumn installs its RequestQueryTimer.
5148 let timings = Arc::new(RequestDbTimings::default());
5149 REQUEST_DB_TIMINGS
5150 .scope(Arc::clone(&timings), async {
5151 let mut slot = "app";
5152 run_checkout_gate(&mut slot);
5153 assert_eq!(
5154 slot, "timer",
5155 "inside a REQUEST_DB_TIMINGS scope autumn installs its timer"
5156 );
5157 })
5158 .await;
5159 }
5160
5161 /// Unit coverage for the uncounted-statement classifier used by the timer:
5162 /// leading-token match is case-insensitive and covers savepoint/release, the
5163 /// two-word `START TRANSACTION` form, and any leading `SET` (both
5164 /// `SET TRANSACTION` and the checkout `SET statement_timeout` housekeeping),
5165 /// while real application queries are counted. `UPDATE ... SET ...` is
5166 /// counted because `SET` is not its leading token.
5167 #[cfg(feature = "db")]
5168 #[test]
5169 fn is_uncounted_statement_classifies_statements() {
5170 for sql in [
5171 "BEGIN",
5172 "begin",
5173 " COMMIT",
5174 "ROLLBACK",
5175 "ROLLBACK TO SAVEPOINT diesel_savepoint_0",
5176 "SAVEPOINT diesel_savepoint_1",
5177 "RELEASE SAVEPOINT diesel_savepoint_0",
5178 "start transaction",
5179 "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE",
5180 "SET statement_timeout = 5000",
5181 "set statement_timeout = 0",
5182 ] {
5183 assert!(
5184 RequestQueryTimer::is_uncounted_statement(sql),
5185 "{sql:?} should be treated as an uncounted housekeeping/tx statement"
5186 );
5187 }
5188
5189 for sql in [
5190 "SELECT 1",
5191 "UPDATE users SET name = $1",
5192 "INSERT INTO t VALUES (1)",
5193 "DELETE FROM t WHERE id = 1",
5194 "",
5195 ] {
5196 assert!(
5197 !RequestQueryTimer::is_uncounted_statement(sql),
5198 "{sql:?} should be treated as a countable query"
5199 );
5200 }
5201 }
5202
5203 /// Finding-1 regression: the capture path stores the parameterised
5204 /// statement WITHOUT diesel's trailing `-- binds: [...]` annotation, so the
5205 /// per-row executions of an N+1 pattern (which differ only in their bind
5206 /// values) collapse to a single template and `detect_n_plus_one` catches
5207 /// them. Without the strip, each `-- binds: [N]` would normalise to a
5208 /// distinct template and the N+1 would go undetected. Locks the fix with no
5209 /// live database.
5210 #[cfg(feature = "db")]
5211 #[test]
5212 fn strip_bind_annotation_lets_detect_n_plus_one_see_per_row_repetition() {
5213 use crate::inspector::{QueryRecord, detect_n_plus_one};
5214
5215 // Construct QueryRecords exactly as `record_request_db_query` does:
5216 // the captured `sql` is diesel's `DebugQuery` `Display` output run
5217 // through `strip_bind_annotation`.
5218 fn record(diesel_display: &str) -> QueryRecord {
5219 QueryRecord {
5220 sql: strip_bind_annotation(diesel_display).to_owned(),
5221 params: Vec::new(),
5222 elapsed_ms: 1,
5223 location: String::new(),
5224 }
5225 }
5226
5227 // The `-- binds:` marker (and the leading whitespace before it) is
5228 // stripped; the `$N` placeholder is preserved.
5229 assert_eq!(
5230 strip_bind_annotation("SELECT * FROM books WHERE author_id = $1 -- binds: [1]"),
5231 "SELECT * FROM books WHERE author_id = $1"
5232 );
5233 // Zero-bind annotation is stripped too.
5234 assert_eq!(
5235 strip_bind_annotation("SELECT * FROM authors -- binds: []"),
5236 "SELECT * FROM authors"
5237 );
5238 // No marker (transaction-control / synthetic input) → unchanged.
5239 assert_eq!(strip_bind_annotation("SELECT 1"), "SELECT 1");
5240 assert_eq!(strip_bind_annotation("BEGIN"), "BEGIN");
5241
5242 // A classic N+1: one parent query, then the same per-row child query
5243 // executed once per row with a different bind value each time.
5244 let n_plus_one = vec![
5245 record("SELECT * FROM authors -- binds: []"),
5246 record("SELECT * FROM books WHERE author_id = $1 -- binds: [1]"),
5247 record("SELECT * FROM books WHERE author_id = $1 -- binds: [2]"),
5248 record("SELECT * FROM books WHERE author_id = $1 -- binds: [3]"),
5249 ];
5250 let warning = detect_n_plus_one(&n_plus_one, 3)
5251 .expect("three identical per-row templates should trip the N+1 detector");
5252 assert_eq!(
5253 warning.count, 3,
5254 "all three per-row executions collapse to a single template"
5255 );
5256 assert_eq!(
5257 warning.sql_template, "select * from books where author_id = $1",
5258 "the reported template is the parameterised statement, bind-free"
5259 );
5260
5261 // Distinct query templates must NOT trip the detector, even at the
5262 // same total query count.
5263 let distinct = vec![
5264 record("SELECT * FROM authors -- binds: []"),
5265 record("SELECT * FROM books WHERE id = $1 -- binds: [1]"),
5266 record("UPDATE users SET name = $1 WHERE id = $2 -- binds: [\"x\", 2]"),
5267 ];
5268 assert!(
5269 detect_n_plus_one(&distinct, 3).is_none(),
5270 "three distinct query templates must not be reported as N+1"
5271 );
5272 }
5273
5274 // ── after_commit tests ───────────────────────────────────────
5275
5276 #[tokio::test]
5277 async fn register_after_commit_outside_tx_runs_eagerly() {
5278 // When called outside a db.tx block, the callback should run immediately.
5279 let counter = Arc::new(AtomicUsize::new(0));
5280 let c = counter.clone();
5281 register_after_commit(move || async move {
5282 c.fetch_add(1, Ordering::SeqCst);
5283 Ok(())
5284 })
5285 .await;
5286 assert_eq!(counter.load(Ordering::SeqCst), 1);
5287 }
5288
5289 #[tokio::test]
5290 async fn register_after_commit_eager_failure_increments_failure_counter() {
5291 let before = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
5292
5293 register_after_commit(|| async {
5294 Err(crate::AutumnError::internal_server_error_msg(
5295 "deliberate eager after-commit failure",
5296 ))
5297 })
5298 .await;
5299
5300 let after = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
5301 assert!(
5302 after > before,
5303 "eager after_commit failures should be counted for recovery signals"
5304 );
5305 }
5306
5307 #[tokio::test]
5308 async fn register_after_commit_inside_scope_defers_until_drained() {
5309 // Inside a task-local scope (simulating Db::tx), callbacks are deferred.
5310 let counter = Arc::new(AtomicUsize::new(0));
5311 let c = counter.clone();
5312
5313 let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
5314
5315 // Simulate being inside a db.tx by setting the task-local
5316 AFTER_COMMIT_REGISTRY
5317 .scope(registry.clone(), async {
5318 register_after_commit(move || async move {
5319 c.fetch_add(1, Ordering::SeqCst);
5320 Ok(())
5321 })
5322 .await;
5323 })
5324 .await;
5325
5326 // Callback must NOT have run yet
5327 assert_eq!(counter.load(Ordering::SeqCst), 0);
5328
5329 // Drain and run the callbacks (simulating post-commit)
5330 let callbacks: Vec<CommitCallback> = {
5331 let mut reg = registry.lock().unwrap();
5332 std::mem::take(&mut *reg)
5333 };
5334 for cb in callbacks {
5335 cb().await.unwrap();
5336 }
5337
5338 assert_eq!(counter.load(Ordering::SeqCst), 1);
5339 }
5340
5341 #[tokio::test]
5342 async fn register_after_commit_on_rollback_callbacks_dropped() {
5343 // Callbacks registered inside a tx scope that is NOT drained are dropped.
5344 let counter = Arc::new(AtomicUsize::new(0));
5345 let c = counter.clone();
5346
5347 let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
5348
5349 AFTER_COMMIT_REGISTRY
5350 .scope(registry.clone(), async {
5351 register_after_commit(move || async move {
5352 c.fetch_add(1, Ordering::SeqCst);
5353 Ok(())
5354 })
5355 .await;
5356 })
5357 .await;
5358
5359 // Simulate rollback: drop the callbacks without running them
5360 drop(registry);
5361
5362 assert_eq!(counter.load(Ordering::SeqCst), 0);
5363 }
5364
5365 #[tokio::test]
5366 async fn register_after_commit_callbacks_run_in_registration_order() {
5367 let order = Arc::new(std::sync::Mutex::new(Vec::<u32>::new()));
5368 let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
5369
5370 let o1 = order.clone();
5371 let o2 = order.clone();
5372 let o3 = order.clone();
5373
5374 AFTER_COMMIT_REGISTRY
5375 .scope(registry.clone(), async {
5376 register_after_commit(move || async move {
5377 o1.lock().unwrap().push(1);
5378 Ok(())
5379 })
5380 .await;
5381 register_after_commit(move || async move {
5382 o2.lock().unwrap().push(2);
5383 Ok(())
5384 })
5385 .await;
5386 register_after_commit(move || async move {
5387 o3.lock().unwrap().push(3);
5388 Ok(())
5389 })
5390 .await;
5391 })
5392 .await;
5393
5394 let callbacks: Vec<CommitCallback> = {
5395 let mut reg = registry.lock().unwrap();
5396 std::mem::take(&mut *reg)
5397 };
5398 for cb in callbacks {
5399 cb().await.unwrap();
5400 }
5401
5402 assert_eq!(*order.lock().unwrap(), vec![1, 2, 3]);
5403 }
5404
5405 #[tokio::test]
5406 async fn production_after_commit_drain_preserves_registration_order() {
5407 let order = Arc::new(std::sync::Mutex::new(Vec::<u32>::new()));
5408 let (release_first, wait_first) = tokio::sync::oneshot::channel::<()>();
5409
5410 let first_order = order.clone();
5411 let second_order = order.clone();
5412 let callbacks: Vec<CommitCallback> = vec![
5413 Box::new(move || {
5414 Box::pin(async move {
5415 wait_first
5416 .await
5417 .expect("test should release first callback");
5418 first_order.lock().unwrap().push(1);
5419 Ok(())
5420 })
5421 }),
5422 Box::new(move || {
5423 Box::pin(async move {
5424 second_order.lock().unwrap().push(2);
5425 Ok(())
5426 })
5427 }),
5428 ];
5429
5430 let drain = spawn_committed_after_commit_callbacks(callbacks)
5431 .expect("non-empty callback list should spawn a drain task");
5432 tokio::task::yield_now().await;
5433
5434 assert_eq!(
5435 *order.lock().unwrap(),
5436 Vec::<u32>::new(),
5437 "later callbacks must wait for earlier callbacks to finish"
5438 );
5439
5440 release_first
5441 .send(())
5442 .expect("first callback receiver alive");
5443 drain.await.expect("drain task should not panic");
5444
5445 assert_eq!(*order.lock().unwrap(), vec![1, 2]);
5446 }
5447
5448 #[tokio::test]
5449 async fn production_after_commit_drain_isolates_panicking_callbacks() {
5450 let before = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
5451 let ran_later = Arc::new(AtomicU64::new(0));
5452 let later = ran_later.clone();
5453
5454 let callbacks: Vec<CommitCallback> = vec![
5455 Box::new(|| Box::pin(async { panic!("deliberate after_commit panic") })),
5456 Box::new(move || {
5457 Box::pin(async move {
5458 later.fetch_add(1, Ordering::SeqCst);
5459 Ok(())
5460 })
5461 }),
5462 ];
5463
5464 let drain = spawn_committed_after_commit_callbacks(callbacks)
5465 .expect("non-empty callback list should spawn a drain task");
5466 drain.await.expect("panicking callback should be isolated");
5467
5468 assert_eq!(
5469 ran_later.load(Ordering::SeqCst),
5470 1,
5471 "later callbacks must still run after an earlier callback panics"
5472 );
5473 let after = AFTER_COMMIT_FAILURES_TOTAL.load(Ordering::Relaxed);
5474 assert!(
5475 after > before,
5476 "panicking after_commit callbacks must increment the failure counter"
5477 );
5478 }
5479
5480 #[tokio::test]
5481 async fn db_tx_rejects_ambient_after_commit_registry() {
5482 let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
5483
5484 let err = AFTER_COMMIT_REGISTRY
5485 .scope(registry, async {
5486 reject_ambient_after_commit_registry_for_tx().expect_err(
5487 "starting Db::tx inside an ambient transaction registry should fail",
5488 )
5489 })
5490 .await;
5491
5492 assert!(
5493 err.to_string().contains("Nested Db::tx calls"),
5494 "unexpected nested transaction error: {err}"
5495 );
5496 }
5497
5498 #[tokio::test]
5499 async fn register_after_commit_callback_error_is_swallowed() {
5500 // A failing callback is logged but doesn't panic or propagate.
5501 let registry = Arc::new(std::sync::Mutex::new(Vec::<CommitCallback>::new()));
5502
5503 AFTER_COMMIT_REGISTRY
5504 .scope(registry.clone(), async {
5505 register_after_commit(|| async {
5506 Err(crate::AutumnError::internal_server_error_msg(
5507 "deliberate error",
5508 ))
5509 })
5510 .await;
5511 })
5512 .await;
5513
5514 let callbacks: Vec<CommitCallback> = {
5515 let mut reg = registry.lock().unwrap();
5516 std::mem::take(&mut *reg)
5517 };
5518 // Running a failing callback should not panic
5519 for cb in callbacks {
5520 let _ = cb().await;
5521 }
5522 }
5523
5524 // ── Pool provider trait tests ────────────────────────────────
5525
5526 /// No-op provider for tests — always returns `Ok(None)` regardless of the
5527 /// supplied config. Verifies the trait actually overrides the default
5528 /// (which would otherwise build a pool from the URL).
5529 struct NoOpPoolProvider;
5530
5531 impl DatabasePoolProvider for NoOpPoolProvider {
5532 async fn create_pool(
5533 &self,
5534 _config: &DatabaseConfig,
5535 ) -> Result<Option<Pool<crate::db::RuntimeConnection>>, PoolError> {
5536 Ok(None)
5537 }
5538 }
5539
5540 #[tokio::test]
5541 async fn pool_provider_trait_returns_supplied_pool() {
5542 // Even with a configured URL, the no-op provider returns None — proving
5543 // the trait can replace the default factory's behaviour.
5544 let config = DatabaseConfig {
5545 url: Some("postgres://localhost/ignored".to_owned()),
5546 ..Default::default()
5547 };
5548 let provider = NoOpPoolProvider;
5549 let pool = provider
5550 .create_pool(&config)
5551 .await
5552 .expect("no-op provider should succeed");
5553 assert!(
5554 pool.is_none(),
5555 "no-op provider must override default behaviour"
5556 );
5557 }
5558
5559 #[tokio::test]
5560 async fn default_pool_provider_matches_free_function() {
5561 let config = DatabaseConfig::default();
5562 let via_provider = DieselDeadpoolPoolProvider::new()
5563 .create_pool(&config)
5564 .await
5565 .expect("default provider should succeed");
5566 let via_function = create_pool(&config).expect("free fn should succeed");
5567 assert_eq!(via_provider.is_none(), via_function.is_none());
5568 }
5569
5570 // ── Backend-neutral test targets ─────────────────────────────
5571 //
5572 // The pool/topology mechanics below — max_size, the connect-timeout →
5573 // wait/create mapping, replica retention, the read-pool fallback — are
5574 // backend-independent, so they must hold on whichever backend
5575 // `RuntimeConnection` resolves to. `crate::test_urls::primary` spells the
5576 // target per backend; before it, an inline `postgres://…` made every one
5577 // of them panic in `build_sqlite_pool` under `--features sqlite`, unseen
5578 // because no CI lane ran the lib tests there.
5579 use crate::test_urls::primary as test_primary_url;
5580
5581 /// A replica target to pair with `primary`.
5582 ///
5583 /// Postgres-only, and deliberately so: `database_backend_consistency`
5584 /// refuses any `replica_url` alongside a `SQLite` primary, so every caller is
5585 /// `#[cfg(not(feature = "sqlite"))]` and there is no `SQLite` spelling to
5586 /// give. The `SQLite` side of the replica contract lives in the
5587 /// `create_topology_*_sqlite_replica` tests.
5588 #[cfg(not(feature = "sqlite"))]
5589 fn test_replica_url(_primary: &str, name: &str) -> String {
5590 format!("postgres://localhost/{name}")
5591 }
5592
5593 // ── Pool creation tests ──────────────────────────────────────
5594
5595 // `#[repository]`-generated code and `Db::checkout` already run `SET
5596 // statement_timeout` on every acquire (a round trip to Postgres). That
5597 // round trip already proves the connection is alive, so the pool must
5598 // not pay for a second one via deadpool's default `Verified` recycling
5599 // (issue #2485).
5600 #[cfg(not(feature = "sqlite"))]
5601 #[test]
5602 fn pg_manager_config_uses_fast_recycling_without_tls() {
5603 let config = pg_manager_config("postgres://user:pass@localhost/app");
5604 assert!(matches!(config.recycling_method, RecyclingMethod::Fast));
5605 }
5606
5607 #[cfg(not(feature = "sqlite"))]
5608 #[test]
5609 fn pg_manager_config_uses_fast_recycling_with_tls() {
5610 let config = pg_manager_config("postgres://user:pass@localhost/app?sslmode=require");
5611 assert!(matches!(config.recycling_method, RecyclingMethod::Fast));
5612 }
5613
5614 #[tokio::test]
5615 async fn default_pool_provider_respects_url_config() {
5616 let config = DatabaseConfig {
5617 url: Some(test_primary_url("test")),
5618 ..Default::default()
5619 };
5620 let provider = DieselDeadpoolPoolProvider::new();
5621 let pool = provider
5622 .create_pool(&config)
5623 .await
5624 .expect("default provider should succeed");
5625 assert!(
5626 pool.is_some(),
5627 "default provider should return Some when url is provided"
5628 );
5629 }
5630
5631 #[test]
5632 fn create_pool_with_no_url_returns_none() {
5633 let config = DatabaseConfig::default();
5634 let pool = create_pool(&config).expect("should not fail with no URL");
5635 assert!(pool.is_none());
5636 }
5637
5638 #[test]
5639 fn create_pool_with_url_returns_some() {
5640 let config = DatabaseConfig {
5641 url: Some(test_primary_url("test")),
5642 ..Default::default()
5643 };
5644 let pool = create_pool(&config).expect("should build pool from valid config");
5645 assert!(pool.is_some());
5646 }
5647
5648 // In the default (Postgres) build a SQLite target has no runtime pool, so
5649 // pool construction must refuse at boot with an actionable message pointing
5650 // at the `--features sqlite` build — never reaching a first-query failure or
5651 // panic. Under `--features sqlite` the same target instead builds a real
5652 // pool (see the `sqlite_boot_serve` integration test), so this refusal
5653 // contract only applies to the default build.
5654 #[cfg(not(feature = "sqlite"))]
5655 #[test]
5656 fn create_pool_with_sqlite_url_fails_fast() {
5657 let config = DatabaseConfig {
5658 url: Some("sqlite:///var/lib/app.db".into()),
5659 ..Default::default()
5660 };
5661 // The Ok variant (`Option<Pool>`) is not `Debug`, so match rather than
5662 // use `expect_err`.
5663 let Err(err) = create_pool(&config) else {
5664 panic!("sqlite target must refuse at pool build");
5665 };
5666 assert!(
5667 matches!(err, PoolError::UnsupportedBackend(_)),
5668 "expected UnsupportedBackend, got: {err:?}"
5669 );
5670 let msg = err.to_string();
5671 assert!(
5672 msg.contains("SQLite") && msg.contains("--features sqlite"),
5673 "message must be actionable and name the sqlite build, got: {msg}"
5674 );
5675 }
5676
5677 #[cfg(not(feature = "sqlite"))]
5678 #[test]
5679 fn create_topology_with_sqlite_url_fails_fast() {
5680 let config = DatabaseConfig {
5681 primary_url: Some("sqlite::memory:".into()),
5682 ..Default::default()
5683 };
5684 let Err(err) = create_topology(&config) else {
5685 panic!("sqlite target must refuse at topology build");
5686 };
5687 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
5688 }
5689
5690 // Under `--features sqlite` the same targets that refuse above instead build
5691 // a real pool/topology over `SyncConnectionWrapper<SqliteConnection>`.
5692 #[cfg(feature = "sqlite")]
5693 #[test]
5694 fn create_pool_with_sqlite_url_builds_under_feature() {
5695 let config = DatabaseConfig {
5696 primary_url: Some("sqlite::memory:".into()),
5697 ..Default::default()
5698 };
5699 let pool = create_pool(&config).expect("sqlite pool builds under the feature");
5700 assert!(pool.is_some(), "a configured sqlite url yields a pool");
5701 }
5702
5703 // A Postgres URL in a sqlite build is a misconfiguration and must refuse.
5704 #[cfg(feature = "sqlite")]
5705 #[test]
5706 fn create_pool_with_postgres_url_refuses_under_sqlite_feature() {
5707 let config = DatabaseConfig {
5708 url: Some("postgres://localhost/test".into()),
5709 ..Default::default()
5710 };
5711 let Err(err) = create_pool(&config) else {
5712 panic!("a Postgres url must refuse under the sqlite feature");
5713 };
5714 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
5715 }
5716
5717 // The end-to-end contract the helper exists for: no refusal this module
5718 // produces may carry a password into the boot log.
5719 #[cfg(feature = "sqlite")]
5720 #[test]
5721 fn pool_refusals_never_echo_a_password() {
5722 for url in [
5723 "postgres://user:hunter2@localhost/app",
5724 "mysql://user:hunter2@localhost/app",
5725 // The secret in the query string rather than the userinfo.
5726 "mysql://localhost/app?password=hunter2",
5727 "postgres://localhost/app?sslpassword=hunter2",
5728 // The libpq keyword/value form: no URL shape at all.
5729 "host=db user=app password=hunter2",
5730 // ...and the same, malformed, so neither parser accepts it.
5731 "host=db user=app password='hunter2",
5732 ] {
5733 let config = DatabaseConfig {
5734 url: Some(url.into()),
5735 ..Default::default()
5736 };
5737 let Err(err) = create_pool(&config) else {
5738 panic!("a non-sqlite target must refuse: {url}");
5739 };
5740 let msg = err.to_string();
5741 assert!(
5742 !msg.contains("hunter2"),
5743 "refusal leaked the password: {msg}"
5744 );
5745 assert!(msg.contains("****"), "refusal must show it masked: {msg}");
5746 }
5747 }
5748
5749 // A target that names NEITHER backend must refuse too, not be opened as a
5750 // file whose name happens to be that string. `normalize_sqlite_target`
5751 // strips only the `sqlite:`/`sqlite://` schemes and passes anything else
5752 // through verbatim, so before this guard a `mysql:app.db` URL — or a typo
5753 // like `sqllite:///app.db` — became a SQLite *filename*. Worse, every other
5754 // consumer of the URL (`autumn doctor`, the generator's DDL mapping,
5755 // `autumn migrate`) classifies it through `DatabaseBackend::detect`, which
5756 // returns `None` for these shapes: the tooling would report "no recognized
5757 // backend" while the runtime quietly served a database out of a junk file.
5758 #[cfg(feature = "sqlite")]
5759 #[test]
5760 fn create_pool_with_unrecognized_url_refuses_under_sqlite_feature() {
5761 for url in [
5762 "mysql://localhost/app",
5763 "mysql:app.db",
5764 // A near-miss typo of the sqlite scheme.
5765 "sqllite:///var/lib/app.db",
5766 // A bare filesystem path: deliberately NOT a recognized target (see
5767 // `DatabaseBackend`), so it must not silently become one here.
5768 "/var/lib/app.db",
5769 ] {
5770 let config = DatabaseConfig {
5771 url: Some(url.into()),
5772 ..Default::default()
5773 };
5774 let Err(err) = create_pool(&config) else {
5775 panic!("an unrecognized url must refuse under the sqlite feature: {url}");
5776 };
5777 assert!(
5778 matches!(err, PoolError::UnsupportedBackend(_)),
5779 "expected UnsupportedBackend for {url}, got: {err:?}"
5780 );
5781 let msg = err.to_string();
5782 assert!(
5783 msg.contains("sqlite:") && msg.contains(url),
5784 "message must name the offending target and the accepted scheme, got: {msg}"
5785 );
5786 }
5787 }
5788
5789 // …but the two scheme-less spellings `normalize_sqlite_target` explicitly
5790 // maps to `:memory:` are NOT "unrecognized": `DatabaseBackend::detect`
5791 // classifies by scheme and these carry none, yet the pool has always taken
5792 // them, `run_pending_sqlite` lists `:memory:` among its accepted spellings,
5793 // and `TestApp::with_transactional_db` hands its URL straight to
5794 // `create_pool`. Refusing them would be a regression, so the guard admits
5795 // them explicitly. Neither can become a stray file.
5796 #[cfg(feature = "sqlite")]
5797 #[test]
5798 fn create_pool_still_accepts_bare_in_memory_spellings() {
5799 for url in [":memory:", ""] {
5800 let config = DatabaseConfig {
5801 url: Some(url.into()),
5802 ..Default::default()
5803 };
5804 let pool = create_pool(&config)
5805 .unwrap_or_else(|e| panic!("bare in-memory target {url:?} must build: {e}"));
5806 assert!(
5807 pool.is_some(),
5808 "bare in-memory target {url:?} must yield a pool"
5809 );
5810 // Private in-memory: every connection is its own database, so the
5811 // pool must stay single-slot (same rule `build_sqlite_pool` applies
5812 // to the scheme-spelled forms).
5813 assert_eq!(
5814 pool.expect("pool").status().max_size,
5815 1,
5816 "bare in-memory target {url:?} must be single-slot"
5817 );
5818 }
5819 }
5820
5821 // A SQLite runtime has no primary/replica replication in this pool
5822 // architecture, so a separate replica pool would serve reads from an empty
5823 // database. `create_topology` must reject an in-memory or distinct-file
5824 // replica with an actionable boot error (addresses Codex P2).
5825 #[cfg(feature = "sqlite")]
5826 #[test]
5827 fn create_topology_rejects_in_memory_sqlite_replica() {
5828 let config = DatabaseConfig {
5829 primary_url: Some("sqlite:///var/lib/app.db".into()),
5830 replica_url: Some("sqlite::memory:".into()),
5831 ..Default::default()
5832 };
5833 let Err(err) = create_topology(&config) else {
5834 panic!("an in-memory sqlite replica must be rejected");
5835 };
5836 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
5837 let msg = err.to_string();
5838 assert!(
5839 msg.contains("separate read replica") && msg.contains("primary"),
5840 "message must be actionable: {msg}"
5841 );
5842 }
5843
5844 #[cfg(feature = "sqlite")]
5845 #[test]
5846 fn create_topology_rejects_distinct_file_sqlite_replica() {
5847 let config = DatabaseConfig {
5848 primary_url: Some("sqlite:///var/lib/primary.db".into()),
5849 replica_url: Some("sqlite:///var/lib/replica.db".into()),
5850 ..Default::default()
5851 };
5852 let Err(err) = create_topology(&config) else {
5853 panic!("a distinct-file sqlite replica must be rejected");
5854 };
5855 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
5856 }
5857
5858 // A replica that normalizes to the SAME file as the primary is the same
5859 // database — harmless — so it is allowed and the topology builds.
5860 #[cfg(feature = "sqlite")]
5861 #[test]
5862 fn create_topology_allows_same_file_sqlite_replica() {
5863 let config = DatabaseConfig {
5864 primary_url: Some("sqlite:///var/lib/app.db".into()),
5865 replica_url: Some("sqlite:///var/lib/app.db".into()),
5866 ..Default::default()
5867 };
5868 let topology = create_topology(&config)
5869 .expect("same-file sqlite replica must be allowed")
5870 .expect("a configured primary yields a topology");
5871 assert!(
5872 topology.replica().is_some(),
5873 "same-file replica pool should be built"
5874 );
5875 }
5876
5877 // The no-replica happy path still builds a topology fine under the feature.
5878 #[cfg(feature = "sqlite")]
5879 #[test]
5880 fn create_topology_sqlite_primary_only_builds() {
5881 let config = DatabaseConfig {
5882 primary_url: Some("sqlite::memory:".into()),
5883 ..Default::default()
5884 };
5885 let topology = create_topology(&config)
5886 .expect("sqlite primary-only topology builds")
5887 .expect("a configured primary yields a topology");
5888 assert!(topology.replica().is_none(), "no replica configured");
5889 }
5890
5891 // A custom provider that overrides ONLY `create_pool` still has its replica
5892 // built by the trait DEFAULT `create_topology`. That default path must apply
5893 // the same SQLite-replica rule as the free `create_topology` /
5894 // `create_shard_topology`, or a provider could boot a distinct/in-memory
5895 // replica and route reads to an unrelated, empty database (addresses Codex
5896 // P2). This minimal provider delegates `create_pool` to the default factory
5897 // and leaves `create_topology` as the trait default — exactly the path under
5898 // test.
5899 #[cfg(feature = "sqlite")]
5900 struct PrimaryOnlyProvider;
5901
5902 #[cfg(feature = "sqlite")]
5903 impl DatabasePoolProvider for PrimaryOnlyProvider {
5904 async fn create_pool(
5905 &self,
5906 config: &DatabaseConfig,
5907 ) -> Result<Option<Pool<RuntimeConnection>>, PoolError> {
5908 create_pool(config)
5909 }
5910 }
5911
5912 #[cfg(feature = "sqlite")]
5913 #[tokio::test]
5914 async fn default_provider_topology_rejects_distinct_file_sqlite_replica() {
5915 let config = DatabaseConfig {
5916 primary_url: Some("sqlite:///var/lib/primary.db".into()),
5917 replica_url: Some("sqlite:///var/lib/replica.db".into()),
5918 ..Default::default()
5919 };
5920 let Err(err) = PrimaryOnlyProvider.create_topology(&config).await else {
5921 panic!("the default-topology path must reject a distinct-file sqlite replica");
5922 };
5923 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
5924 let msg = err.to_string();
5925 assert!(
5926 msg.contains("separate read replica") && msg.contains("primary"),
5927 "message must be actionable: {msg}"
5928 );
5929 }
5930
5931 #[cfg(feature = "sqlite")]
5932 #[tokio::test]
5933 async fn default_provider_topology_rejects_in_memory_sqlite_replica() {
5934 let config = DatabaseConfig {
5935 primary_url: Some("sqlite:///var/lib/app.db".into()),
5936 replica_url: Some("sqlite::memory:".into()),
5937 ..Default::default()
5938 };
5939 let Err(err) = PrimaryOnlyProvider.create_topology(&config).await else {
5940 panic!("an in-memory sqlite replica must be rejected by the default topology");
5941 };
5942 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
5943 }
5944
5945 // A same-file replica is the same database (harmless) and still builds
5946 // through the default topology; a primary-only config builds with no replica.
5947 #[cfg(feature = "sqlite")]
5948 #[tokio::test]
5949 async fn default_provider_topology_allows_same_file_and_primary_only() {
5950 let same_file = DatabaseConfig {
5951 primary_url: Some("sqlite:///var/lib/app.db".into()),
5952 replica_url: Some("sqlite:///var/lib/app.db".into()),
5953 ..Default::default()
5954 };
5955 let topology = PrimaryOnlyProvider
5956 .create_topology(&same_file)
5957 .await
5958 .expect("same-file replica must be allowed by the default topology")
5959 .expect("a configured primary yields a topology");
5960 assert!(
5961 topology.replica().is_some(),
5962 "same-file replica pool should be built"
5963 );
5964
5965 let primary_only = DatabaseConfig {
5966 primary_url: Some("sqlite::memory:".into()),
5967 ..Default::default()
5968 };
5969 let topology = PrimaryOnlyProvider
5970 .create_topology(&primary_only)
5971 .await
5972 .expect("primary-only default topology builds")
5973 .expect("a configured primary yields a topology");
5974 assert!(topology.replica().is_none(), "no replica configured");
5975 }
5976
5977 // The per-shard replica is subject to the SAME SQLite-replica rule as the
5978 // control database (addresses Codex P2): an in-memory or distinct-file shard
5979 // replica would route reads to an unrelated, empty database, so it must be
5980 // rejected; a shard with only a primary (or a same-file replica) must build.
5981 #[cfg(feature = "sqlite")]
5982 #[test]
5983 fn create_shard_topology_rejects_in_memory_sqlite_replica() {
5984 let defaults = DatabaseConfig::default();
5985 let shard = crate::config::ShardConfig {
5986 name: "shard0".into(),
5987 primary_url: "sqlite:///var/lib/shard0.db".into(),
5988 replica_url: Some("sqlite::memory:".into()),
5989 ..Default::default()
5990 };
5991 let Err(err) = create_shard_topology(&shard, &defaults) else {
5992 panic!("an in-memory sqlite shard replica must be rejected");
5993 };
5994 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
5995 let msg = err.to_string();
5996 assert!(
5997 msg.contains("separate read replica") && msg.contains("primary"),
5998 "message must be actionable: {msg}"
5999 );
6000 }
6001
6002 #[cfg(feature = "sqlite")]
6003 #[test]
6004 fn create_shard_topology_rejects_distinct_file_sqlite_replica() {
6005 let defaults = DatabaseConfig::default();
6006 let shard = crate::config::ShardConfig {
6007 name: "shard0".into(),
6008 primary_url: "sqlite:///var/lib/shard0-primary.db".into(),
6009 replica_url: Some("sqlite:///var/lib/shard0-replica.db".into()),
6010 ..Default::default()
6011 };
6012 let Err(err) = create_shard_topology(&shard, &defaults) else {
6013 panic!("a distinct-file sqlite shard replica must be rejected");
6014 };
6015 assert!(matches!(err, PoolError::UnsupportedBackend(_)), "{err:?}");
6016 }
6017
6018 #[cfg(feature = "sqlite")]
6019 #[test]
6020 fn create_shard_topology_allows_same_file_sqlite_replica() {
6021 let defaults = DatabaseConfig::default();
6022 let shard = crate::config::ShardConfig {
6023 name: "shard0".into(),
6024 primary_url: "sqlite:///var/lib/shard0.db".into(),
6025 replica_url: Some("sqlite:///var/lib/shard0.db".into()),
6026 ..Default::default()
6027 };
6028 let topology = create_shard_topology(&shard, &defaults)
6029 .expect("same-file sqlite shard replica must be allowed");
6030 assert!(
6031 topology.replica().is_some(),
6032 "same-file shard replica pool should be built"
6033 );
6034 }
6035
6036 #[cfg(feature = "sqlite")]
6037 #[test]
6038 fn create_shard_topology_sqlite_primary_only_builds() {
6039 let defaults = DatabaseConfig::default();
6040 let shard = crate::config::ShardConfig {
6041 name: "shard0".into(),
6042 primary_url: "sqlite::memory:".into(),
6043 ..Default::default()
6044 };
6045 let topology = create_shard_topology(&shard, &defaults)
6046 .expect("sqlite primary-only shard topology builds");
6047 assert!(topology.replica().is_none(), "no shard replica configured");
6048 }
6049
6050 // `file::memory:` (and its query-string form) is a private in-memory target
6051 // and must be forced single-slot; a shared-cache in-memory DB is shareable
6052 // and must NOT be (addresses Codex P1).
6053 #[cfg(feature = "sqlite")]
6054 #[test]
6055 fn sqlite_target_is_memory_covers_file_memory() {
6056 assert!(sqlite_target_is_memory(":memory:"));
6057 assert!(sqlite_target_is_memory("file::memory:"));
6058 assert!(sqlite_target_is_memory("file::memory:?foo=bar"));
6059 assert!(sqlite_target_is_memory("file:app?mode=memory"));
6060 // Shared-cache in-memory databases are shareable across connections.
6061 assert!(!sqlite_target_is_memory("file::memory:?cache=shared"));
6062 assert!(!sqlite_target_is_memory(
6063 "file:app?mode=memory&cache=shared"
6064 ));
6065 // Plain file targets are not in-memory.
6066 assert!(!sqlite_target_is_memory("/var/lib/app.db"));
6067 }
6068
6069 // `sqlite_target_is_shared_cache` is the predicate that scopes the
6070 // `statement_timeout` rejection message (issue #2881): it must catch every
6071 // `cache=shared` spelling, including a shared-cache file target, and must
6072 // not fire for private in-memory or plain file targets.
6073 #[cfg(feature = "sqlite")]
6074 #[test]
6075 fn sqlite_target_is_shared_cache_covers_query_spellings() {
6076 // Shared-cache in-memory spellings.
6077 assert!(sqlite_target_is_shared_cache("file::memory:?cache=shared"));
6078 assert!(sqlite_target_is_shared_cache(
6079 "file:app?mode=memory&cache=shared"
6080 ));
6081 // Shared-cache also applies to file-backed targets.
6082 assert!(sqlite_target_is_shared_cache(
6083 "file:/srv/ref.db?cache=shared&mode=ro"
6084 ));
6085 // Private in-memory targets are NOT shared-cache.
6086 assert!(!sqlite_target_is_shared_cache(":memory:"));
6087 assert!(!sqlite_target_is_shared_cache("file::memory:"));
6088 assert!(!sqlite_target_is_shared_cache("file::memory:?foo=bar"));
6089 assert!(!sqlite_target_is_shared_cache("file:app?mode=memory"));
6090 // Plain file targets are NOT shared-cache.
6091 assert!(!sqlite_target_is_shared_cache("file:/srv/ref.db"));
6092 assert!(!sqlite_target_is_shared_cache("/var/lib/app.db"));
6093 // Text that merely contains `cache=shared` outside an exact query
6094 // pair is NOT shared-cache.
6095 assert!(!sqlite_target_is_shared_cache("/srv/cache=shared.db"));
6096 assert!(!sqlite_target_is_shared_cache("/var/lib/cache=shared.db"));
6097 assert!(!sqlite_target_is_shared_cache(
6098 "file:/srv/app.db?note=cache=shared"
6099 ));
6100 assert!(!sqlite_target_is_shared_cache("file:app?cache=sharedly"));
6101 // A URI fragment is not part of the query (SQLite ignores it).
6102 assert!(sqlite_target_is_shared_cache(
6103 "file:mem?mode=memory&cache=shared#tag"
6104 ));
6105 // Percent-encoded names and values decode as SQLite decodes them.
6106 assert!(sqlite_target_is_shared_cache("file:app.db?%63ache=shared"));
6107 assert!(sqlite_target_is_shared_cache("file:app.db?cache=%73hared"));
6108 // A raw configured URL is normalized first.
6109 assert!(sqlite_target_is_shared_cache(
6110 "sqlite:file:app?mode=memory&cache=shared"
6111 ));
6112 // Only a `file:` URI has query parameters; elsewhere `?` is part of
6113 // an ordinary filename.
6114 assert!(!sqlite_target_is_shared_cache("app.db?cache=shared"));
6115 assert!(!sqlite_target_is_shared_cache("sqlite:app.db?cache=shared"));
6116 // A repeated parameter takes its last value.
6117 assert!(!sqlite_target_is_shared_cache(
6118 "file:app.db?cache=shared&cache=private"
6119 ));
6120 assert!(sqlite_target_is_shared_cache(
6121 "file:app.db?cache=private&cache=shared"
6122 ));
6123 }
6124
6125 // Issue #3032. A decoded NUL (`%00`) ends only the name or value being
6126 // read. SQLite keeps parsing the next pair. Checked with Python's
6127 // `sqlite3` (SQLite 3.45).
6128 #[cfg(feature = "sqlite")]
6129 #[test]
6130 fn percent_decode_stops_at_decoded_nul() {
6131 assert_eq!(percent_decode("a%00b"), b"a");
6132 assert_eq!(percent_decode("%00"), b"");
6133 assert_eq!(percent_decode("a%0"), b"a%0");
6134 assert_eq!(percent_decode("%53hared"), b"Shared");
6135 }
6136
6137 #[cfg(feature = "sqlite")]
6138 #[test]
6139 fn sqlite_shared_cache_nul_ends_only_current_component() {
6140 // NUL in a value: the text after it is dropped.
6141 assert!(sqlite_target_is_shared_cache("file:a?cache=shared%00"));
6142 assert!(sqlite_target_is_shared_cache("file:a?cache=shared%00x"));
6143 assert!(!sqlite_target_is_shared_cache("file:a?cache=shar%00ed"));
6144 // NUL in the last value: the last value still wins.
6145 assert!(!sqlite_target_is_shared_cache(
6146 "file::memory:?cache=shared&cache=private%00"
6147 ));
6148 assert!(sqlite_target_is_shared_cache(
6149 "file:a?cache=private%00&cache=shared"
6150 ));
6151 assert!(!sqlite_target_is_shared_cache(
6152 "file:a?cache=shared%00&cache=private"
6153 ));
6154 // NUL at the start of a name: the name is empty, so the pair is no
6155 // `cache` pair.
6156 assert!(sqlite_target_is_shared_cache(
6157 "file:a?cache=shared&%00cache=private"
6158 ));
6159 // NUL inside a name: the name is cut there.
6160 assert!(!sqlite_target_is_shared_cache("file:a?ca%00che=shared"));
6161 assert!(sqlite_target_is_shared_cache("file:a?cache%00x=shared"));
6162 assert!(!sqlite_target_is_shared_cache("file:a?cache=a%00b=shared"));
6163 // Text after a NUL cannot start a new pair.
6164 assert!(sqlite_target_is_shared_cache(
6165 "file:a?cache=shared%00x=y&cache=shared"
6166 ));
6167 // NUL in the path does not stop the query.
6168 assert!(sqlite_target_is_shared_cache("file:a%00?cache=shared"));
6169 }
6170
6171 #[cfg(feature = "sqlite")]
6172 #[test]
6173 fn reject_sqlite_unsupported_timeouts_checks_both_settings() {
6174 let idle_only = DatabaseConfig {
6175 idle_in_transaction_timeout: Some(Duration::from_secs(60)),
6176 ..Default::default()
6177 };
6178 assert!(reject_sqlite_unsupported_timeouts(&idle_only, "file:/srv/app.db").is_err());
6179 let statement_only = DatabaseConfig {
6180 statement_timeout: Some(Duration::from_secs(30)),
6181 ..Default::default()
6182 };
6183 assert!(reject_sqlite_unsupported_timeouts(&statement_only, "file:/srv/app.db").is_err());
6184 assert!(
6185 reject_sqlite_unsupported_timeouts(&DatabaseConfig::default(), "file:/srv/app.db")
6186 .is_ok()
6187 );
6188 }
6189
6190 #[cfg(feature = "sqlite")]
6191 #[test]
6192 fn create_shard_topology_refuses_sqlite_idle_timeout() {
6193 let shard = crate::config::ShardConfig {
6194 name: "s0".to_owned(),
6195 primary_url: ":memory:".to_owned(),
6196 ..Default::default()
6197 };
6198 let defaults = DatabaseConfig {
6199 idle_in_transaction_timeout: Some(Duration::from_secs(60)),
6200 ..Default::default()
6201 };
6202 match create_shard_topology(&shard, &defaults) {
6203 Err(PoolError::UnsupportedBackend(message)) => {
6204 assert!(message.contains("idle_in_transaction_timeout"), "{message}");
6205 }
6206 Err(other) => panic!("expected UnsupportedBackend, got {other:?}"),
6207 Ok(_) => panic!("expected UnsupportedBackend, got a topology"),
6208 }
6209 }
6210
6211 #[cfg(feature = "sqlite")]
6212 #[test]
6213 fn reject_sqlite_idle_in_transaction_timeout_refuses_a_nonzero_value() {
6214 let err = reject_sqlite_idle_in_transaction_timeout(Some(Duration::from_secs(60)))
6215 .expect_err("SQLite cannot enforce it");
6216 match err {
6217 PoolError::UnsupportedBackend(message) => {
6218 assert!(message.contains("idle_in_transaction_timeout"), "{message}");
6219 }
6220 other => panic!("expected UnsupportedBackend, got {other:?}"),
6221 }
6222 assert!(reject_sqlite_idle_in_transaction_timeout(None).is_ok());
6223 assert!(reject_sqlite_idle_in_transaction_timeout(Some(Duration::ZERO)).is_ok());
6224 }
6225
6226 // The `statement_timeout` rejection must not claim `busy_timeout` bounds
6227 // lock waits for `cache=shared` targets (issue #2881): shared-cache
6228 // table-lock conflicts return `SQLITE_LOCKED` without invoking the busy
6229 // handler at all, so the claim is false there and the message says so.
6230 #[cfg(feature = "sqlite")]
6231 #[test]
6232 fn reject_sqlite_statement_timeout_scopes_lock_wait_claim() {
6233 let timeout = Some(std::time::Duration::from_millis(100));
6234
6235 let shared = reject_sqlite_statement_timeout(timeout, "file:app?mode=memory&cache=shared");
6236 let message = match shared {
6237 Err(PoolError::UnsupportedBackend(message)) => message,
6238 other => panic!("expected UnsupportedBackend, got {other:?}"),
6239 };
6240 assert!(
6241 message.contains("does NOT bound"),
6242 "shared-cache message must disclaim the busy_timeout bound, got: {message}"
6243 );
6244 assert!(
6245 message.contains("SQLITE_LOCKED"),
6246 "shared-cache message must name the failure mode, got: {message}"
6247 );
6248 assert!(
6249 message.contains("#2881"),
6250 "shared-cache message must point at the issue, got: {message}"
6251 );
6252 assert!(
6253 !message.contains("already bounds lock waits"),
6254 "shared-cache message must not make the unqualified claim, got: {message}"
6255 );
6256
6257 let plain = reject_sqlite_statement_timeout(timeout, "file:/srv/app.db");
6258 let message = match plain {
6259 Err(PoolError::UnsupportedBackend(message)) => message,
6260 other => panic!("expected UnsupportedBackend, got {other:?}"),
6261 };
6262 assert!(
6263 message.contains("(`busy_timeout` already bounds lock waits)"),
6264 "non-shared-cache message keeps the original claim, got: {message}"
6265 );
6266
6267 // No configured timeout asks for no guarantee and boots cleanly on
6268 // either target shape.
6269 assert!(reject_sqlite_statement_timeout(None, "file:app?mode=memory&cache=shared").is_ok());
6270 assert!(reject_sqlite_statement_timeout(None, "file:/srv/app.db").is_ok());
6271 }
6272
6273 // `sqlite_target_is_any_in_memory` is the broader predicate the
6274 // startup-migration reject uses: it classifies EVERY in-memory spelling —
6275 // private AND shared-cache — as in-memory, because none of them survive the
6276 // transient migration connection closing to reach the runtime pool (issue
6277 // #1614 follow-up). It must diverge from `sqlite_target_is_memory` (pool
6278 // sizing) precisely on `cache=shared`, which sizing keeps NOT-in-memory so a
6279 // shared-cache DB is never forced single-slot.
6280 #[cfg(feature = "sqlite")]
6281 #[test]
6282 fn sqlite_target_is_any_in_memory_covers_shared_cache() {
6283 // Private in-memory spellings — in-memory for BOTH predicates.
6284 assert!(sqlite_target_is_any_in_memory(":memory:"));
6285 assert!(sqlite_target_is_any_in_memory("sqlite::memory:"));
6286 assert!(sqlite_target_is_any_in_memory("sqlite://:memory:"));
6287 assert!(sqlite_target_is_any_in_memory("sqlite://"));
6288 assert!(sqlite_target_is_any_in_memory("file::memory:"));
6289 assert!(sqlite_target_is_any_in_memory("file::memory:?foo=bar"));
6290 // Shared-cache in-memory: `any_in_memory` → true (rejected for
6291 // migrations), but pool sizing's `sqlite_target_is_memory` → false (NOT
6292 // forced single-slot). This divergence is the whole point.
6293 assert!(sqlite_target_is_any_in_memory("file::memory:?cache=shared"));
6294 assert!(sqlite_target_is_any_in_memory(
6295 "file:app?mode=memory&cache=shared"
6296 ));
6297 assert!(!sqlite_target_is_memory("file::memory:?cache=shared"));
6298 assert!(!sqlite_target_is_memory(
6299 "file:app?mode=memory&cache=shared"
6300 ));
6301 // File-backed targets are never in-memory under either predicate.
6302 assert!(!sqlite_target_is_any_in_memory("sqlite:///var/lib/app.db"));
6303 assert!(!sqlite_target_is_any_in_memory("/var/lib/app.db"));
6304 assert!(!sqlite_target_is_any_in_memory("file:/var/lib/app.db"));
6305 }
6306
6307 // The transient SQLite migration connection must carry `PRAGMA busy_timeout`
6308 // (mirroring the runtime pool's per-connection setup) so a concurrent
6309 // migrator or a briefly-held write lock WAITS instead of failing immediately
6310 // with SQLITE_BUSY and aborting `auto_migrate_sqlite` (Codex P1). Proof: a
6311 // fresh migration connection reports the configured non-zero timeout.
6312 #[cfg(feature = "sqlite")]
6313 #[test]
6314 fn sqlite_migration_connection_sets_busy_timeout() {
6315 use diesel::RunQueryDsl as _;
6316
6317 #[derive(diesel::QueryableByName)]
6318 struct BusyTimeout {
6319 #[diesel(sql_type = diesel::sql_types::Integer)]
6320 timeout: i32,
6321 }
6322
6323 // A tempfile-backed target (not `:memory:`) so the connection maps to a
6324 // real database the pragma read reflects.
6325 let tmp = tempfile::TempDir::new().expect("temp dir");
6326 let db_path = tmp.path().join("migration.db");
6327 let url = format!("sqlite://{}", db_path.display());
6328
6329 let mut conn = super::establish_sqlite_migration_connection(&url)
6330 .expect("establish sqlite migration connection");
6331 let rows: Vec<BusyTimeout> = diesel::sql_query("PRAGMA busy_timeout")
6332 .load(&mut conn)
6333 .expect("read busy_timeout pragma");
6334 let timeout = rows
6335 .into_iter()
6336 .next()
6337 .expect("busy_timeout pragma returns a row")
6338 .timeout;
6339 assert_eq!(
6340 timeout, 5000,
6341 "the migration connection must carry the configured busy_timeout (ms) \
6342 so concurrent/locked migrators wait rather than hitting SQLITE_BUSY"
6343 );
6344 }
6345
6346 // A read-only URI target (`mode=ro` / `immutable`) must be detected so the
6347 // per-connection setup batch skips the write-affecting `journal_mode = WAL`
6348 // pragma, which otherwise fails with "attempt to write a readonly database"
6349 // and 503s the whole pool (Codex P2).
6350 #[cfg(feature = "sqlite")]
6351 #[test]
6352 fn sqlite_target_is_read_only_detects_ro_and_immutable() {
6353 assert!(sqlite_target_is_read_only("file:/srv/ref.db?mode=ro"));
6354 // Case-insensitive key/value.
6355 assert!(sqlite_target_is_read_only("file:/srv/ref.db?MODE=RO"));
6356 assert!(sqlite_target_is_read_only("file:/srv/ref.db?immutable=1"));
6357 assert!(sqlite_target_is_read_only(
6358 "file:/srv/ref.db?immutable=true"
6359 ));
6360 assert!(sqlite_target_is_read_only(
6361 "file:/srv/ref.db?immutable=TRUE"
6362 ));
6363 // Read-only marker alongside other params.
6364 assert!(sqlite_target_is_read_only(
6365 "file:/srv/ref.db?cache=shared&mode=ro"
6366 ));
6367
6368 // A plain file, an in-memory target, and a writable/shared-cache target
6369 // are NOT read-only.
6370 assert!(!sqlite_target_is_read_only("/var/lib/app.db"));
6371 assert!(!sqlite_target_is_read_only(":memory:"));
6372 assert!(!sqlite_target_is_read_only("file::memory:?cache=shared"));
6373 assert!(!sqlite_target_is_read_only("file:app?mode=memory"));
6374 assert!(!sqlite_target_is_read_only("file:/srv/ref.db?mode=rwc"));
6375 assert!(!sqlite_target_is_read_only("file:/srv/ref.db?immutable=0"));
6376 // A value that merely CONTAINS `ro` must not trip the substring trap.
6377 assert!(!sqlite_target_is_read_only("file:/srv/ro-data.db"));
6378 }
6379
6380 #[cfg(feature = "sqlite")]
6381 #[test]
6382 fn file_memory_sqlite_pool_is_single_slot() {
6383 let config = DatabaseConfig {
6384 primary_url: Some("file::memory:".into()),
6385 pool_size: 5,
6386 ..Default::default()
6387 };
6388 let pool = create_pool(&config)
6389 .expect("file::memory: sqlite pool builds")
6390 .expect("a configured url yields a pool");
6391 assert_eq!(
6392 pool.status().max_size,
6393 1,
6394 "a private in-memory target must be forced single-slot"
6395 );
6396 }
6397
6398 #[cfg(feature = "sqlite")]
6399 #[test]
6400 fn shared_cache_in_memory_sqlite_pool_is_not_forced_single_slot() {
6401 let config = DatabaseConfig {
6402 primary_url: Some("file::memory:?cache=shared".into()),
6403 pool_size: 5,
6404 ..Default::default()
6405 };
6406 let pool = create_pool(&config)
6407 .expect("shared-cache in-memory sqlite pool builds")
6408 .expect("a configured url yields a pool");
6409 assert_eq!(
6410 pool.status().max_size,
6411 5,
6412 "a shared-cache in-memory target must respect the configured size"
6413 );
6414 }
6415
6416 // The runtime landed (#1905); the tree described it as pending for another
6417 // release. Each phrase below is one a reader takes as the current contract,
6418 // and each was false under `--features sqlite`. This is a ratchet, not a
6419 // style rule: a "planned"/"not yet wired" claim about a SUBSYSTEM (sessions
6420 // #1908, jobs #1907, backup #1909) is still accurate and is not matched.
6421 #[test]
6422 fn no_source_doc_still_calls_the_sqlite_runtime_unwired() {
6423 let sources = [
6424 ("autumn/src/db.rs", include_str!("db.rs")),
6425 ("autumn/src/config.rs", include_str!("config.rs")),
6426 ("autumn/Cargo.toml", include_str!("../Cargo.toml")),
6427 (
6428 "docs/guide/sqlite-in-production.md",
6429 include_str!("../../docs/guide/sqlite-in-production.md"),
6430 ),
6431 ];
6432 // Split, because this file is one of the files scanned: a phrase
6433 // written whole here would be a finding against itself.
6434 let stale = [
6435 ["until the pool", "rework lands"].join(" "),
6436 ["the runtime pool", "is not yet wired"].join(" "),
6437 ["recognized-but", "not-yet-wired"].join("-"),
6438 ["PR1 is", "a no-op refactor"].join(" "),
6439 ["Update (this", "release)"].join(" "),
6440 ];
6441 for (name, text) in sources {
6442 for phrase in &stale {
6443 assert!(
6444 !text.contains(phrase.as_str()),
6445 "{name} still says {phrase:?}: the SQLite runtime is wired"
6446 );
6447 }
6448 }
6449 }
6450
6451 #[cfg(feature = "sqlite")]
6452 #[test]
6453 fn normalize_sqlite_target_strips_schemes() {
6454 assert_eq!(normalize_sqlite_target("sqlite::memory:"), ":memory:");
6455 assert_eq!(normalize_sqlite_target("sqlite://:memory:"), ":memory:");
6456 assert_eq!(normalize_sqlite_target("sqlite://"), ":memory:");
6457 assert_eq!(
6458 normalize_sqlite_target("sqlite:///var/lib/app.db"),
6459 "/var/lib/app.db"
6460 );
6461 assert_eq!(normalize_sqlite_target("sqlite:app.db"), "app.db");
6462 assert_eq!(
6463 normalize_sqlite_target("file:/var/lib/app.db"),
6464 "file:/var/lib/app.db"
6465 );
6466 }
6467
6468 #[test]
6469 fn pool_respects_max_size() {
6470 let config = DatabaseConfig {
6471 url: Some(test_primary_url("test")),
6472 pool_size: 5,
6473 ..Default::default()
6474 };
6475 let pool = create_pool(&config)
6476 .expect("should build pool")
6477 .expect("should be Some");
6478 assert_eq!(pool.status().max_size, 5);
6479 }
6480
6481 #[test]
6482 fn pool_clamps_size_to_one_if_zero() {
6483 let config = DatabaseConfig {
6484 url: Some(test_primary_url("test")),
6485 pool_size: 0,
6486 ..Default::default()
6487 };
6488 let pool = create_pool(&config)
6489 .expect("should build pool")
6490 .expect("should be Some");
6491 assert_eq!(
6492 pool.status().max_size,
6493 1,
6494 "Pool size should be clamped to 1"
6495 );
6496 }
6497
6498 // ── Db extractor tests ───────────────────────────────────────
6499
6500 // Postgres-only: `database_backend_consistency` refuses ANY `replica_url`
6501 // alongside a SQLite primary ("read replicas require the postgres
6502 // backend"), so a two-pool topology is unreachable under the backend flip.
6503 // Running this under `sqlite` would only assert pool mechanics for a
6504 // configuration no validated deployment can have; the SQLite side of the
6505 // replica contract is asserted by `create_topology_rejects_*_sqlite_replica`
6506 // and `create_topology_allows_same_file_sqlite_replica` instead.
6507 #[cfg(not(feature = "sqlite"))]
6508 #[test]
6509 fn database_topology_builds_primary_and_replica_pools() {
6510 let primary = test_primary_url("primary");
6511 let config = DatabaseConfig {
6512 primary_url: Some(primary.clone()),
6513 replica_url: Some(test_replica_url(&primary, "replica")),
6514 primary_pool_size: Some(6),
6515 replica_pool_size: Some(2),
6516 ..Default::default()
6517 };
6518
6519 let topology = create_topology(&config)
6520 .expect("topology should build")
6521 .expect("topology should be configured");
6522
6523 assert_eq!(topology.primary().status().max_size, 6);
6524 assert_eq!(
6525 topology.replica().expect("replica pool").status().max_size,
6526 2
6527 );
6528 assert_eq!(topology.read().status().max_size, 2);
6529 }
6530
6531 #[test]
6532 fn database_topology_single_url_builds_only_primary_pool() {
6533 let config = DatabaseConfig {
6534 url: Some(test_primary_url("single")),
6535 pool_size: 5,
6536 ..Default::default()
6537 };
6538
6539 let topology = create_topology(&config)
6540 .expect("topology should build")
6541 .expect("topology should be configured");
6542
6543 assert_eq!(topology.primary().status().max_size, 5);
6544 assert!(topology.replica().is_none());
6545 assert_eq!(topology.read().status().max_size, 5);
6546 }
6547
6548 #[test]
6549 fn config_runtime_drift_pool_applies_connect_timeout_to_wait_and_create() {
6550 let config = DatabaseConfig {
6551 url: Some(test_primary_url("test")),
6552 connect_timeout_secs: 7,
6553 ..Default::default()
6554 };
6555 let pool = create_pool(&config)
6556 .expect("should build pool")
6557 .expect("should be Some");
6558
6559 let timeouts = pool.timeouts();
6560 assert_eq!(timeouts.wait, Some(Duration::from_secs(7)));
6561 assert_eq!(timeouts.create, Some(Duration::from_secs(7)));
6562 }
6563
6564 #[derive(Clone)]
6565 struct TestDbState;
6566
6567 impl DbState for TestDbState {
6568 fn pool(&self) -> Option<&Pool<crate::db::RuntimeConnection>> {
6569 None
6570 }
6571 }
6572
6573 #[derive(Clone)]
6574 struct TestReadState {
6575 primary: Pool<crate::db::RuntimeConnection>,
6576 }
6577
6578 impl DbState for TestReadState {
6579 fn pool(&self) -> Option<&Pool<crate::db::RuntimeConnection>> {
6580 Some(&self.primary)
6581 }
6582 }
6583
6584 #[test]
6585 fn database_topology_read_pool_falls_back_to_primary() {
6586 let config = DatabaseConfig {
6587 url: Some(test_primary_url("read-fallback")),
6588 pool_size: 3,
6589 ..Default::default()
6590 };
6591 let primary = create_pool(&config).unwrap().unwrap();
6592 let state = TestReadState { primary };
6593
6594 assert_eq!(state.read_pool().expect("read pool").status().max_size, 3);
6595 }
6596
6597 #[tokio::test]
6598 async fn db_extractor_rejects_when_no_pool() {
6599 use axum::Router;
6600 use axum::body::Body;
6601 use axum::http::{Request, StatusCode};
6602 use axum::routing::get;
6603 use tower::ServiceExt;
6604
6605 async fn handler(_db: Db) -> &'static str {
6606 "ok"
6607 }
6608
6609 let app = Router::new()
6610 .route("/", get(handler))
6611 .with_state(TestDbState);
6612
6613 let response = app
6614 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
6615 .await
6616 .unwrap();
6617
6618 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
6619 }
6620
6621 /// The per-route DB metrics key is `"METHOD /matched/path"` byte for byte,
6622 /// and a matched route keeps axum's shared `MatchedPath` instead of a
6623 /// `String` composed per extraction. An unmatched request keys by raw path.
6624 #[tokio::test]
6625 async fn request_db_context_keys_by_method_and_matched_path() {
6626 use axum::Router;
6627 use axum::body::Body;
6628 use axum::http::Request;
6629 use axum::routing::{any, get};
6630 use tower::ServiceExt;
6631
6632 /// Answers with the context's route key, then the metric key `Db`'s
6633 /// drop records under it (composed on the stack, as `Drop` does).
6634 async fn handler(parts: axum::http::request::Parts) -> String {
6635 let ctx = RequestDbContext::from_parts(&parts, &TestDbState);
6636 assert!(
6637 matches!(ctx.route_key, Some(RouteKey::Matched { .. })),
6638 "a matched route must not compose a String per extraction"
6639 );
6640 let route_key = ctx
6641 .route_key
6642 .as_ref()
6643 .map(ToString::to_string)
6644 .unwrap_or_default();
6645 let metrics = crate::middleware::MetricsCollector::new();
6646 if let Some(key) = &ctx.route_key {
6647 metrics.record_db_query_fmt(format_args!("{key} SELECT"), 1);
6648 }
6649 let metric_key = metrics
6650 .snapshot()
6651 .db_queries
6652 .into_keys()
6653 .next()
6654 .unwrap_or_default();
6655 format!("{route_key}\n{metric_key}")
6656 }
6657
6658 async fn body_of(app: Router, request: Request<Body>) -> String {
6659 let response = app.oneshot(request).await.unwrap();
6660 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
6661 .await
6662 .unwrap();
6663 String::from_utf8(body.to_vec()).unwrap()
6664 }
6665
6666 let app = Router::new()
6667 .route("/users/{id}", get(handler))
6668 .route("/dav/{*rest}", any(handler));
6669
6670 let request = Request::builder()
6671 .uri("/users/7")
6672 .body(Body::empty())
6673 .unwrap();
6674 assert_eq!(
6675 body_of(app.clone(), request).await,
6676 "GET /users/{id}\nGET /users/{id} SELECT"
6677 );
6678
6679 // An extension method too long for `Method`'s inline storage writes
6680 // exactly as the old `format!("{} {}", method, path)` did.
6681 let request = Request::builder()
6682 .method("X-LONG-EXTENSION-VERB")
6683 .uri("/dav/a/b")
6684 .body(Body::empty())
6685 .unwrap();
6686 assert_eq!(
6687 body_of(app, request).await,
6688 "X-LONG-EXTENSION-VERB /dav/{*rest}\nX-LONG-EXTENSION-VERB /dav/{*rest} SELECT"
6689 );
6690
6691 let (parts, ()) = Request::builder()
6692 .method("POST")
6693 .uri("/no/route?q=1")
6694 .body(())
6695 .unwrap()
6696 .into_parts();
6697 let ctx = RequestDbContext::from_parts(&parts, &TestDbState);
6698 assert!(
6699 matches!(ctx.route_key, Some(RouteKey::Composed(_))),
6700 "a request no route matched composes its raw path"
6701 );
6702 assert_eq!(
6703 ctx.route_key.as_ref().map(ToString::to_string).as_deref(),
6704 Some("POST /no/route")
6705 );
6706 }
6707
6708 /// A composed key (a shard-tagged label, an unmatched path) is written
6709 /// verbatim into both the metric key and the slow-query `route` field.
6710 #[test]
6711 fn route_key_composed_writes_verbatim() {
6712 let key = RouteKey::Composed("GET /notes shard=shard0".to_owned());
6713 assert_eq!(key.to_string(), "GET /notes shard=shard0");
6714 assert_eq!(format!("{key} SELECT"), "GET /notes shard=shard0 SELECT");
6715 }
6716
6717 /// #2264: `Db` is a `FromRequestParts` extractor. Axum runs it before a
6718 /// body extractor (`Form`, `Json`, ...) reads the request body. A handler
6719 /// that takes both holds a pooled connection for as long as the client
6720 /// takes to send its body.
6721 ///
6722 /// `LazyDb` must not touch the pool during extraction. It defers the
6723 /// checkout to `LazyDb::checkout`, called once the body is already read.
6724 /// The pool here can never complete a checkout (`test_urls::unreachable`).
6725 /// So `Db` extraction fails right away, while `LazyDb` extraction still
6726 /// succeeds. Only its later `checkout()` call sees the same failure.
6727 #[tokio::test]
6728 async fn lazy_db_extraction_does_not_check_out_a_connection() {
6729 use axum::http::Request;
6730
6731 let config = DatabaseConfig {
6732 url: Some(crate::test_urls::unreachable("lazy-db-no-checkout")),
6733 connect_timeout_secs: 1,
6734 ..Default::default()
6735 };
6736 let pool = create_pool(&config).unwrap().unwrap();
6737 let state = TestReadState { primary: pool };
6738
6739 let (mut parts, ()) = Request::builder().body(()).unwrap().into_parts();
6740 assert!(
6741 Db::from_request_parts(&mut parts, &state).await.is_err(),
6742 "sanity check: the eager extractor must fail against a pool \
6743 that can never complete a checkout"
6744 );
6745
6746 let (mut parts, ()) = Request::builder().body(()).unwrap().into_parts();
6747 let lazy = LazyDb::from_request_parts(&mut parts, &state)
6748 .await
6749 .expect("LazyDb must extract without touching the pool");
6750 assert!(
6751 lazy.checkout().await.is_err(),
6752 "the checkout failure is deferred, not avoided"
6753 );
6754 }
6755
6756 #[tokio::test]
6757 async fn database_topology_primary_only_has_no_replica() {
6758 let config = DatabaseConfig {
6759 primary_url: Some(test_primary_url("db")),
6760 ..DatabaseConfig::default()
6761 };
6762 let topology = create_topology(&config).unwrap().unwrap();
6763
6764 let primary = topology.primary().clone();
6765
6766 let new_topology = DatabaseTopology::primary_only(primary);
6767 assert!(
6768 new_topology.replica().is_none(),
6769 "primary_only must set replica to None"
6770 );
6771 }
6772
6773 // Postgres-only: `database_backend_consistency` refuses ANY `replica_url`
6774 // alongside a SQLite primary ("read replicas require the postgres
6775 // backend"), so a two-pool topology is unreachable under the backend flip.
6776 // Running this under `sqlite` would only assert pool mechanics for a
6777 // configuration no validated deployment can have; the SQLite side of the
6778 // replica contract is asserted by `create_topology_rejects_*_sqlite_replica`
6779 // and `create_topology_allows_same_file_sqlite_replica` instead.
6780 #[cfg(not(feature = "sqlite"))]
6781 #[tokio::test]
6782 async fn database_topology_from_pools_retains_replica() {
6783 let primary = test_primary_url("primary");
6784 let config = DatabaseConfig {
6785 primary_url: Some(primary.clone()),
6786 replica_url: Some(test_replica_url(&primary, "db_replica")),
6787 ..DatabaseConfig::default()
6788 };
6789 let topology = create_topology(&config).unwrap().unwrap();
6790
6791 let primary = topology.primary().clone();
6792 let replica = topology.replica().cloned();
6793
6794 let new_topology = DatabaseTopology::from_pools(primary, replica);
6795 assert!(
6796 new_topology.replica().is_some(),
6797 "from_pools must preserve the replica pool"
6798 );
6799 }
6800
6801 // ── scrub_sql tests ───────────────────────────────────────────────────────
6802
6803 #[test]
6804 fn scrub_sql_strips_string_literals() {
6805 assert_eq!(
6806 super::scrub_sql("SELECT * FROM users WHERE name = 'Alice'"),
6807 "SELECT * FROM users WHERE name = '?'"
6808 );
6809 }
6810
6811 #[test]
6812 fn scrub_sql_strips_numeric_literals() {
6813 assert_eq!(
6814 super::scrub_sql("SELECT * FROM orders WHERE id = 42"),
6815 "SELECT * FROM orders WHERE id = ?"
6816 );
6817 }
6818
6819 #[test]
6820 fn scrub_sql_preserves_pg_positional_params() {
6821 assert_eq!(
6822 super::scrub_sql("SELECT * FROM t WHERE x = $1 AND y = $2"),
6823 "SELECT * FROM t WHERE x = $1 AND y = $2"
6824 );
6825 }
6826
6827 #[test]
6828 fn scrub_sql_does_not_stomp_identifiers() {
6829 // "table1" should not be replaced because it's not preceded by a separator
6830 assert_eq!(
6831 super::scrub_sql("SELECT * FROM table1 WHERE active = true"),
6832 "SELECT * FROM table1 WHERE active = true"
6833 );
6834 }
6835
6836 #[test]
6837 fn scrub_sql_multiple_literals_in_one_query() {
6838 assert_eq!(
6839 super::scrub_sql("INSERT INTO users (name, age) VALUES ('Bob', 30)"),
6840 "INSERT INTO users (name, age) VALUES ('?', ?)"
6841 );
6842 }
6843
6844 #[test]
6845 fn scrub_sql_handles_escaped_single_quotes() {
6846 assert_eq!(
6847 super::scrub_sql("SELECT * FROM t WHERE s = 'it''s a test'"),
6848 "SELECT * FROM t WHERE s = '?'"
6849 );
6850 }
6851
6852 #[test]
6853 fn scrub_sql_empty_string() {
6854 assert_eq!(super::scrub_sql(""), "");
6855 }
6856
6857 // ── Bug fixes: exponent suffix, dollar-quoted strings, E-string escapes ──
6858
6859 #[test]
6860 fn scrub_sql_scientific_notation_integer_exponent() {
6861 // 1e6 should be fully redacted to ? (the "e6" part is the exponent)
6862 assert_eq!(
6863 super::scrub_sql("SELECT * FROM t WHERE n = 1e6"),
6864 "SELECT * FROM t WHERE n = ?"
6865 );
6866 }
6867
6868 #[test]
6869 fn scrub_sql_scientific_notation_float_exponent() {
6870 // 2.5E-4 should be fully redacted: digit + decimal + E + sign + digit(s)
6871 assert_eq!(
6872 super::scrub_sql("SELECT * FROM t WHERE n = 2.5E-4"),
6873 "SELECT * FROM t WHERE n = ?"
6874 );
6875 }
6876
6877 #[test]
6878 fn scrub_sql_scientific_notation_uppercase_positive_exponent() {
6879 // 3E+10 — uppercase E with explicit + sign
6880 assert_eq!(
6881 super::scrub_sql("SELECT * FROM t WHERE n = 3E+10"),
6882 "SELECT * FROM t WHERE n = ?"
6883 );
6884 }
6885
6886 #[test]
6887 fn scrub_sql_dollar_quoted_anonymous() {
6888 // $$...$$ dollar-quoted string: content must be fully redacted
6889 assert_eq!(super::scrub_sql("SELECT $$secret value$$"), "SELECT '?'");
6890 }
6891
6892 #[test]
6893 fn scrub_sql_dollar_quoted_with_tag() {
6894 // $tag$...$tag$ — tagged dollar-quoted string
6895 assert_eq!(
6896 super::scrub_sql("SELECT $body$hello world$body$"),
6897 "SELECT '?'"
6898 );
6899 }
6900
6901 #[test]
6902 fn scrub_sql_dollar_quoted_does_not_affect_positional_params() {
6903 // $1, $2 positional params must still pass through unmodified
6904 assert_eq!(
6905 super::scrub_sql("SELECT $1, $2 FROM $$secret$$ WHERE id = $3"),
6906 "SELECT $1, $2 FROM '?' WHERE id = $3"
6907 );
6908 }
6909
6910 #[test]
6911 fn scrub_sql_estring_backslash_escaped_quote() {
6912 // E'it\'s secret' — backslash-escaped quote inside E'' string
6913 assert_eq!(
6914 super::scrub_sql(r"SELECT E'it\'s secret' FROM t"),
6915 "SELECT '?' FROM t"
6916 );
6917 }
6918
6919 #[test]
6920 fn scrub_sql_estring_uppercase() {
6921 // Uppercase E prefix variant E'...'
6922 assert_eq!(
6923 super::scrub_sql("SELECT E'hello world' FROM t"),
6924 "SELECT '?' FROM t"
6925 );
6926 }
6927
6928 #[test]
6929 fn scrub_sql_estring_multiple_backslash_escapes() {
6930 // Multiple backslash sequences inside one E'' literal
6931 assert_eq!(
6932 super::scrub_sql(r"SELECT E'line1\nline2' FROM t"),
6933 "SELECT '?' FROM t"
6934 );
6935 }
6936
6937 #[test]
6938 fn scrub_sql_leading_dot_numeric_literals() {
6939 assert_eq!(super::scrub_sql("SELECT .5"), "SELECT ?");
6940 assert_eq!(super::scrub_sql("SELECT .25 + .75"), "SELECT ? + ?");
6941 assert_eq!(super::scrub_sql("SELECT t.col"), "SELECT t.col");
6942 assert_eq!(
6943 super::scrub_sql("SELECT schema.table.col"),
6944 "SELECT schema.table.col"
6945 );
6946 }
6947
6948 #[test]
6949 fn scrub_sql_estring_doubled_quote_escape() {
6950 assert_eq!(
6951 super::scrub_sql("SELECT E'it''s secret' FROM t"),
6952 "SELECT '?' FROM t"
6953 );
6954 }
6955
6956 #[test]
6957 fn scrub_sql_numeric_literal_underscore_grouping() {
6958 assert_eq!(super::scrub_sql("SELECT 5_432_000"), "SELECT ?");
6959 assert_eq!(super::scrub_sql("SELECT 1_000.5_0"), "SELECT ?");
6960 assert_eq!(super::scrub_sql("SELECT col_5_val"), "SELECT col_5_val");
6961 assert_eq!(super::scrub_sql("SELECT col_5"), "SELECT col_5");
6962 }
6963
6964 // ── Transaction isolation + retry (issue #1202) ─────────────────
6965
6966 #[derive(Debug)]
6967 struct FakeDbErrorInfo {
6968 message: &'static str,
6969 }
6970
6971 impl diesel::result::DatabaseErrorInformation for FakeDbErrorInfo {
6972 fn message(&self) -> &str {
6973 self.message
6974 }
6975 fn details(&self) -> Option<&str> {
6976 None
6977 }
6978 fn hint(&self) -> Option<&str> {
6979 None
6980 }
6981 fn table_name(&self) -> Option<&str> {
6982 None
6983 }
6984 fn column_name(&self) -> Option<&str> {
6985 None
6986 }
6987 fn constraint_name(&self) -> Option<&str> {
6988 None
6989 }
6990 fn statement_position(&self) -> Option<i32> {
6991 None
6992 }
6993 }
6994
6995 fn diesel_db_error(
6996 kind: diesel::result::DatabaseErrorKind,
6997 message: &'static str,
6998 ) -> diesel::result::Error {
6999 diesel::result::Error::DatabaseError(kind, Box::new(FakeDbErrorInfo { message }))
7000 }
7001
7002 // ── TxOptions builder ────────────────────────────────────────
7003
7004 #[test]
7005 fn tx_options_default_is_read_committed_no_retry() {
7006 let opts = TxOptions::default();
7007 assert_eq!(opts.isolation, IsolationLevel::ReadCommitted);
7008 assert!(!opts.read_only);
7009 assert!(!opts.deferrable);
7010 assert_eq!(
7011 opts.max_attempts, 1,
7012 "default must behave exactly like today's tx(): one attempt, no retry"
7013 );
7014 }
7015
7016 #[test]
7017 fn tx_options_serializable_enables_retry() {
7018 let opts = TxOptions::serializable();
7019 assert_eq!(opts.isolation, IsolationLevel::Serializable);
7020 assert!(
7021 opts.max_attempts > 1,
7022 "serializable() should default to retrying since retry is the point"
7023 );
7024 }
7025
7026 #[test]
7027 fn tx_options_repeatable_read_enables_retry() {
7028 let opts = TxOptions::repeatable_read();
7029 assert_eq!(opts.isolation, IsolationLevel::RepeatableRead);
7030 assert!(opts.max_attempts > 1);
7031 }
7032
7033 #[test]
7034 fn tx_options_builder_chains() {
7035 let opts = TxOptions::serializable()
7036 .read_only()
7037 .deferrable()
7038 .max_attempts(9)
7039 .initial_backoff(Duration::from_millis(3))
7040 .max_backoff(Duration::from_millis(30));
7041 assert!(opts.read_only);
7042 assert!(opts.deferrable);
7043 assert_eq!(opts.max_attempts, 9);
7044 assert_eq!(opts.initial_backoff, Duration::from_millis(3));
7045 assert_eq!(opts.max_backoff, Duration::from_millis(30));
7046 }
7047
7048 #[test]
7049 fn tx_options_max_attempts_clamps_to_at_least_one() {
7050 // A zero here must never produce a loop that skips the closure entirely.
7051 assert_eq!(TxOptions::default().max_attempts(0).max_attempts, 1);
7052 }
7053
7054 #[test]
7055 fn tx_options_effective_max_attempts_clamps_struct_literal_bypass() {
7056 // `max_attempts` is a public field, so struct-literal update syntax can
7057 // set it to 0 directly, bypassing the `max_attempts()` builder's clamp.
7058 // The retry loop must still treat this as "run once", not "never run".
7059 let opts = TxOptions {
7060 max_attempts: 0,
7061 ..TxOptions::default()
7062 };
7063 assert_eq!(opts.max_attempts, 0, "the raw field is not itself clamped");
7064 assert_eq!(
7065 opts.effective_max_attempts(),
7066 1,
7067 "the retry loop's accessor must clamp a struct-literal 0 to 1"
7068 );
7069 }
7070
7071 // ── Backoff ──────────────────────────────────────────────────
7072
7073 #[test]
7074 fn retry_backoff_base_doubles_per_attempt() {
7075 let initial = Duration::from_millis(5);
7076 let max = Duration::from_secs(60);
7077 assert_eq!(
7078 retry_backoff_base(initial, max, 1),
7079 Duration::from_millis(5)
7080 );
7081 assert_eq!(
7082 retry_backoff_base(initial, max, 2),
7083 Duration::from_millis(10)
7084 );
7085 assert_eq!(
7086 retry_backoff_base(initial, max, 3),
7087 Duration::from_millis(20)
7088 );
7089 assert_eq!(
7090 retry_backoff_base(initial, max, 4),
7091 Duration::from_millis(40)
7092 );
7093 }
7094
7095 #[test]
7096 fn retry_backoff_base_caps_at_max() {
7097 let initial = Duration::from_millis(5);
7098 let max = Duration::from_millis(50);
7099 // 5,10,20,40 then capped at 50 for all higher attempts.
7100 assert_eq!(
7101 retry_backoff_base(initial, max, 5),
7102 Duration::from_millis(50)
7103 );
7104 assert_eq!(retry_backoff_base(initial, max, 40), max);
7105 // Huge attempt must not panic on shift overflow.
7106 assert_eq!(retry_backoff_base(initial, max, u32::MAX), max);
7107 }
7108
7109 #[test]
7110 fn retry_backoff_delay_stays_within_jitter_bounds_and_cap() {
7111 let initial = Duration::from_millis(100);
7112 let max = Duration::from_secs(10);
7113 for attempt in 1..=6u32 {
7114 let base = retry_backoff_base(initial, max, attempt);
7115 for _ in 0..64 {
7116 let d = retry_backoff_delay(initial, max, attempt);
7117 // Jitter is +/-20% of base, never exceeding the cap.
7118 assert!(
7119 d >= base.mul_f64(0.8) && d <= base.mul_f64(1.2),
7120 "delay {d:?} out of jitter bounds for base {base:?}"
7121 );
7122 assert!(d <= max, "delay {d:?} exceeded cap {max:?}");
7123 }
7124 }
7125 }
7126
7127 // ── Retryable-error classification ───────────────────────────
7128
7129 #[cfg(feature = "postgres")]
7130 #[test]
7131 fn only_serialization_failure_and_deadlock_sqlstates_are_retryable() {
7132 use tokio_postgres::error::SqlState;
7133 assert!(is_retryable_sqlstate(&SqlState::T_R_SERIALIZATION_FAILURE));
7134 assert!(is_retryable_sqlstate(&SqlState::T_R_DEADLOCK_DETECTED));
7135 assert!(!is_retryable_sqlstate(&SqlState::UNIQUE_VIOLATION));
7136 assert!(!is_retryable_sqlstate(&SqlState::QUERY_CANCELED));
7137 }
7138
7139 #[test]
7140 fn a_plain_diesel_error_is_not_a_cancelled_query() {
7141 // No timeout text and no SQLSTATE in the chain.
7142 assert!(!is_query_canceled(&diesel::result::Error::NotFound));
7143 assert!(is_query_canceled(&diesel_db_error(
7144 diesel::result::DatabaseErrorKind::Unknown,
7145 "canceling statement due to statement timeout",
7146 )));
7147 }
7148
7149 #[test]
7150 fn serialization_failure_is_retryable() {
7151 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
7152 diesel::result::DatabaseErrorKind::SerializationFailure,
7153 "could not serialize access due to read/write dependencies among transactions",
7154 ));
7155 assert!(is_retryable_txn_error(&err));
7156 }
7157
7158 #[test]
7159 fn deadlock_is_retryable() {
7160 // Postgres 40P01 is not mapped to a dedicated DatabaseErrorKind, so it
7161 // is caught via an exact match against Postgres's own invariant
7162 // primary message for this condition.
7163 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
7164 diesel::result::DatabaseErrorKind::Unknown,
7165 "deadlock detected",
7166 ));
7167 assert!(is_retryable_txn_error(&err));
7168 }
7169
7170 #[test]
7171 fn deadlock_message_is_matched_case_and_whitespace_insensitively() {
7172 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
7173 diesel::result::DatabaseErrorKind::Unknown,
7174 " Deadlock Detected ",
7175 ));
7176 assert!(is_retryable_txn_error(&err));
7177 }
7178
7179 #[test]
7180 fn unknown_kind_message_merely_mentioning_deadlock_is_not_retryable() {
7181 // Regression (PR #1581 review, round 2): an `Unknown`-kind Postgres
7182 // error from application SQL (e.g. `RAISE EXCEPTION`) whose message
7183 // merely *mentions* "deadlock detected" or "40001" as part of a
7184 // longer business message must not be retried -- only an exact match
7185 // against Postgres's own invariant primary message counts.
7186 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
7187 diesel::result::DatabaseErrorKind::Unknown,
7188 "order 40001 could not be processed: deadlock detected in workflow",
7189 ));
7190 assert!(!is_retryable_txn_error(&err));
7191 }
7192
7193 #[test]
7194 fn unknown_kind_40001_text_alone_is_not_retryable() {
7195 // A bare "40001" in an Unknown-kind message is no longer treated as a
7196 // retry signal -- genuine 40001s are already reliably classified via
7197 // the DatabaseErrorKind::SerializationFailure fast path (diesel-async
7198 // maps the real SQLSTATE), so this text pattern is dead weight that
7199 // only added false-positive risk.
7200 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
7201 diesel::result::DatabaseErrorKind::Unknown,
7202 "ERROR: 40001: could not serialize access",
7203 ));
7204 assert!(!is_retryable_txn_error(&err));
7205 }
7206
7207 #[test]
7208 fn unique_violation_is_not_retryable() {
7209 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
7210 diesel::result::DatabaseErrorKind::UniqueViolation,
7211 "duplicate key value violates unique constraint",
7212 ));
7213 assert!(!is_retryable_txn_error(&err));
7214 }
7215
7216 #[test]
7217 fn unique_violation_with_magic_substring_in_message_is_not_retryable() {
7218 // Regression: a non-retryable `DatabaseErrorKind` must never be
7219 // misclassified just because its message/constraint text happens to
7220 // contain a magic substring like "40001" or "deadlock detected".
7221 let err: AutumnError = AutumnError::internal_server_error(diesel_db_error(
7222 diesel::result::DatabaseErrorKind::UniqueViolation,
7223 "duplicate key value violates unique constraint \"orders_40001_key\"",
7224 ));
7225 assert!(!is_retryable_txn_error(&err));
7226 }
7227
7228 #[test]
7229 fn plain_internal_error_is_not_retryable() {
7230 let err = AutumnError::internal_server_error_msg("something unrelated broke");
7231 assert!(!is_retryable_txn_error(&err));
7232 }
7233
7234 #[test]
7235 fn domain_error_with_magic_substring_and_no_db_error_is_not_retryable() {
7236 // Regression (PR #1581 review): a plain domain/validation error — no
7237 // diesel::result::Error or tokio_postgres error anywhere in its
7238 // chain — must never be retried just because its message happens to
7239 // contain a magic substring like "40001" or "deadlock detected".
7240 // Retry requires a structured signal, never bare text.
7241 let err = AutumnError::bad_request_msg(
7242 "order 40001 could not be processed: deadlock detected in workflow",
7243 );
7244 assert!(!is_retryable_txn_error(&err));
7245 }
7246
7247 #[derive(Debug, thiserror::Error)]
7248 #[error("app error")]
7249 struct WrappedDbError(#[source] diesel::result::Error);
7250
7251 #[test]
7252 fn serialization_failure_wrapped_in_custom_error_is_retryable() {
7253 // A custom `E` (e.g. an app error enum with a `#[from] diesel::result::Error`
7254 // variant) is not itself a `diesel::result::Error`, so it isn't found by
7255 // a top-level-only downcast. `downcast_chain_ref` walks `source()` to
7256 // find it, so wrapped diesel errors are still classified structurally
7257 // — no need to fall back to message scanning for this common case.
7258 let err: AutumnError = AutumnError::internal_server_error(WrappedDbError(diesel_db_error(
7259 diesel::result::DatabaseErrorKind::SerializationFailure,
7260 "could not serialize access due to read/write dependencies among transactions",
7261 )));
7262 assert!(is_retryable_txn_error(&err));
7263 }
7264
7265 #[test]
7266 fn unique_violation_wrapped_in_custom_error_is_not_retryable() {
7267 let err: AutumnError = AutumnError::internal_server_error(WrappedDbError(diesel_db_error(
7268 diesel::result::DatabaseErrorKind::UniqueViolation,
7269 "duplicate key value violates unique constraint",
7270 )));
7271 assert!(!is_retryable_txn_error(&err));
7272 }
7273
7274 // ── Attempt accounting ───────────────────────────────────────
7275
7276 #[test]
7277 fn retry_decision_retries_until_attempts_exhausted() {
7278 // A retryable error keeps retrying while attempts remain, then stops.
7279 let max = 3;
7280 assert_eq!(retry_decision(1, max, true), RetryDecision::Retry);
7281 assert_eq!(retry_decision(2, max, true), RetryDecision::Retry);
7282 assert_eq!(
7283 retry_decision(3, max, true),
7284 RetryDecision::Stop,
7285 "the final attempt must stop even when the error is retryable (exhausted)"
7286 );
7287 }
7288
7289 #[test]
7290 fn retry_decision_stops_immediately_on_non_retryable() {
7291 assert_eq!(retry_decision(1, 5, false), RetryDecision::Stop);
7292 }
7293
7294 #[test]
7295 fn retry_decision_single_attempt_never_retries() {
7296 // max_attempts == 1 (the default) must behave exactly like today's tx().
7297 assert_eq!(retry_decision(1, 1, true), RetryDecision::Stop);
7298 }
7299}
7300
7301// ── Postgres TLS (sslmode) support ───────────────────────────────────────────
7302
7303/// TLS support for the Postgres pool, driven by `sslmode` in the database URL.
7304///
7305/// diesel-async's default `AsyncPgConnection::establish` hardcodes
7306/// `tokio_postgres::NoTls`, which makes `sslmode=require` fail on every
7307/// connection with "no TLS implementation configured". This module plugs a
7308/// rustls-backed connector into the pool via
7309/// [`diesel_async::pooled_connection::ManagerConfig::custom_setup`] when the
7310/// URL asks for TLS. Both URL (`postgres://…?sslmode=require`) and
7311/// keyword/value (`host=… sslmode=require`) connection strings are
7312/// recognized. The synchronous migration/startup-wait path shares the same
7313/// connector through [`super::establish_migration_connection`] — the
7314/// bundled libpq has no SSL support, so the native `PgConnection` path
7315/// cannot reach TLS-only servers at all.
7316///
7317/// | `sslmode` | behavior |
7318/// | ----------------------------- | -------- |
7319/// | absent, `disable`, `prefer` | unchanged: the default `NoTls` path (plaintext), exactly as before this module existed |
7320/// | `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) |
7321/// | `verify-full` | TLS with full chain **and** hostname verification against the Mozilla root store (`webpki-roots`), plus any `sslrootcert=<PEM file>` from the URL |
7322/// | `verify-ca` | rejected with guidance: chain-only verification is not implemented; use `verify-full` (stricter) or `require` |
7323///
7324/// `require` combined with `sslrootcert` is also rejected rather than
7325/// silently ignoring the CA file: `libpq` documents that combination as
7326/// upgrading to certificate verification, so dropping the file would
7327/// silently weaken what the operator asked for.
7328// `pub(crate)` so the failure-capsule recording pool can reuse the same
7329// `sslmode` classification when deciding whether a database URL can be teed
7330// (#1598) instead of re-implementing the parse.
7331#[cfg(feature = "postgres")]
7332pub(crate) mod tls {
7333 use std::sync::Arc;
7334
7335 use diesel::{ConnectionError, ConnectionResult};
7336 use diesel_async::pooled_connection::SetupCallback;
7337 use diesel_async::{AsyncConnection as _, AsyncPgConnection};
7338 use futures::FutureExt as _;
7339 use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
7340 use rustls::crypto::CryptoProvider;
7341 use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
7342 use rustls::{DigitallySignedStruct, SignatureScheme};
7343 use tokio_postgres_rustls::MakeRustlsConnect;
7344
7345 /// TLS posture derived from the connection string's `sslmode` (and
7346 /// `sslrootcert`). See the [module docs](self) for the full table.
7347 #[derive(Debug, Clone, PartialEq, Eq)]
7348 pub enum TlsPosture {
7349 /// No TLS machinery: keep diesel-async's default `NoTls` setup path.
7350 Off,
7351 /// Encrypt without verifying the server certificate chain
7352 /// (`libpq` parity for `sslmode=require`).
7353 Require,
7354 /// Encrypt and fully verify the certificate chain + hostname.
7355 VerifyFull {
7356 /// Optional `sslrootcert` PEM file to trust in addition to the
7357 /// Mozilla root store.
7358 root_cert: Option<String>,
7359 },
7360 /// A recognized-but-unsupported combination; every connection attempt
7361 /// fails loudly with this reason instead of silently downgrading.
7362 Unsupported { reason: String },
7363 }
7364
7365 impl TlsPosture {
7366 /// Classify a database URL / keyword-value connection string.
7367 pub fn from_database_url(database_url: &str) -> Self {
7368 let params = ssl_params(database_url);
7369 // Last occurrence wins, matching libpq/tokio-postgres semantics.
7370 let get = |key: &str| {
7371 params
7372 .iter()
7373 .rev()
7374 .find(|(k, _)| k == key)
7375 .map(|(_, v)| v.as_str())
7376 };
7377 let root_cert = get("sslrootcert").map(str::to_owned);
7378 match get("sslmode") {
7379 Some("require") => {
7380 if root_cert.is_some() {
7381 Self::Unsupported {
7382 reason: "sslmode=require with sslrootcert is not supported: \
7383 PostgreSQL treats that combination as requiring \
7384 certificate verification against the CA file, which \
7385 this pool implements only for sslmode=verify-full. \
7386 Use sslmode=verify-full to verify the certificate, or \
7387 sslmode=require without sslrootcert to encrypt without \
7388 verifying it."
7389 .to_owned(),
7390 }
7391 } else {
7392 Self::Require
7393 }
7394 }
7395 Some("verify-full") => Self::VerifyFull { root_cert },
7396 Some("verify-ca") => Self::Unsupported {
7397 reason: "sslmode=verify-ca is not supported: chain-only verification \
7398 (without hostname checking) is not implemented. Use \
7399 sslmode=verify-full (stricter) or sslmode=require (encrypts \
7400 without verifying the certificate)."
7401 .to_owned(),
7402 },
7403 // Absent, `disable`, `prefer`, or anything unrecognized:
7404 // preserve the pre-TLS behavior exactly (including the error
7405 // tokio-postgres itself raises for invalid values).
7406 _ => Self::Off,
7407 }
7408 }
7409 }
7410
7411 /// Build the pool's custom connection-setup callback for a TLS posture.
7412 // Only the default (Postgres) `build_pool` arm installs this custom TLS
7413 // setup; unused in a `--features sqlite` build (SQLite has no TLS transport).
7414 #[cfg_attr(feature = "sqlite", allow(dead_code))]
7415 pub(super) fn setup_callback(posture: TlsPosture) -> SetupCallback<AsyncPgConnection> {
7416 Box::new(move |url: &str| {
7417 let posture = posture.clone();
7418 let url = url.to_owned();
7419 async move { establish(&url, posture).await }.boxed()
7420 })
7421 }
7422
7423 /// Establish an [`AsyncPgConnection`] honoring `posture` — the single
7424 /// connect path shared by the pool's setup callback and the synchronous
7425 /// migration/wait-check wrapper
7426 /// ([`super::establish_migration_connection`]).
7427 pub(super) async fn establish(
7428 url: &str,
7429 posture: TlsPosture,
7430 ) -> ConnectionResult<AsyncPgConnection> {
7431 match posture {
7432 // Defensive: `build_pool` never installs the callback for
7433 // `Off`, but fall back to the stock path if it ever does.
7434 TlsPosture::Off => AsyncPgConnection::establish(url).await,
7435 TlsPosture::Require => connect_with(url, relaxed_connector()?).await,
7436 TlsPosture::VerifyFull { root_cert } => {
7437 // tokio-postgres's own connection-string parser
7438 // rejects `verify-*` and `sslrootcert`, so hand it a
7439 // sanitized string; the real verification lives in
7440 // the rustls config.
7441 let sanitized = sanitize_for_verify_full(url);
7442 connect_with(&sanitized, verifying_connector(root_cert.as_deref())?).await
7443 }
7444 TlsPosture::Unsupported { reason } => {
7445 Err(ConnectionError::InvalidConnectionUrl(reason))
7446 }
7447 }
7448 }
7449
7450 async fn connect_with(
7451 url: &str,
7452 tls: MakeRustlsConnect,
7453 ) -> ConnectionResult<AsyncPgConnection> {
7454 let (client, connection) = tokio_postgres::connect(url, tls).await.map_err(|e| {
7455 // tokio_postgres's Display gives only the error kind ("error
7456 // performing TLS handshake"); append the source so operators see
7457 // the actionable cause ("server does not support TLS", a
7458 // certificate verification failure, …).
7459 let msg = std::error::Error::source(&e)
7460 .map_or_else(|| e.to_string(), |source| format!("{e}: {source}"));
7461 ConnectionError::BadConnection(msg)
7462 })?;
7463 AsyncPgConnection::try_from_client_and_connection(client, connection).await
7464 }
7465
7466 /// Encrypt-only connector for `sslmode=require`: handshake signatures are
7467 /// verified but the certificate chain/identity is not, mirroring
7468 /// `libpq`/`psql` behavior for `require` (which self-signed and
7469 /// private-CA deployments rely on).
7470 fn relaxed_connector() -> Result<MakeRustlsConnect, ConnectionError> {
7471 let provider = Arc::new(rustls::crypto::ring::default_provider());
7472 let config = rustls::ClientConfig::builder_with_provider(provider.clone())
7473 .with_safe_default_protocol_versions()
7474 .map_err(|e| {
7475 ConnectionError::BadConnection(format!("failed to build TLS config: {e}"))
7476 })?
7477 .dangerous()
7478 .with_custom_certificate_verifier(Arc::new(NoServerCertVerification(
7479 (*provider).clone(),
7480 )))
7481 .with_no_client_auth();
7482 Ok(MakeRustlsConnect::new(config))
7483 }
7484
7485 /// Fully verifying connector for `sslmode=verify-full`: certificate chain
7486 /// and hostname are checked against the Mozilla root store plus any
7487 /// `sslrootcert` PEM file from the connection string. `webpki-roots` is
7488 /// used (rather than the platform store) so behavior is deterministic on
7489 /// every target, including mobile, where no native store is reachable.
7490 fn verifying_connector(root_cert: Option<&str>) -> Result<MakeRustlsConnect, ConnectionError> {
7491 let mut roots = rustls::RootCertStore::empty();
7492 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
7493 if let Some(path) = root_cert {
7494 use rustls_pki_types::pem::PemObject as _;
7495 let certs = CertificateDer::pem_file_iter(path).map_err(|e| {
7496 ConnectionError::BadConnection(format!("failed to read sslrootcert {path}: {e}"))
7497 })?;
7498 for cert in certs {
7499 let cert = cert.map_err(|e| {
7500 ConnectionError::BadConnection(format!(
7501 "failed to parse sslrootcert {path}: {e}"
7502 ))
7503 })?;
7504 roots.add(cert).map_err(|e| {
7505 ConnectionError::BadConnection(format!(
7506 "failed to trust sslrootcert {path}: {e}"
7507 ))
7508 })?;
7509 }
7510 }
7511 let provider = Arc::new(rustls::crypto::ring::default_provider());
7512 let config = rustls::ClientConfig::builder_with_provider(provider)
7513 .with_safe_default_protocol_versions()
7514 .map_err(|e| {
7515 ConnectionError::BadConnection(format!("failed to build TLS config: {e}"))
7516 })?
7517 .with_root_certificates(roots)
7518 .with_no_client_auth();
7519 Ok(MakeRustlsConnect::new(config))
7520 }
7521
7522 /// Whether tokio-postgres would parse this connection string as a URL —
7523 /// see [`crate::pg_conn_str::is_url`]. Getting this wrong would send
7524 /// keyword strings whose values embed URL-like tokens
7525 /// (`password=https://…`) down the URL path, where `url::Url::parse`
7526 /// fails and the TLS posture silently falls back to [`TlsPosture::Off`].
7527 /// Shared with config validation so the two never disagree about which
7528 /// strings are reachable.
7529 use crate::pg_conn_str::is_url as is_url_connection_string;
7530 /// Parse a libpq-style `key = value` connection string, mirroring
7531 /// tokio-postgres's (private) parser — see
7532 /// [`crate::pg_conn_str::keyword_value_pairs`]. Naive whitespace
7533 /// splitting would miss `sslmode` in quoted/spaced strings and silently
7534 /// downgrade the TLS posture to [`TlsPosture::Off`].
7535 use crate::pg_conn_str::keyword_value_pairs;
7536
7537 /// Extract query/keyword parameters relevant to TLS from either a
7538 /// `postgres://…` URL or a `key=value …` connection string.
7539 fn ssl_params(database_url: &str) -> Vec<(String, String)> {
7540 if is_url_connection_string(database_url) {
7541 url::Url::parse(database_url)
7542 .map(|u| {
7543 u.query_pairs()
7544 .map(|(k, v)| (k.into_owned(), v.into_owned()))
7545 .collect()
7546 })
7547 .unwrap_or_default()
7548 } else {
7549 keyword_value_pairs(database_url).unwrap_or_default()
7550 }
7551 }
7552
7553 /// Serialize one value of a keyword/value connection string, quoting it
7554 /// whenever it would not survive re-parsing as a bare token.
7555 fn quote_keyword_value(value: &str) -> String {
7556 if !value.is_empty()
7557 && !value.contains(|c: char| c.is_whitespace() || c == '\'' || c == '\\')
7558 {
7559 return value.to_owned();
7560 }
7561 let mut quoted = String::with_capacity(value.len() + 2);
7562 quoted.push('\'');
7563 for c in value.chars() {
7564 if c == '\'' || c == '\\' {
7565 quoted.push('\\');
7566 }
7567 quoted.push(c);
7568 }
7569 quoted.push('\'');
7570 quoted
7571 }
7572
7573 /// Rewrite a `verify-full` connection string into one tokio-postgres can
7574 /// parse: `sslmode` downgraded to `require` (the handshake decision), and
7575 /// `sslrootcert` removed (consumed by [`verifying_connector`] instead).
7576 fn sanitize_for_verify_full(database_url: &str) -> String {
7577 if is_url_connection_string(database_url) {
7578 let Ok(mut parsed) = url::Url::parse(database_url) else {
7579 return database_url.to_owned();
7580 };
7581 // Rewrite ONLY the sslmode/sslrootcert components, preserving
7582 // every other raw query component byte-for-byte: decoding and
7583 // re-serializing through query_pairs()/append_pair() would
7584 // form-encode spaces as `+`, which libpq/tokio-postgres do not
7585 // accept in Postgres URI parameters —
7586 // `options=-c%20search_path%3Dtenant` would be mangled into
7587 // `options=-c+search_path=tenant`. (The startup-wait splice in
7588 // migrate.rs avoids those APIs for the same reason.)
7589 let raw = parsed
7590 .query()
7591 .unwrap_or("")
7592 .split('&')
7593 .filter(|component| !component.is_empty())
7594 .filter_map(|component| {
7595 let key = component.split('=').next().unwrap_or(component);
7596 match key {
7597 "sslmode" => Some("sslmode=require"),
7598 "sslrootcert" => None,
7599 _ => Some(component),
7600 }
7601 })
7602 .collect::<Vec<_>>()
7603 .join("&");
7604 if raw.is_empty() {
7605 parsed.set_query(None);
7606 } else {
7607 parsed.set_query(Some(&raw));
7608 }
7609 parsed.to_string()
7610 } else {
7611 let Some(pairs) = keyword_value_pairs(database_url) else {
7612 // Malformed: pass through so tokio-postgres reports its own
7613 // parse error instead of us inventing a different string.
7614 return database_url.to_owned();
7615 };
7616 pairs
7617 .into_iter()
7618 .filter_map(|(k, v)| match k.as_str() {
7619 "sslmode" => Some("sslmode=require".to_owned()),
7620 "sslrootcert" => None,
7621 _ => Some(format!("{k}={}", quote_keyword_value(&v))),
7622 })
7623 .collect::<Vec<_>>()
7624 .join(" ")
7625 }
7626 }
7627
7628 /// Accepts any server certificate without validating its chain or
7629 /// hostname, while still cryptographically verifying the TLS handshake
7630 /// signatures (the connection is genuinely encrypted to *some* holder of
7631 /// the presented key — "no identity check", not "no security"). This is
7632 /// exactly `libpq`/`psql`'s posture for `sslmode=require`; identity
7633 /// verification is `sslmode=verify-full`'s job.
7634 #[derive(Debug)]
7635 struct NoServerCertVerification(CryptoProvider);
7636
7637 impl ServerCertVerifier for NoServerCertVerification {
7638 fn verify_server_cert(
7639 &self,
7640 _end_entity: &CertificateDer<'_>,
7641 _intermediates: &[CertificateDer<'_>],
7642 _server_name: &ServerName<'_>,
7643 _ocsp_response: &[u8],
7644 _now: UnixTime,
7645 ) -> Result<ServerCertVerified, rustls::Error> {
7646 Ok(ServerCertVerified::assertion())
7647 }
7648
7649 fn verify_tls12_signature(
7650 &self,
7651 message: &[u8],
7652 cert: &CertificateDer<'_>,
7653 dss: &DigitallySignedStruct,
7654 ) -> Result<HandshakeSignatureValid, rustls::Error> {
7655 rustls::crypto::verify_tls12_signature(
7656 message,
7657 cert,
7658 dss,
7659 &self.0.signature_verification_algorithms,
7660 )
7661 }
7662
7663 fn verify_tls13_signature(
7664 &self,
7665 message: &[u8],
7666 cert: &CertificateDer<'_>,
7667 dss: &DigitallySignedStruct,
7668 ) -> Result<HandshakeSignatureValid, rustls::Error> {
7669 rustls::crypto::verify_tls13_signature(
7670 message,
7671 cert,
7672 dss,
7673 &self.0.signature_verification_algorithms,
7674 )
7675 }
7676
7677 fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
7678 self.0.signature_verification_algorithms.supported_schemes()
7679 }
7680 }
7681
7682 #[cfg(test)]
7683 mod tests {
7684 use super::*;
7685
7686 #[test]
7687 fn absent_disable_and_prefer_keep_the_default_notls_path() {
7688 for url in [
7689 "postgres://user:pass@db.example.com:5432/app",
7690 "postgres://user:pass@db.example.com:5432/app?sslmode=disable",
7691 "postgres://user:pass@db.example.com:5432/app?sslmode=prefer",
7692 "host=db.example.com user=user",
7693 "host=db.example.com user=user sslmode=disable",
7694 ] {
7695 assert_eq!(
7696 TlsPosture::from_database_url(url),
7697 TlsPosture::Off,
7698 "{url} must keep the default NoTls path"
7699 );
7700 }
7701 }
7702
7703 #[test]
7704 fn unrecognized_sslmode_values_keep_the_default_path_and_its_errors() {
7705 // tokio-postgres itself rejects these at connect time; the pool
7706 // must not mask that with a different TLS decision.
7707 assert_eq!(
7708 TlsPosture::from_database_url("postgres://u@h/db?sslmode=allow"),
7709 TlsPosture::Off
7710 );
7711 assert_eq!(
7712 TlsPosture::from_database_url("postgres://u@h/db?sslmode=bogus"),
7713 TlsPosture::Off
7714 );
7715 }
7716
7717 #[test]
7718 fn require_selects_the_relaxed_tls_connector() {
7719 assert_eq!(
7720 TlsPosture::from_database_url(
7721 "postgres://user:pass@db.example.com:5432/app?sslmode=require"
7722 ),
7723 TlsPosture::Require
7724 );
7725 assert_eq!(
7726 TlsPosture::from_database_url("host=db.example.com sslmode=require"),
7727 TlsPosture::Require
7728 );
7729 }
7730
7731 #[test]
7732 fn verify_full_selects_the_verifying_connector_with_optional_root() {
7733 assert_eq!(
7734 TlsPosture::from_database_url("postgres://u@h/db?sslmode=verify-full"),
7735 TlsPosture::VerifyFull { root_cert: None }
7736 );
7737 assert_eq!(
7738 TlsPosture::from_database_url(
7739 "postgres://u@h/db?sslmode=verify-full&sslrootcert=/etc/ca.pem"
7740 ),
7741 TlsPosture::VerifyFull {
7742 root_cert: Some("/etc/ca.pem".to_owned())
7743 }
7744 );
7745 }
7746
7747 #[test]
7748 fn keyword_strings_with_spaces_around_equals_are_parsed() {
7749 // Regression: whitespace-splitting missed `sslmode` here and
7750 // silently downgraded the posture to Off (plaintext).
7751 assert_eq!(
7752 TlsPosture::from_database_url("host=db user=u sslmode = require"),
7753 TlsPosture::Require
7754 );
7755 assert_eq!(
7756 TlsPosture::from_database_url("sslmode = require"),
7757 TlsPosture::Require
7758 );
7759 assert_eq!(
7760 TlsPosture::from_database_url("host = db sslmode\t=\nverify-full user=u"),
7761 TlsPosture::VerifyFull { root_cert: None }
7762 );
7763 }
7764
7765 #[test]
7766 fn keyword_strings_with_quoted_values_are_parsed() {
7767 assert_eq!(
7768 TlsPosture::from_database_url("sslmode='verify-full'"),
7769 TlsPosture::VerifyFull { root_cert: None }
7770 );
7771 assert_eq!(
7772 TlsPosture::from_database_url(
7773 "host=db sslmode='verify-full' sslrootcert='/path with space/ca.pem'"
7774 ),
7775 TlsPosture::VerifyFull {
7776 root_cert: Some("/path with space/ca.pem".to_owned())
7777 }
7778 );
7779 // Backslash escapes work inside and outside quotes, as in
7780 // tokio-postgres/libpq.
7781 assert_eq!(
7782 TlsPosture::from_database_url(r"sslmode=verify-full sslrootcert='/pki/it\'s.pem'"),
7783 TlsPosture::VerifyFull {
7784 root_cert: Some("/pki/it's.pem".to_owned())
7785 }
7786 );
7787 assert_eq!(
7788 TlsPosture::from_database_url(r"sslmode=verify-full sslrootcert=/pki/my\ ca.pem"),
7789 TlsPosture::VerifyFull {
7790 root_cert: Some("/pki/my ca.pem".to_owned())
7791 }
7792 );
7793 }
7794
7795 #[test]
7796 fn malformed_keyword_strings_keep_the_default_path_and_its_errors() {
7797 // tokio-postgres rejects these at connect time; the posture must
7798 // not guess a different TLS decision for them.
7799 for url in [
7800 "host=db sslmode", // missing `=` and value
7801 "host=db sslmode=", // empty unquoted value
7802 "host=db sslmode='require", // unterminated quote
7803 r"host=db sslmode='require\", // EOF right after escape
7804 ] {
7805 assert_eq!(
7806 TlsPosture::from_database_url(url),
7807 TlsPosture::Off,
7808 "{url:?} must keep the default NoTls path"
7809 );
7810 }
7811 }
7812
7813 #[test]
7814 fn keyword_strings_with_url_like_values_stay_on_the_keyword_path() {
7815 // Regression: `contains("://")` sent this whole string to
7816 // url::Url::parse, which failed, so sslmode=require silently
7817 // became Off (plaintext). Only `postgres://`/`postgresql://`
7818 // prefixes are URLs, exactly as in tokio-postgres.
7819 assert_eq!(
7820 TlsPosture::from_database_url("host=db password=https://secret sslmode=require"),
7821 TlsPosture::Require
7822 );
7823 assert_eq!(
7824 TlsPosture::from_database_url(
7825 "host=db options='-c foo=bar://baz' sslmode='verify-full'"
7826 ),
7827 TlsPosture::VerifyFull { root_cert: None }
7828 );
7829 // The alternate scheme spelling is still parsed as a URL.
7830 assert_eq!(
7831 TlsPosture::from_database_url("postgresql://u@h/db?sslmode=require"),
7832 TlsPosture::Require
7833 );
7834 }
7835
7836 #[test]
7837 fn verify_ca_and_require_with_rootcert_fail_loudly() {
7838 assert!(matches!(
7839 TlsPosture::from_database_url("postgres://u@h/db?sslmode=verify-ca"),
7840 TlsPosture::Unsupported { .. }
7841 ));
7842 assert!(matches!(
7843 TlsPosture::from_database_url(
7844 "postgres://u@h/db?sslmode=require&sslrootcert=/etc/ca.pem"
7845 ),
7846 TlsPosture::Unsupported { .. }
7847 ));
7848 }
7849
7850 #[test]
7851 fn last_sslmode_occurrence_wins() {
7852 assert_eq!(
7853 TlsPosture::from_database_url("postgres://u@h/db?sslmode=disable&sslmode=require"),
7854 TlsPosture::Require
7855 );
7856 }
7857
7858 #[test]
7859 fn sanitize_rewrites_verify_full_and_drops_sslrootcert() {
7860 let sanitized = sanitize_for_verify_full(
7861 "postgres://u@h:5432/db?application_name=app&sslmode=verify-full&sslrootcert=/etc/ca.pem",
7862 );
7863 assert!(sanitized.contains("sslmode=require"), "{sanitized}");
7864 assert!(!sanitized.contains("verify-full"), "{sanitized}");
7865 assert!(!sanitized.contains("sslrootcert"), "{sanitized}");
7866 assert!(sanitized.contains("application_name=app"), "{sanitized}");
7867
7868 // Percent-encoded components survive byte-for-byte: form
7869 // re-encoding would turn %20 into `+`, which libpq and
7870 // tokio-postgres do not accept as a space in URI parameters.
7871 let sanitized = sanitize_for_verify_full(
7872 "postgres://u@h/db?options=-c%20search_path%3Dtenant&sslmode=verify-full&sslrootcert=/etc/ca.pem",
7873 );
7874 assert_eq!(
7875 sanitized, "postgres://u@h/db?options=-c%20search_path%3Dtenant&sslmode=require",
7876 "raw components must be preserved verbatim"
7877 );
7878 // Dropping the only params leaves a clean URL with no `?`.
7879 assert_eq!(
7880 sanitize_for_verify_full("postgres://u@h/db?sslrootcert=/etc/ca.pem"),
7881 "postgres://u@h/db"
7882 );
7883
7884 let kv = sanitize_for_verify_full(
7885 "host=h user=u sslmode=verify-full sslrootcert=/etc/ca.pem dbname=db",
7886 );
7887 assert_eq!(kv, "host=h user=u sslmode=require dbname=db");
7888
7889 // Whitespace/quoting variants normalize to a string
7890 // tokio-postgres parses to the same parameters.
7891 let kv = sanitize_for_verify_full(
7892 r"host=h sslmode = 'verify-full' sslrootcert='/path with space/ca.pem' password='it\'s'",
7893 );
7894 assert_eq!(kv, r"host=h sslmode=require password='it\'s'");
7895
7896 // URL-like values must not push a keyword string onto the URL
7897 // branch (which would pass it through unsanitized).
7898 let kv = sanitize_for_verify_full(
7899 "host=h password=https://secret sslmode=verify-full sslrootcert=/etc/ca.pem",
7900 );
7901 assert_eq!(kv, "host=h password=https://secret sslmode=require");
7902 }
7903
7904 #[test]
7905 fn verifying_connector_builds_with_and_without_root_cert_file() {
7906 assert!(verifying_connector(None).is_ok());
7907 assert!(
7908 verifying_connector(Some("/nonexistent/ca.pem")).is_err(),
7909 "an unreadable sslrootcert must fail loudly"
7910 );
7911 }
7912
7913 #[test]
7914 fn relaxed_connector_builds() {
7915 assert!(relaxed_connector().is_ok());
7916 }
7917 }
7918}
7919
7920// ── Synchronous TLS-aware connections (migrations, wait checks) ───────────────
7921
7922/// A synchronous diesel Pg connection for migrations and startup wait
7923/// checks, honoring the connection string's `sslmode` (issue #1585 review).
7924///
7925/// diesel's native [`diesel::PgConnection`] connects through libpq, and the
7926/// workspace bundles libpq **without** SSL support (`pq-sys`'s
7927/// `bundled_without_openssl`) — so with `sslmode=require`/`verify-full` the
7928/// async pool (rustls) connects fine while the sync migration path fails at
7929/// startup before the app ever serves. For those postures this wraps an
7930/// [`AsyncPgConnection`] established through the pool's own rustls connector
7931/// in diesel-async's [`AsyncConnectionWrapper`], which provides the full
7932/// sync diesel API including `MigrationHarness`. With TLS off the native
7933/// `PgConnection` path is kept byte-identical to the historical behavior.
7934///
7935/// [`AsyncConnectionWrapper`]:
7936/// diesel_async::async_connection_wrapper::AsyncConnectionWrapper
7937#[cfg(feature = "postgres")]
7938#[allow(clippy::large_enum_variant)] // short-lived, one per migration target
7939pub(crate) enum MigrationConnection {
7940 /// The historical libpq-backed connection (TLS posture `Off`).
7941 Native(diesel::PgConnection),
7942 /// rustls-backed sync wrapper (TLS posture `Require`/`VerifyFull`).
7943 Rustls {
7944 /// Dedicated runtime owning the connection's tokio driver task —
7945 /// always present, never the caller's ambient runtime (see
7946 /// [`establish_migration_connection`] for why). Callers must keep
7947 /// this alive as long as `conn` — dropping it kills the driver and
7948 /// every query after that hangs or errors. It is safe to drop
7949 /// normally (no deferred-shutdown wrapper needed): every caller of
7950 /// [`establish_migration_connection`] reaches it only from a thread
7951 /// that has never entered any runtime (see that function's doc
7952 /// comment), and stays off any runtime's "entered" state except
7953 /// during each individual `block_on` call this connection or `conn`
7954 /// makes — so a drop between those calls is never "from within an
7955 /// asynchronous context" the way tokio forbids.
7956 runtime: tokio::runtime::Runtime,
7957 conn: diesel_async::async_connection_wrapper::AsyncConnectionWrapper<AsyncPgConnection>,
7958 },
7959}
7960
7961/// Whether migrations/wait checks for `database_url` must go through the
7962/// rustls wrapper rather than the native libpq path. Split out of
7963/// [`establish_migration_connection`] so the path selection is unit-testable
7964/// without a server.
7965#[cfg(feature = "postgres")]
7966fn migration_connection_needs_rustls(database_url: &str) -> bool {
7967 !matches!(
7968 tls::TlsPosture::from_database_url(database_url),
7969 tls::TlsPosture::Off
7970 )
7971}
7972
7973/// Establish a [`MigrationConnection`] for `database_url`.
7974///
7975/// # Thread-safety contract — read before adding a new caller
7976///
7977/// This function itself is unconditionally safe to call from any thread: it
7978/// only ever enters/exits a runtime it just built, on the thread it is
7979/// running on, never touching an ambient one. But the connection it returns
7980/// is NOT fully self-contained for the rustls arm — the returned
7981/// [`AsyncConnectionWrapper`] bridges every subsequent sync diesel call
7982/// (`MigrationHarness::run_pending_migrations`, plain queries, ...) through
7983/// its OWN internal `block_on`, and that bridging still panics
7984/// ("Cannot start a runtime from within a runtime") if invoked from a thread
7985/// that is itself already inside some OTHER ambient runtime's context — the
7986/// exact shape of an app's own `.on_startup(|state| async move { ... })`
7987/// hook calling a migration function directly (TLS-enabled migrations used
7988/// to panic in production this way; TLS off never hit this arm, so it never
7989/// surfaced in dev). So this function alone does not make that case safe —
7990/// **every caller must ensure the connection is established AND used
7991/// entirely on a thread that has never entered any runtime**, e.g. via
7992/// [`with_migration_connection!`]/`with_sync_pg_connection!`, which dispatch
7993/// their whole body (this call plus every query `$body` makes) onto a
7994/// freshly spawned [`std::thread::scope`] thread. [`hold_migration_lock`]'s
7995/// caller (the CLI's `autumn migrate`) satisfies this by construction — it
7996/// never runs inside a tokio runtime at all.
7997///
7998/// [`AsyncConnectionWrapper`]:
7999/// diesel_async::async_connection_wrapper::AsyncConnectionWrapper
8000/// [`with_migration_connection!`]: crate::migrate
8001///
8002/// # Errors
8003///
8004/// Returns the underlying [`diesel::ConnectionError`] when the connection
8005/// cannot be established (including [`TlsPosture::Unsupported`] postures,
8006/// which fail with their documented guidance).
8007///
8008/// [`TlsPosture::Unsupported`]: tls::TlsPosture::Unsupported
8009#[cfg(feature = "postgres")]
8010pub(crate) fn establish_migration_connection(
8011 database_url: &str,
8012) -> Result<MigrationConnection, diesel::ConnectionError> {
8013 use diesel::Connection as _;
8014 if !migration_connection_needs_rustls(database_url) {
8015 return diesel::PgConnection::establish(database_url).map(MigrationConnection::Native);
8016 }
8017 let posture = tls::TlsPosture::from_database_url(database_url);
8018 // Build a dedicated runtime and connect on the calling thread directly, with no
8019 // further nested thread-spawn: per this function's own contract above, every caller
8020 // guarantees the calling thread has never entered any runtime, so entering this
8021 // freshly built one is always safe. It drives the connection's background I/O task
8022 // for as long as `conn` is used, so it is kept alongside `conn` in
8023 // [`MigrationConnection::Rustls`]. Dropping it is likewise safe here: by the time it
8024 // drops, after every query `$body` makes has completed, this thread is not
8025 // mid-`block_on` on anything.
8026 let runtime = tokio::runtime::Builder::new_multi_thread()
8027 .worker_threads(1)
8028 .enable_all()
8029 .build()
8030 .map_err(|e| {
8031 diesel::ConnectionError::BadConnection(format!(
8032 "failed to build a tokio runtime for the TLS migration connection: {e}"
8033 ))
8034 })?;
8035 let inner = runtime.block_on(tls::establish(database_url, posture))?;
8036 Ok(MigrationConnection::Rustls {
8037 runtime,
8038 conn: diesel_async::async_connection_wrapper::AsyncConnectionWrapper::from(inner),
8039 })
8040}
8041
8042/// Establish a synchronous diesel [`diesel::SqliteConnection`] for the `SQLite`
8043/// startup-migration path (issue #1614, PR3).
8044///
8045/// The `SQLite` counterpart to [`establish_migration_connection`], and much
8046/// thinner: `SQLite` is a single-writer local database, so there is no TLS
8047/// negotiation and no advisory-lock connection wrapper — diesel's
8048/// `MigrationHarness` runs directly on a plain `SqliteConnection`. The URL is
8049/// normalized through the same [`normalize_sqlite_target`] the runtime pool
8050/// uses, so the migration connection and the pool open the same database file.
8051///
8052/// The connection sets `PRAGMA busy_timeout = 5000` right after opening,
8053/// mirroring the runtime pool's per-connection `custom_setup` (see
8054/// [`build_sqlite_pool`]): without it, a second concurrent migrator — or a write
8055/// lock briefly held by the runtime pool — makes this connection fail
8056/// *immediately* with `SQLITE_BUSY`, and `auto_migrate_sqlite` exits the process.
8057/// With the timeout, migration statements WAIT up to 5s for the lock to clear
8058/// instead of aborting; diesel migrations are idempotent, so a migrator that
8059/// waits and then finds migrations already applied is fine.
8060///
8061/// Only `busy_timeout` is set here — NOT `foreign_keys`/`journal_mode`, because
8062/// `foreign_keys = ON` can break table-recreating migrations.
8063///
8064/// # Errors
8065///
8066/// Returns the underlying [`diesel::ConnectionError`] when the database cannot
8067/// be opened, or [`diesel::ConnectionError::CouldntSetupConfiguration`] if the
8068/// `busy_timeout` pragma cannot be applied.
8069#[cfg(feature = "sqlite")]
8070pub(crate) fn establish_sqlite_migration_connection(
8071 database_url: &str,
8072) -> Result<diesel::SqliteConnection, diesel::ConnectionError> {
8073 use diesel::Connection as _;
8074 use diesel::connection::SimpleConnection as _;
8075 let mut conn = diesel::SqliteConnection::establish(&normalize_sqlite_target(database_url))?;
8076 conn.batch_execute("PRAGMA busy_timeout = 5000;")
8077 .map_err(diesel::ConnectionError::CouldntSetupConfiguration)?;
8078 Ok(conn)
8079}
8080
8081#[cfg(all(test, feature = "postgres"))]
8082mod migration_connection_tests {
8083 use super::migration_connection_needs_rustls;
8084
8085 #[test]
8086 fn migration_path_selection_follows_the_tls_posture() {
8087 // NoTls URLs keep the historical native libpq path byte-identical.
8088 for url in [
8089 "postgres://u@h/db",
8090 "postgres://u@h/db?sslmode=disable",
8091 "postgres://u@h/db?sslmode=prefer",
8092 "host=db user=u",
8093 ] {
8094 assert!(
8095 !migration_connection_needs_rustls(url),
8096 "must stay on the native path: {url}"
8097 );
8098 }
8099 // TLS-requiring strings (URL and keyword forms) — and unsupported
8100 // postures, which must fail with the TLS module's guidance instead
8101 // of libpq's — go through the rustls wrapper.
8102 for url in [
8103 "postgres://u@h/db?sslmode=require",
8104 "postgres://u@h/db?sslmode=verify-full",
8105 "postgres://u@h/db?sslmode=verify-full&sslrootcert=/etc/ca.pem",
8106 "postgres://u@h/db?sslmode=verify-ca",
8107 "host=db user=u sslmode=require",
8108 "host=db sslmode = 'verify-full'",
8109 ] {
8110 assert!(
8111 migration_connection_needs_rustls(url),
8112 "must use the rustls wrapper: {url}"
8113 );
8114 }
8115 }
8116
8117 // Regression: TLS-enabled migrations panicked with "Cannot start a runtime from
8118 // within a runtime" when called directly from an app's own async `on_startup` hook
8119 // rather than from `spawn_blocking`. The rustls arm's connect used to `block_on` on
8120 // the calling thread, which is one of the ambient runtime's worker threads while an
8121 // `.on_startup(|state| async move { ... })` body executes. TLS off (dev) never took
8122 // that arm, so the bug surfaced only with `sslmode=require`/`verify-full`.
8123 //
8124 // This exercises `crate::migrate::pending_migrations`, the public entry point apps
8125 // call, rather than `establish_migration_connection` directly: that bare function is
8126 // safe only when the whole connect-and-query sequence runs on a thread that never
8127 // entered a runtime, a guarantee `with_migration_connection!` provides by
8128 // construction. Calling it directly, as this test used to, no longer represents how
8129 // any real caller uses it.
8130 #[tokio::test(flavor = "multi_thread")]
8131 async fn migration_functions_do_not_panic_from_an_async_caller() {
8132 const MIGRATIONS: crate::migrate::EmbeddedMigrations =
8133 diesel_migrations::embed_migrations!("tests/fixtures/plugin_migrations_ok");
8134 // Calling this directly from an `async fn` body -- not wrapped in
8135 // `spawn_blocking` -- mirrors the shape of an app's own async
8136 // `on_startup` hook calling `autumn_web::migrate::run_pending(...)`.
8137 // On a multi-threaded runtime (axum's default), matching production.
8138 let url = "postgres://invalid_user:invalid_password@0.0.0.0:1/invalid_db?sslmode=require";
8139 let result = crate::migrate::pending_migrations(url, MIGRATIONS);
8140 assert!(
8141 result.is_err(),
8142 "an unreachable TLS-requiring host must fail with a connection error \
8143 (not panic on nested block_on)"
8144 );
8145 }
8146
8147 // Regression guard: `#[tokio::test]`'s default flavor is `current_thread`, so exactly
8148 // one thread drives this runtime's I/O and timers, and that thread is the one about
8149 // to call a migration function directly — mirroring an app's own `.on_startup` hook
8150 // body, not `spawn_blocking`. If `with_migration_connection!` ever went back to
8151 // running the connect, or the query, on the calling thread instead of a freshly
8152 // spawned `thread::scope` thread, this would deadlock rather than fail fast: nothing
8153 // would be left free to drive the connect's I/O. A watchdog thread bounds that —
8154 // there is no clean way to cancel a genuinely stuck OS thread, so a real regression
8155 // hard-exits the process instead of hanging the suite.
8156 #[tokio::test]
8157 async fn migration_functions_do_not_hang_on_a_current_thread_runtime() {
8158 const MIGRATIONS: crate::migrate::EmbeddedMigrations =
8159 diesel_migrations::embed_migrations!("tests/fixtures/plugin_migrations_ok");
8160 // A plain `sleep`-then-`exit` watchdog can't be cancelled once
8161 // spawned, so a detached one would keep counting down after this
8162 // test returns and could later `process::exit` the shared test
8163 // binary mid-suite, on an unrelated test, if the suite is still
8164 // running 20s after this test passed. A channel lets the main
8165 // thread signal "the call returned, stand down" so the watchdog only
8166 // ever fires on a genuine hang.
8167 let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
8168 let watchdog = std::thread::spawn(move || {
8169 if done_rx
8170 .recv_timeout(std::time::Duration::from_secs(20))
8171 .is_err()
8172 {
8173 eprintln!(
8174 "migration_functions_do_not_hang_on_a_current_thread_runtime: \
8175 timed out after 20s -- the TLS connect hung instead of failing fast"
8176 );
8177 std::process::exit(101);
8178 }
8179 });
8180
8181 let url = "postgres://invalid_user:invalid_password@0.0.0.0:1/invalid_db?sslmode=require";
8182 let result = crate::migrate::pending_migrations(url, MIGRATIONS);
8183 assert!(
8184 result.is_err(),
8185 "an unreachable TLS-requiring host must fail with a connection error"
8186 );
8187
8188 // Stand the watchdog down and wait for it to exit cleanly, rather
8189 // than leaving a live "exit(101) in 20s" timer running in the shared
8190 // test binary after this test has already passed.
8191 let _ = done_tx.send(());
8192 let _ = watchdog.join();
8193 }
8194}