Skip to main content

renox_core/db/
mod.rs

1//! Database access (SQLite, or PostgreSQL with the `postgres` feature): the
2//! connection pool, raw SQL, models, queries, pagination, migrations and
3//! factories.
4
5mod conn;
6mod encrypted;
7mod error;
8mod factory;
9mod from_row;
10mod json;
11mod key;
12mod migrate;
13mod model;
14mod paginate;
15mod query;
16mod query_log;
17pub mod relations;
18pub mod search;
19mod value;
20
21use std::str::FromStr;
22use std::time::Duration;
23
24use anyhow::Context;
25use axum::extract::FromRef;
26use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
27
28#[doc(hidden)]
29pub use conn::{Conn, bounds};
30pub use conn::{Db, Dialect, Executor, FromDb, Row, RowIndex, Sql, Transaction, sql};
31pub(crate) use conn::{RowInner, SchemaEpoch, script};
32pub use encrypted::{Encrypted, Unsealed};
33pub use error::DbError;
34pub use factory::{Factory, FactoryBuilder};
35pub use from_row::FromRow;
36pub use json::Json;
37pub use key::{InvalidUlid, ModelKey, Ulid};
38pub use migrate::{Migration, MigrationStatus};
39pub(crate) use migrate::{Migrator, framework_migration};
40pub use model::{Model, ModelHooks};
41pub use paginate::{CursorPage, Page, Paginated, SimplePage};
42pub use query::{Number, Query};
43pub use query_log::capture_queries;
44/// sqlx, for what Renox's own API doesn't cover: `Db::sqlite()`, `Db::postgres()`,
45/// `Row::sqlite()`, `Row::postgres()` and `DbError::sqlx()` hand out its types.
46/// sqlx may move to a new version in a minor Renox release; see docs/stability.md.
47pub use sqlx;
48pub use value::{DbValue, ToDbValue};
49
50use crate::{AppState, Config};
51
52/// The timestamp type for `created_at`, `updated_at` and `deleted_at`.
53pub type DateTime = chrono::DateTime<chrono::Utc>;
54
55/// Unix seconds (the queue's and webhooks' columns) as a `DateTime`.
56pub(crate) fn from_unix(seconds: i64) -> DateTime {
57    chrono::DateTime::from_timestamp(seconds, 0).unwrap_or_default()
58}
59
60/// The current time, as stored in timestamps: to the microsecond, which is
61/// what PostgreSQL keeps, so a saved model equals the same row read back.
62pub fn now() -> DateTime {
63    use chrono::SubsecRound;
64    chrono::DateTime::<chrono::Utc>::from(crate::clock::system_now()).trunc_subsecs(6)
65}
66
67impl FromRef<AppState> for Db {
68    fn from_ref(state: &AppState) -> Self {
69        state.db.clone()
70    }
71}
72
73/// Opens the pool for `DATABASE_URL`. `postgres://` and `postgresql://` URLs
74/// need the `postgres` feature, `sqlite:` URLs open SQLite, and any other
75/// scheme is refused (so a typo doesn't become a SQLite file).
76///
77/// An in-memory SQLite database (what tests use) is swapped for a fresh
78/// schema in `TEST_DATABASE_URL` when that is set to a PostgreSQL URL (in the
79/// environment or in `.env`), so a test suite can run against PostgreSQL
80/// unchanged.
81pub(crate) async fn connect(config: &Config) -> anyhow::Result<Db> {
82    let url = &config.database_url;
83    if config.database_pool_size == 0 {
84        anyhow::bail!("DATABASE_POOL_SIZE must be at least 1");
85    }
86    if is_postgres(url) {
87        return connect_postgres(url, config, false).await;
88    }
89    if !url.starts_with("sqlite:") {
90        // A typo such as `postgress://` must not become a SQLite file.
91        anyhow::bail!(
92            "DATABASE_URL `{}` must start with sqlite:, postgres:// or postgresql://",
93            redact(url)
94        );
95    }
96    if is_memory(url)
97        && let Some(test_url) = test_database_url().filter(|u| is_postgres(u))
98    {
99        return connect_postgres(&test_url, config, true).await;
100    }
101    let schema = SchemaEpoch::default();
102    let pool = connect_sqlite(config, schema.clone()).await?;
103    Ok(Db::from_sqlite(pool, schema))
104}
105
106/// `TEST_DATABASE_URL` from the environment, else from `.env` in the current
107/// directory (without loading the rest of `.env` into the environment).
108fn test_database_url() -> Option<String> {
109    test_database_url_in(
110        std::env::var("TEST_DATABASE_URL").ok(),
111        std::path::Path::new(".env"),
112    )
113}
114
115/// `env` when set, else `TEST_DATABASE_URL` in the file `dotenv`.
116fn test_database_url_in(env: Option<String>, dotenv: &std::path::Path) -> Option<String> {
117    env.or_else(|| {
118        dotenvy::from_path_iter(dotenv)
119            .ok()?
120            .flatten()
121            .find(|(key, _)| key == "TEST_DATABASE_URL")
122            .map(|(_, url)| url)
123    })
124}
125
126fn is_postgres(url: &str) -> bool {
127    url.starts_with("postgres://") || url.starts_with("postgresql://")
128}
129
130fn is_memory(url: &str) -> bool {
131    url.contains(":memory:") || url.contains("mode=memory")
132}
133
134/// Connects to PostgreSQL; with `fresh_schema`, in a new, empty schema of its
135/// own (named `renox_test_…`, left behind for inspection).
136#[cfg(feature = "postgres")]
137async fn connect_postgres(url: &str, config: &Config, fresh_schema: bool) -> anyhow::Result<Db> {
138    use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
139    use sqlx::{ConnectOptions, Connection};
140
141    let failed = || format!("could not connect to the database at `{}`", redact(url));
142    let mut options = PgConnectOptions::from_str(url).with_context(failed)?;
143    if fresh_schema {
144        static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
145        let nanos = std::time::SystemTime::now()
146            .duration_since(std::time::UNIX_EPOCH)
147            .unwrap_or_default()
148            .as_nanos();
149        let schema = format!(
150            "renox_test_{}_{}_{nanos}",
151            std::process::id(),
152            COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
153        );
154        let mut conn = options.connect().await.with_context(failed)?;
155        sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
156            "CREATE SCHEMA {}",
157            quote(&schema)
158        )))
159        .execute(&mut conn)
160        .await
161        .context("could not create a schema for the test")?;
162        conn.close().await.ok();
163        options = options.options([("search_path", schema)]);
164    }
165    if let Some(limit) = config.database_statement_timeout {
166        options = options.options([("statement_timeout", format!("{}ms", limit.as_millis()))]);
167    }
168    let pool_size = config.database_pool_size;
169    // Test suites boot many apps at once; a few connections each keeps them
170    // under the server's limit (100 by default).
171    let pool_size = if fresh_schema {
172        pool_size.min(3)
173    } else {
174        pool_size
175    };
176    let schema = SchemaEpoch::default();
177    let epoch = schema.clone();
178    let pool = PgPoolOptions::new()
179        .max_connections(pool_size)
180        .acquire_timeout(config.database_acquire_timeout)
181        .before_acquire(move |_, meta| {
182            let fresh = !epoch.is_stale(meta.age);
183            Box::pin(async move { Ok(fresh) })
184        })
185        .connect_with(options)
186        .await
187        .with_context(failed)?;
188    Ok(Db::from_postgres(pool, schema))
189}
190
191#[cfg(not(feature = "postgres"))]
192async fn connect_postgres(_url: &str, _config: &Config, _fresh_schema: bool) -> anyhow::Result<Db> {
193    anyhow::bail!(
194        "the database URL points to PostgreSQL, but this build has no PostgreSQL support; \
195         enable the `postgres` feature of `renox`"
196    )
197}
198
199/// The URL with its password hidden, for error messages.
200fn redact(url: &str) -> String {
201    let Some((scheme, rest)) = url.split_once("://") else {
202        return url.to_owned();
203    };
204    match rest.split_once('@') {
205        Some((user, host)) => {
206            let user = user.split_once(':').map_or(user, |(name, _)| name);
207            format!("{scheme}://{user}:***@{host}")
208        }
209        None => url.to_owned(),
210    }
211}
212
213/// How long SQLite waits for a lock before answering "database is locked".
214const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
215
216/// Opens a SQLite pool, creating the file and its directory if needed. File
217/// databases use WAL mode; every connection enforces foreign keys.
218async fn connect_sqlite(config: &Config, schema: SchemaEpoch) -> anyhow::Result<sqlx::SqlitePool> {
219    let url = &config.database_url;
220    let in_memory = is_memory(url);
221
222    let mut options = SqliteConnectOptions::from_str(url)
223        .with_context(|| format!("DATABASE_URL `{url}` is not a valid SQLite URL"))?
224        .create_if_missing(true)
225        .foreign_keys(true)
226        .busy_timeout(BUSY_TIMEOUT);
227    // One busy wait can take the whole `BUSY_TIMEOUT`, so busy errors get that
228    // much on top of the acquire timeout: a long wait still leaves room to
229    // try again (#178).
230    let busy_budget = config.database_acquire_timeout + BUSY_TIMEOUT;
231    if !in_memory {
232        options = options
233            .journal_mode(SqliteJournalMode::Wal)
234            .synchronous(SqliteSynchronous::Normal);
235        if let Some(dir) = options
236            .get_filename()
237            .parent()
238            .filter(|d| !d.as_os_str().is_empty())
239        {
240            std::fs::create_dir_all(dir)
241                .with_context(|| format!("could not create {}", dir.display()))?;
242        }
243        prepare_file(&options, busy_budget).await.with_context(|| {
244            format!("could not open the database at `{url}` (switching it to WAL)")
245        })?;
246    }
247
248    // Each connection to `:memory:` is a separate database, so keep exactly one.
249    let pool = if in_memory {
250        SqlitePoolOptions::new()
251            .max_connections(1)
252            .min_connections(1)
253            .idle_timeout(None)
254            .max_lifetime(None)
255    } else {
256        // Connections opened before a migration would read the old schema.
257        SqlitePoolOptions::new()
258            .max_connections(config.database_pool_size)
259            .before_acquire(move |_, meta| {
260                let fresh = !schema.is_stale(meta.age);
261                Box::pin(async move { Ok(fresh) })
262            })
263    }
264    .acquire_timeout(if in_memory {
265        // The one connection is busy for a moment at most, unless a task
266        // waits for itself (a query through `&db` while its own transaction
267        // is open): fail such a test fast instead of letting it hang.
268        config.database_acquire_timeout.min(Duration::from_secs(2))
269    } else {
270        config.database_acquire_timeout
271    });
272    let pool = if in_memory {
273        // sqlx opens the first connection within `acquire_timeout` too, and on a
274        // busy machine that can take longer than the 2 s above: try again until
275        // the configured timeout has passed.
276        let budget = config.database_acquire_timeout;
277        retry_while(budget, is_pool_timeout, || {
278            pool.clone().connect_with(options.clone())
279        })
280        .await
281    } else {
282        // Another process opening or closing the same file can make SQLite
283        // answer "database is locked" at once (#163), or wait in the busy
284        // handler past the pool's deadline.
285        retry_while(busy_budget, is_busy_or_pool_timeout, || {
286            pool.clone().connect_with(options.clone())
287        })
288        .await
289    };
290    pool.with_context(|| format!("could not open the database at `{url}`"))
291}
292
293/// Opens a file database once before its pool does, so the file exists and is
294/// in WAL mode. Switching to WAL takes an exclusive lock, so processes opening
295/// a brand-new file together (`serve` and `route:list`, say) contend for it:
296/// SQLite answers some of them "database is locked" at once, and a wait in the
297/// busy handler could outlast the pool's acquire timeout (#163). Busy errors
298/// are retried until `budget` has passed; this one connection has no acquire
299/// deadline. Once the file is in WAL mode, the pool's connections don't need
300/// the exclusive lock.
301async fn prepare_file(options: &SqliteConnectOptions, budget: Duration) -> Result<(), sqlx::Error> {
302    use sqlx::{ConnectOptions, Connection};
303
304    // A short busy wait per try: connections switching a new file to WAL
305    // together can each wait for the others' lock, and with the usual five
306    // seconds the first try alone used up the whole budget (#163).
307    let options = options.clone().busy_timeout(Duration::from_millis(100));
308    let conn = retry_while(budget, is_busy, || options.connect()).await?;
309    conn.close().await
310}
311
312/// Whether SQLite answered `SQLITE_BUSY` or `SQLITE_LOCKED` (or one of their
313/// extended codes).
314fn is_busy(error: &sqlx::Error) -> bool {
315    let Some(code) = error
316        .as_database_error()
317        .and_then(|e| e.code())
318        .and_then(|code| code.parse::<i32>().ok())
319    else {
320        return false;
321    };
322    matches!(code & 0xff, 5 | 6)
323}
324
325fn is_busy_or_pool_timeout(error: &sqlx::Error) -> bool {
326    is_busy(error) || is_pool_timeout(error)
327}
328
329fn is_pool_timeout(error: &sqlx::Error) -> bool {
330    matches!(error, sqlx::Error::PoolTimedOut)
331}
332
333/// Runs `open` again, after a short pause, while it fails with an error
334/// `retryable` accepts and `budget` hasn't passed since the first try (but at
335/// least three times); any other result is returned as it is.
336async fn retry_while<T, F>(
337    budget: Duration,
338    retryable: fn(&sqlx::Error) -> bool,
339    mut open: impl FnMut() -> F,
340) -> Result<T, sqlx::Error>
341where
342    F: std::future::Future<Output = Result<T, sqlx::Error>>,
343{
344    let start = tokio::time::Instant::now();
345    let mut tries = 0;
346    loop {
347        tries += 1;
348        match open().await {
349            // At least three tries: one try can wait out a whole busy timeout
350            // (as long as the budget) before it fails (#163).
351            Err(e) if retryable(&e) && (start.elapsed() < budget || tries < 3) => {
352                tracing::debug!("opening the database failed ({e}); trying again");
353                // 25–75 ms, so processes that collided don't collide again.
354                let jitter = u64::from(rand::random::<u8>()) * 50 / 255;
355                tokio::time::sleep(Duration::from_millis(25 + jitter)).await;
356            }
357            result => return result,
358        }
359    }
360}
361
362/// Quotes an identifier (the same on SQLite and PostgreSQL), e.g. `order` -> `"order"`.
363pub(crate) fn quote(ident: &str) -> String {
364    format!("\"{}\"", ident.replace('"', "\"\""))
365}
366
367/// Implements sqlx's traits for a type stored as text through `Display` and
368/// `FromStr` (what `#[derive(DbEnum)]` generates), for every database this
369/// build of Renox supports. Chosen when renox-core is compiled, so apps don't
370/// need to know which features it has.
371#[doc(hidden)]
372#[cfg(not(feature = "postgres"))]
373#[macro_export]
374macro_rules! __db_text_type {
375    ($t:ty) => {
376        $crate::__db_text_type_for!($t, $crate::__sqlx::sqlite::Sqlite);
377    };
378}
379
380#[doc(hidden)]
381#[cfg(feature = "postgres")]
382#[macro_export]
383macro_rules! __db_text_type {
384    ($t:ty) => {
385        $crate::__db_text_type_for!($t, $crate::__sqlx::sqlite::Sqlite);
386        $crate::__db_text_type_for!($t, $crate::__sqlx::postgres::Postgres);
387    };
388}
389
390#[doc(hidden)]
391#[macro_export]
392macro_rules! __db_text_type_for {
393    ($t:ty, $db:ty) => {
394        impl $crate::__sqlx::Type<$db> for $t {
395            fn type_info() -> <$db as $crate::__sqlx::Database>::TypeInfo {
396                <::std::string::String as $crate::__sqlx::Type<$db>>::type_info()
397            }
398
399            fn compatible(ty: &<$db as $crate::__sqlx::Database>::TypeInfo) -> bool {
400                <::std::string::String as $crate::__sqlx::Type<$db>>::compatible(ty)
401            }
402        }
403
404        impl<'r> $crate::__sqlx::Decode<'r, $db> for $t {
405            fn decode(
406                value: <$db as $crate::__sqlx::Database>::ValueRef<'r>,
407            ) -> ::std::result::Result<Self, $crate::__sqlx::error::BoxDynError> {
408                let text = <::std::string::String as $crate::__sqlx::Decode<$db>>::decode(value)?;
409                text.parse::<$t>().map_err(::std::convert::Into::into)
410            }
411        }
412    };
413}
414
415#[cfg(test)]
416mod tests {
417    use std::sync::atomic::{AtomicU32, Ordering};
418    use std::time::Duration;
419
420    use super::{
421        is_busy, is_busy_or_pool_timeout, is_pool_timeout, redact, retry_while,
422        test_database_url_in,
423    };
424    use crate::db::error::fake;
425
426    #[test]
427    fn urls_lose_only_their_password() {
428        assert_eq!(
429            redact("postgres://app:secret@db:5432/app"),
430            "postgres://app:***@db:5432/app"
431        );
432        // A user without a password still gets the mask; no `@` or no
433        // scheme leaves the URL as it is.
434        assert_eq!(redact("postgres://app@db/app"), "postgres://app:***@db/app");
435        assert_eq!(redact("postgres://db/app"), "postgres://db/app");
436        assert_eq!(redact("sqlite:app.db"), "sqlite:app.db");
437    }
438
439    #[test]
440    fn busy_errors_and_pool_timeouts() {
441        for code in ["5", "6", "517", "261"] {
442            assert!(is_busy(&fake::coded(Some(code))), "{code}");
443        }
444        assert!(!is_busy(&fake::coded(Some("19"))));
445        assert!(!is_busy(&fake::coded(Some("40001")))); // not a number
446        assert!(!is_busy(&fake::coded(None)));
447        assert!(!is_busy(&sqlx::Error::PoolTimedOut));
448        assert!(is_busy_or_pool_timeout(&sqlx::Error::PoolTimedOut));
449        assert!(is_busy_or_pool_timeout(&fake::coded(Some("5"))));
450        assert!(!is_busy_or_pool_timeout(&sqlx::Error::PoolClosed));
451    }
452
453    /// The environment wins; else `.env` is read for that one key; a
454    /// missing file or key gives nothing.
455    #[test]
456    fn the_test_database_url_comes_from_the_environment_or_dotenv() {
457        let dir = tempfile::tempdir().unwrap();
458        let dotenv = dir.path().join(".env");
459        assert_eq!(test_database_url_in(None, &dotenv), None);
460        std::fs::write(
461            &dotenv,
462            "APP_NAME=x\nTEST_DATABASE_URL=postgres://t@localhost/t\n",
463        )
464        .unwrap();
465        assert_eq!(
466            test_database_url_in(None, &dotenv).as_deref(),
467            Some("postgres://t@localhost/t")
468        );
469        assert_eq!(
470            test_database_url_in(Some("postgres://env/e".into()), &dotenv).as_deref(),
471            Some("postgres://env/e")
472        );
473        std::fs::write(&dotenv, "APP_NAME=x\n").unwrap();
474        assert_eq!(test_database_url_in(None, &dotenv), None);
475    }
476
477    /// A file database in directories that don't exist yet: they're made.
478    /// An in-memory one waits at most 2 s for its one connection.
479    #[tokio::test]
480    async fn sqlite_makes_directories_and_caps_the_in_memory_wait() {
481        let dir = tempfile::tempdir().unwrap();
482        let config = crate::Config {
483            database_url: format!("sqlite://{}/a/b/app.db", dir.path().display()),
484            ..crate::Config::default()
485        };
486        let pool = super::connect_sqlite(&config, Default::default())
487            .await
488            .unwrap();
489        assert!(dir.path().join("a/b/app.db").exists());
490        pool.close().await;
491
492        let config = crate::Config {
493            database_url: "sqlite::memory:".into(),
494            database_acquire_timeout: Duration::from_secs(30),
495            ..crate::Config::default()
496        };
497        let pool = super::connect_sqlite(&config, Default::default())
498            .await
499            .unwrap();
500        assert_eq!(pool.options().get_acquire_timeout(), Duration::from_secs(2));
501        assert_eq!(pool.options().get_max_connections(), 1);
502    }
503
504    /// Pools opening one brand-new file database at once all open it (#163).
505    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
506    async fn pools_opening_a_new_file_together_all_open_it() {
507        for _ in 0..10 {
508            let dir = tempfile::tempdir().unwrap();
509            let config = crate::Config {
510                database_url: format!("sqlite://{}/app.db", dir.path().display()),
511                database_acquire_timeout: Duration::from_secs(5),
512                ..crate::Config::default()
513            };
514            let opening: Vec<_> = (0..8)
515                .map(|_| {
516                    let config = config.clone();
517                    tokio::spawn(async move {
518                        super::connect_sqlite(&config, Default::default())
519                            .await
520                            .map(|_| ())
521                    })
522                })
523                .collect();
524            for task in opening {
525                task.await.unwrap().expect("every pool opens the new file");
526            }
527        }
528    }
529
530    /// A slow first connection doesn't fail an in-memory app's boot (#144).
531    #[tokio::test]
532    async fn opening_retries_pool_timeouts_within_the_budget() {
533        let tries = AtomicU32::new(0);
534        let opened = retry_while(Duration::from_secs(30), is_pool_timeout, || async {
535            match tries.fetch_add(1, Ordering::SeqCst) {
536                0 | 1 => Err(sqlx::Error::PoolTimedOut),
537                _ => Ok("pool"),
538            }
539        })
540        .await;
541        assert_eq!(opened.unwrap(), "pool");
542        assert_eq!(tries.load(Ordering::SeqCst), 3);
543    }
544
545    #[tokio::test]
546    async fn opening_gives_up_after_the_budget_and_on_other_errors() {
547        let tries = AtomicU32::new(0);
548        let opened: Result<(), _> = retry_while(Duration::ZERO, is_pool_timeout, || async {
549            tries.fetch_add(1, Ordering::SeqCst);
550            Err(sqlx::Error::PoolTimedOut)
551        })
552        .await;
553        assert!(matches!(opened, Err(sqlx::Error::PoolTimedOut)));
554        // Three tries even with no budget left: one try can take a whole busy
555        // timeout, which used to leave no time to try again (#163).
556        assert_eq!(
557            tries.load(Ordering::SeqCst),
558            3,
559            "three tries, then no retry past the budget"
560        );
561
562        let tries = AtomicU32::new(0);
563        let opened: Result<(), _> =
564            retry_while(Duration::from_secs(30), is_pool_timeout, || async {
565                tries.fetch_add(1, Ordering::SeqCst);
566                Err(sqlx::Error::PoolClosed)
567            })
568            .await;
569        assert!(matches!(opened, Err(sqlx::Error::PoolClosed)));
570        assert_eq!(
571            tries.load(Ordering::SeqCst),
572            1,
573            "other errors aren't retried"
574        );
575    }
576}