Skip to main content

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) = &params.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 &params.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                    &params.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(&registry), 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(&registry), 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}