Skip to main content

teamcraft/
db.rs

1//! Database pool and migrations.
2
3use std::time::Duration;
4
5use anyhow::{Context, Result};
6use sqlx::{
7    migrate::Migrator,
8    postgres::{PgConnectOptions, PgPoolOptions},
9    Connection, PgConnection, PgPool,
10};
11
12use crate::config::Config;
13
14/// Migrations from `./migrations`, embedded into the binary at compile time.
15pub static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
16
17/// How long start-up waits for PostgreSQL (e.g. while its container boots,
18/// which takes a few seconds) before it gives up with the reason.
19const STARTUP_WAIT: Duration = Duration::from_secs(20);
20
21/// Session settings of every TeamCraft connection, sent with the startup
22/// message (no extra round trip):
23///
24/// * `jit = off` – TeamCraft runs short interactive queries; on a large
25///   vault the planner's cost estimates cross `jit_above_cost` and the JIT
26///   compilation (100–400 ms in the scale check) costs far more than it
27///   saves. Analytical work on the same server keeps its own setting.
28/// * `plan_cache_mode = force_custom_plan` – sqlx keeps prepared
29///   statements per connection; after five runs PostgreSQL may switch a
30///   statement to a generic plan that ignores the actual parameters. For
31///   `= ANY($1)` over a BOM level or the parents of a standard part, the
32///   array holds one id or thousands, and a generic plan chosen for one is
33///   wrong for the other (the scale check saw 70 ms become 400 ms). Planning
34///   each run costs far less than such a plan.
35pub fn session_options(options: PgConnectOptions) -> PgConnectOptions {
36    options.options([("jit", "off"), ("plan_cache_mode", "force_custom_plan")])
37}
38
39pub async fn connect(config: &Config) -> Result<PgPool> {
40    wait_for_database(config).await?;
41    PgPoolOptions::new()
42        .max_connections(config.db_max_connections)
43        .acquire_timeout(Duration::from_secs(10))
44        .connect_with(session_options(config.database.clone()))
45        .await
46        .with_context(|| {
47            format!(
48                "cannot connect to PostgreSQL at {}",
49                config.database_display
50            )
51        })
52}
53
54/// Opens single probe connections until one succeeds or [`STARTUP_WAIT`]
55/// elapsed, so the real cause (refused, auth failed, …) is reported.
56async fn wait_for_database(config: &Config) -> Result<()> {
57    let started = std::time::Instant::now();
58    let mut attempt = 0u32;
59    loop {
60        attempt += 1;
61        let probe = tokio::time::timeout(
62            Duration::from_secs(5),
63            PgConnection::connect_with(&config.database),
64        )
65        .await;
66        let error = match probe {
67            Ok(Ok(conn)) => {
68                let _ = conn.close().await;
69                return Ok(());
70            }
71            Ok(Err(e)) => e.to_string(),
72            Err(_) => "connection attempt timed out".to_string(),
73        };
74        // Wrong credentials or database names do not fix themselves.
75        let permanent = error.contains("password authentication failed")
76            || error.contains("does not exist")
77            || error.contains("no pg_hba.conf entry");
78        if permanent || started.elapsed() >= STARTUP_WAIT {
79            anyhow::bail!(
80                "cannot connect to PostgreSQL at {}: {error}\n\
81                 hint: start the development database with `docker compose up -d db` \
82                 or set TEAMCRAFT_DATABASE_URL (see docs: Configuration)",
83                config.database_display
84            );
85        }
86        if attempt == 1 {
87            tracing::warn!(
88                database = %config.database_display,
89                %error,
90                "database not reachable yet, retrying for up to {}s (start it with `docker compose up -d db`)",
91                STARTUP_WAIT.as_secs()
92            );
93        }
94        tokio::time::sleep(Duration::from_secs(1)).await;
95    }
96}
97
98/// Migrations whose text was edited before the first release (comments and
99/// seed data only, same schema). A database that applied the old text takes
100/// the new checksum instead of failing with "migration … was previously
101/// applied but has been modified".
102const EDITED_BEFORE_RELEASE: [i64; 5] = [2, 3, 5, 11, 12];
103
104/// Oldest supported PostgreSQL major version. CI tests this one and the
105/// newest; older servers are refused at start-up with a clear message
106/// instead of failing later on a query.
107pub const MIN_POSTGRES_MAJOR: i64 = 16;
108
109/// Checks `SHOW server_version_num` (e.g. `170004` = 17.4).
110fn check_server_version(version_num: &str) -> Result<()> {
111    let num: i64 = version_num
112        .trim()
113        .parse()
114        .with_context(|| format!("unexpected PostgreSQL version number '{version_num}'"))?;
115    let major = num / 10_000;
116    if major < MIN_POSTGRES_MAJOR {
117        anyhow::bail!(
118            "TeamCraft needs PostgreSQL {MIN_POSTGRES_MAJOR} or newer, but the server runs \
119             PostgreSQL {major} – upgrade the database server (pg_upgrade or dump and restore)"
120        );
121    }
122    Ok(())
123}
124
125pub async fn migrate(pool: &PgPool) -> Result<()> {
126    let version: String = sqlx::query_scalar("SHOW server_version_num")
127        .fetch_one(pool)
128        .await
129        .context("reading the PostgreSQL version failed")?;
130    check_server_version(&version)?;
131    accept_edits_before_release(pool)
132        .await
133        .context("updating the checksums of edited migrations failed")?;
134    // sqlx takes a session advisory lock and returns early on a refused
135    // migration (changed checksum, dirty version) without releasing it: on a
136    // pooled connection the lock would outlive the error and the next
137    // migrate() would wait for it for ever. One dedicated connection, closed
138    // on error, ends the session and its lock.
139    let mut conn = pool
140        .acquire()
141        .await
142        .context("connecting for the migrations failed")?;
143    let result = MIGRATOR.run(&mut *conn).await;
144    if result.is_err() {
145        let _ = conn.detach().close().await;
146    }
147    result.context("running database migrations failed")
148}
149
150async fn accept_edits_before_release(pool: &PgPool) -> Result<()> {
151    let table: Option<String> = sqlx::query_scalar("SELECT to_regclass('_sqlx_migrations')::text")
152        .fetch_one(pool)
153        .await?;
154    if table.is_none() {
155        return Ok(()); // a new database
156    }
157    for migration in MIGRATOR
158        .iter()
159        .filter(|m| EDITED_BEFORE_RELEASE.contains(&m.version))
160    {
161        let updated = sqlx::query(
162            "UPDATE _sqlx_migrations SET checksum = $1 \
163             WHERE version = $2 AND success AND checksum <> $1",
164        )
165        .bind(migration.checksum.as_ref())
166        .bind(migration.version)
167        .execute(pool)
168        .await?
169        .rows_affected();
170        if updated > 0 {
171            tracing::info!(
172                version = migration.version,
173                "accepted the edited text of an applied migration"
174            );
175        }
176    }
177    Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    /// sqlx records a migration by the number before the `_`: two files with
185    /// the same number make every migration run fail ("duplicate key …
186    /// _sqlx_migrations_pkey"). Parallel pull requests must renumber.
187    #[test]
188    fn migration_numbers_are_unique() {
189        let mut seen = std::collections::HashMap::new();
190        for m in MIGRATOR.iter() {
191            if let Some(other) = seen.insert(m.version, m.description.clone()) {
192                panic!(
193                    "migration number {:04} is used twice: '{}' and '{}' – renumber the newer one",
194                    m.version, other, m.description
195                );
196            }
197        }
198    }
199
200    #[test]
201    fn supported_postgres_versions() {
202        for ok in ["160000", "160009", "170004", "180001\n", "190000"] {
203            assert!(check_server_version(ok).is_ok(), "{ok}");
204        }
205        for old in ["140013", "150010", "90624"] {
206            let err = check_server_version(old).unwrap_err().to_string();
207            assert!(err.contains("PostgreSQL 16 or newer"), "{err}");
208        }
209        assert!(check_server_version("17.4").is_err());
210    }
211}