Skip to main content

xtask/
postgres.rs

1//! A throwaway Postgres for the lifetime of one command.
2//!
3//! Replaces the `sqlx-cli` shell-out: the database is created and migrated
4//! in-process through `sqlx`, using the *same* `migrations/` directory the
5//! server embeds via `server::db::MIGRATOR`. One set of migrations, one
6//! migrator, no second implementation that can drift.
7//!
8//! `initdb` and `pg_ctl` remain external programs because they are external
9//! programs; there is no library form of them.
10
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14use anyhow::{Context, Result, bail};
15use sqlx::migrate::Migrator;
16
17/// The project's migrations, embedded at compile time from the same directory
18/// `server::db::MIGRATOR` reads. Embedding needs no database, which is what
19/// lets this binary be built before the server it goes on to test.
20static MIGRATOR: Migrator = sqlx::migrate!("../migrations");
21
22/// Ports tried in order. No allocator to lean on, and `pg_ctl -w` exits
23/// non-zero when the server fails to come up, so a busy port is self-correcting.
24const PORT_RANGE: std::ops::Range<u16> = 54320..54340;
25
26/// Bounds on how long the server will let a client misbehave.
27///
28/// NOT a fix for the hang seen on 2026-08-28, and worth saying so: there the
29/// server sat `idle / ClientRead` — it had answered and was waiting for the
30/// client, so there was no statement to time out. These bound the failures they
31/// can bound: a runaway query, and a transaction abandoned mid-flight holding
32/// locks. Both otherwise sit until the job's own deadline kills it, an hour
33/// later, with no indication of which test was responsible.
34const TIMEOUTS: &[&str] = &[
35    "-c",
36    "statement_timeout=120s",
37    "-c",
38    "idle_in_transaction_session_timeout=300s",
39];
40
41/// Durability is off on purpose. This cluster is initdb'd moments earlier and
42/// dies with the container, so there is nothing for it to protect — every fsync
43/// is pure cost, paid on cloud block storage, and 275 of the tests create and
44/// migrate a database of their own. Measured worth ~2m30s of a test run.
45const DURABILITY_OFF: &[&str] = &[
46    "-c",
47    "fsync=off",
48    "-c",
49    "synchronous_commit=off",
50    "-c",
51    "full_page_writes=off",
52    "-c",
53    "autovacuum=off",
54];
55
56/// A running cluster that stops itself when dropped.
57pub struct Postgres {
58    data_dir: PathBuf,
59    port: u16,
60    /// Kept so the temp dir outlives the cluster.
61    _root: tempfile::TempDir,
62}
63
64impl Postgres {
65    pub fn database_url(&self) -> String {
66        format!("postgres://postgres@127.0.0.1:{}/nomisync", self.port)
67    }
68}
69
70impl Drop for Postgres {
71    fn drop(&mut self) {
72        // Best effort: the container is about to vanish anyway, but leaving a
73        // server holding a port breaks a second invocation in the same job.
74        let _ = Command::new("pg_ctl")
75            .args([
76                "-D",
77                &self.data_dir.to_string_lossy(),
78                "-m",
79                "immediate",
80                "stop",
81            ])
82            .status();
83    }
84}
85
86fn run(program: &str, args: &[&str]) -> Result<()> {
87    let status = Command::new(program)
88        .args(args)
89        .status()
90        .with_context(|| format!("failed to run {program}"))?;
91    if !status.success() {
92        bail!("{program} exited with {status}");
93    }
94    Ok(())
95}
96
97/// initdb, start, create the database, migrate it.
98pub async fn start() -> Result<Postgres> {
99    // initdb refuses to run as root, and under a uid with no passwd entry it
100    // dies inside getpwuid — the builder image supplies a named non-root user.
101    let root =
102        tempfile::TempDir::with_prefix("nomisync-ci-pg").context("could not make a temp dir")?;
103    let data_dir = root.path().join("pgdata");
104    let socket_dir = data_dir.join("sockets");
105
106    run(
107        "initdb",
108        &[
109            "-D",
110            &data_dir.to_string_lossy(),
111            "-U",
112            "postgres",
113            "--auth=trust",
114        ],
115    )
116    .context("initdb failed")?;
117    std::fs::create_dir_all(&socket_dir).context("could not make the socket dir")?;
118
119    let log = root.path().join("postgres.log");
120    let port = start_on_free_port(&data_dir, &socket_dir, &log)?;
121    let postgres = Postgres {
122        data_dir,
123        port,
124        _root: root,
125    };
126
127    println!(">> postgres: listening on 127.0.0.1:{port}");
128    create_and_migrate(&postgres).await?;
129    Ok(postgres)
130}
131
132fn start_on_free_port(data_dir: &Path, socket_dir: &Path, log: &Path) -> Result<u16> {
133    for port in PORT_RANGE {
134        // The socket directory lives inside PGDATA: a shared /tmp is not
135        // writable the same way in every container, and its lock file is where
136        // that first shows up.
137        let options = format!(
138            "-p {port} -c listen_addresses=127.0.0.1 -c unix_socket_directories={} {}",
139            socket_dir.display(),
140            [DURABILITY_OFF, TIMEOUTS].concat().join(" ")
141        );
142        let status = Command::new("pg_ctl")
143            .args([
144                "-D",
145                &data_dir.to_string_lossy(),
146                "-l",
147                &log.to_string_lossy(),
148                "-w",
149                "-o",
150                &options,
151                "start",
152            ])
153            .status()
154            .context("failed to run pg_ctl")?;
155        if status.success() {
156            return Ok(port);
157        }
158    }
159    bail!("no free port in {PORT_RANGE:?} accepted a postgres server")
160}
161
162async fn create_and_migrate(postgres: &Postgres) -> Result<()> {
163    use sqlx::{Connection, Executor, PgConnection};
164
165    let admin = format!("postgres://postgres@127.0.0.1:{}/postgres", postgres.port);
166    let mut conn = PgConnection::connect(&admin)
167        .await
168        .context("could not connect to the new cluster")?;
169    conn.execute("CREATE DATABASE nomisync")
170        .await
171        .context("could not create the nomisync database")?;
172    conn.close().await.ok();
173
174    let mut conn = PgConnection::connect(&postgres.database_url())
175        .await
176        .context("could not connect to the nomisync database")?;
177    MIGRATOR.run(&mut conn).await.context("migrations failed")?;
178    conn.close().await.ok();
179
180    println!(
181        ">> postgres: migrated ({} migrations)",
182        MIGRATOR.iter().count()
183    );
184    Ok(())
185}