OXYGENHUB

Coverage / crates/hub/src/db/mod.rs 94.6%

35 of 37 lines run, 11 of 18 functions · run #482 on proposal/333 · run missed

1
//! SQLite through `sqlx` (hub spec §3.1, §17.2): WAL, foreign keys on,
2
//! migrations embedded and applied at start; or PostgreSQL, built with the
3
//! `postgres` feature (#211). One module per table group.
4
5
pub mod accounts;
6
pub mod activity;
7
#[cfg(feature = "postgres")]
8
pub mod copy;
9
pub mod flow;
10
pub mod hooks;
11
pub mod issues;
12
pub mod notify;
13
pub mod projects;
14
pub mod proposals;
15
pub mod push_protection;
16
pub mod queue;
17
pub mod releases;
18
pub mod repos;
19
pub mod runner_updates;
20
pub mod runners;
21
pub mod sessions;
22
pub mod terraform;
23
pub mod tlog;
24
25
#[cfg(not(feature = "postgres"))]
26
pub use sqlite::{begin_write, open, Db};
27
28
#[cfg(feature = "postgres")]
29
pub use postgres::{begin_write, open, Db};
30
31
pub type Pool = sqlx::Pool<Db>;
32
33
#[cfg(not(feature = "postgres"))]
34
mod sqlite {
35
    use super::Pool;
36
    use crate::config::Config;
37
    use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
38
39
    pub type Db = sqlx::Sqlite;
40
41
    /// How long a statement waits for SQLite's write lock. Most writers are
42
    /// background clients (runners appending logs, webhook deliveries), and a
43
    /// commit can stall for seconds while a build saturates the host's disk, so
44
    /// waiting beats failing.
45
    const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
46
47
    /// Open (creating if needed) and migrate `<data>/hub.db`.
48193×
    pub async fn open(config: &Config) -> anyhow::Result<Pool> {
49193×
        let options = SqliteConnectOptions::new()
50193×
            .filename(config.db_path())
51193×
            .create_if_missing(true)
52193×
            .journal_mode(SqliteJournalMode::Wal)
53193×
            .synchronous(SqliteSynchronous::Normal)
54193×
            .foreign_keys(true)
55193×
            .busy_timeout(BUSY_TIMEOUT);
56193×
        migrate(options.clone()).await?;
57193×
        let pool = SqlitePoolOptions::new().max_connections(8).connect_with(options).await?;
58193×
        Ok(pool)
59193×
    }
60
61
    /// Apply the migrations on a connection of their own with foreign keys
62
    /// off, as SQLite's way to change a table (rebuild it, drop the old one,
63
    /// rename) needs: with them on, dropping a table its children reference
64
    /// deletes the children too. Every reference must hold afterwards.
65193×
    async fn migrate(options: SqliteConnectOptions) -> anyhow::Result<()> {
66
        use sqlx::Connection;
67193×
        let mut conn = sqlx::SqliteConnection::connect_with(&options.foreign_keys(false)).await?;
68193×
        sqlx::migrate!("./migrations").run(&mut conn).await?;
69193×
        let broken: Vec<(String,)> = sqlx::query_as("SELECT \"table\" FROM pragma_foreign_key_check").fetch_all(&mut conn).await?;
70193×
        if let Some((table,)) = broken.first() {
710×
            anyhow::bail!("after the migrations, {} rows break a foreign key (the first in {table})", broken.len());
72193×
        }
73193×
        conn.close().await?;
74193×
        Ok(())
75193×
    }
76
77
    /// A write transaction that takes SQLite's write lock at `BEGIN`
78
    /// (`BEGIN IMMEDIATE`). A deferred transaction that reads and then writes
79
    /// fails at once with "database is locked" when another writer holds the
80
    /// lock, whatever the busy timeout; an immediate one waits its turn.
811012×
    pub async fn begin_write(pool: &Pool) -> sqlx::Result<sqlx::Transaction<'static, Db>> {
821012×
        pool.begin_with("BEGIN IMMEDIATE").await
831012×
    }
84
}
85
86
/// PostgreSQL (#211), for a hub on more than one host or a managed database.
87
/// The same queries as SQLite's; the schema is `migrations-pg`, which starts
88
/// as SQLite's 0001-0072 in one and takes each later change written for it.
89
#[cfg(feature = "postgres")]
90
mod postgres {
91
    use super::Pool;
92
    use crate::config::Config;
93
    use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
94
    use sqlx::Executor;
95
96
    pub type Db = sqlx::Postgres;
97
98
    /// Connect to `[database] url`, use its schema, and migrate.
99
    pub async fn open(config: &Config) -> anyhow::Result<Pool> {
100
        if config.database.url.is_empty() {
101
            anyhow::bail!("this hub is built for PostgreSQL: set [database] url (or OXYHUB_DATABASE_URL)");
102
        }
103
        let options: PgConnectOptions = config.database.url.parse()?;
104
        let schema = schema_name(config);
105
        if let Some(schema) = &schema {
106
            let pool = PgPoolOptions::new().max_connections(1).connect_with(options.clone()).await?;
107
            pool.execute(format!("CREATE SCHEMA IF NOT EXISTS \"{schema}\"").as_str()).await?;
108
            pool.close().await;
109
        }
110
        let path = match &schema {
111
            // Plain (letters, digits, `_`), so it needs no quoting here.
112
            Some(s) => format!("{s},public"),
113
            None => "public".to_string(),
114
        };
115
        let options = options.options([("search_path", path.as_str())]);
116
        let pool = PgPoolOptions::new().max_connections(16).connect_with(options).await?;
117
        extension(&pool).await?;
118
        sqlx::migrate!("./migrations-pg").run(&pool).await?;
119
        Ok(pool)
120
    }
121
122
    /// `citext`, for names compared without case, made once in `public` so
123
    /// every schema sees it. Two hubs starting at once may race to make it.
124
    async fn extension(pool: &Pool) -> anyhow::Result<()> {
125
        let made = pool.execute("CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public").await;
126
        if let Err(e) = made {
127
            let there: bool =
128
                sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'citext')").fetch_one(pool).await?;
129
            if !there {
130
                return Err(e.into());
131
            }
132
        }
133
        Ok(())
134
    }
135
136
    /// The schema to use: none for `public`; `*` names it after the data
137
    /// directory, so hubs (and tests) can share one database.
138
    fn schema_name(config: &Config) -> Option<String> {
139
        let s = match config.database.schema.as_str() {
140
            "" | "public" => return None,
141
            "*" => {
142
                let dir = config.data.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
143
                format!("hub_{dir}")
144
            }
145
            s => s.to_string(),
146
        };
147
        Some(s.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' }).collect())
148
    }
149
150
    /// One writer at a time, as SQLite's `BEGIN IMMEDIATE` gives: callers
151
    /// read and then write (the next number, a check before an insert) and
152
    /// rely on nothing changing between. A transaction-scoped advisory lock.
153
    pub async fn begin_write(pool: &Pool) -> sqlx::Result<sqlx::Transaction<'static, Db>> {
154
        let mut tx = pool.begin().await?;
155
        sqlx::query("SELECT pg_advisory_xact_lock(7300211)").execute(&mut *tx).await?;
156
        Ok(tx)
157
    }
158
}
159
160
/// SQL for the text at `key` of the JSON object in column `col` (null when
161
/// the column is empty or has no such key).
162195×
pub fn json_text(col: &str, key: &str) -> String {
163195×
    if cfg!(feature = "postgres") {
1640×
        format!("(CAST(NULLIF({col}, '') AS json) ->> '{key}')")
165
    } else {
166195×
        format!("json_extract({col}, '$.{key}')")
167
    }
168195×
}
169
170
/// Milliseconds since the epoch, the hub's timestamp unit (as `oxy`'s).
17113264×
pub fn now_ms() -> i64 {
17213264×
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
17313264×
}