OXYGENHUB
◆ v0.9.19 oxygenhub / crates / hub / src / db / copy.rs
9.6 KiB · Rust · last changed copy-db --replace: empty a rehearsal's or a fresh hub's rows first 19 days ago source blame history raw
1
//! Copy a SQLite hub's records into PostgreSQL (#211), for moving a hub:
2
//! `oxygenhub admin copy-db --from <old data>/hub.db` on the new hub, built
3
//! with the `postgres` feature and migrated, before it first serves.
4
//!
5
//! Everything is copied in one transaction, ids kept, so a failed copy
6
//! leaves the database as it was. The tables go in the order the schema
7
//! made them, which is after every table they point at; a column pointing
8
//! at its own table (a fork's parent) is filled in once all its rows are
9
//! there. Then each id sequence is moved past the copied ids, the search
10
//! index is copied, and every table's count is checked against SQLite's.
11
12
use super::Pool;
13
use anyhow::{bail, Context, Result};
14
use sqlx::sqlite::{SqliteConnectOptions, SqliteRow};
15
use sqlx::{Connection, Row};
16
use std::path::Path;
17
18
/// Rows sent in one INSERT: well under Postgres's 65535 parameters for the
19
/// widest table.
20
const BATCH: usize = 500;
21
22
/// One column of a table here, and how to carry its values across.
23
struct Col {
24
    name: String,
25
    kind: Kind,
26
    /// Points at its own table: copied after the rows.
27
    self_ref: bool,
28
}
29
30
#[derive(Clone, Copy, PartialEq)]
31
enum Kind {
32
    Int,
33
    Bool,
34
    Real,
35
    Blob,
36
    Text,
37
}
38
39
enum Value {
40
    Int(Option<i64>),
41
    Bool(Option<bool>),
42
    Real(Option<f64>),
43
    Blob(Option<Vec<u8>>),
44
    Text(Option<String>),
45
}
46
47
/// What was copied: `(table, rows)`, in copy order. With `replace`, the
48
/// rows already here (a fresh hub's, a rehearsal's) are emptied first, in
49
/// the same transaction; without it, a database with rows is refused.
50
pub async fn from_sqlite(pool: &Pool, path: &Path, replace: bool) -> Result<Vec<(String, i64)>> {
51
    let options = SqliteConnectOptions::new().filename(path).read_only(true);
52
    let mut lite = sqlx::SqliteConnection::connect_with(&options).await.with_context(|| format!("open {}", path.display()))?;
53
    up_to_date(&mut lite).await?;
54
    let tables: Vec<String> = sqlx::query_scalar(
55
        "SELECT CAST(relname AS TEXT) FROM pg_class WHERE relkind = 'r' AND relnamespace = CAST(current_schema() AS regnamespace) \
56
         AND relname NOT IN ('_sqlx_migrations', 'search') ORDER BY oid",
57
    )
58
    .fetch_all(pool)
59
    .await?;
60
    if !replace {
61
        for t in &tables {
62
            let any: bool = sqlx::query_scalar(&format!("SELECT EXISTS (SELECT 1 FROM \"{t}\")")).fetch_one(pool).await?;
63
            if any {
64
                bail!("{t} already has rows here: copy into a new, empty database, or give --replace to empty this one first");
65
            }
66
        }
67
    }
68
    let mut tx = pool.begin().await?;
69
    if replace {
70
        let all: Vec<String> = tables.iter().chain(std::iter::once(&"search".to_string())).map(|t| format!("\"{t}\"")).collect();
71
        sqlx::query(&format!("TRUNCATE {} RESTART IDENTITY CASCADE", all.join(", "))).execute(&mut *tx).await?;
72
    }
73
    let mut copied = Vec::new();
74
    for t in &tables {
75
        let cols = columns(&mut tx, t).await?;
76
        let n = copy_table(&mut lite, &mut tx, t, &cols).await.with_context(|| format!("copy {t}"))?;
77
        copied.push((t.clone(), n));
78
    }
79
    // Ids carry on after the copied ones.
80
    let numbered: Vec<String> = sqlx::query_scalar(
81
        "SELECT CAST(t.relname AS TEXT) FROM pg_attribute a JOIN pg_class t ON t.oid = a.attrelid \
82
         WHERE t.relnamespace = CAST(current_schema() AS regnamespace) AND a.attname = 'id' AND a.attidentity <> ''",
83
    )
84
    .fetch_all(&mut *tx)
85
    .await?;
86
    for t in &numbered {
87
        sqlx::query(&format!("SELECT setval(pg_get_serial_sequence($1, 'id'), COALESCE((SELECT MAX(id) FROM \"{t}\"), 0) + 1, false)"))
88
            .bind(t)
89
            .execute(&mut *tx)
90
            .await?;
91
    }
92
    let search = copy_search(&mut lite, &mut tx).await.context("copy the search index")?;
93
    copied.push(("search".into(), search));
94
    for (t, n) in &copied {
95
        let there: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM \"{t}\"")).fetch_one(&mut *tx).await?;
96
        if there != *n {
97
            bail!("{t}: {n} rows read but {there} here");
98
        }
99
    }
100
    tx.commit().await?;
101
    lite.close().await?;
102
    Ok(copied)
103
}
104
105
/// The SQLite hub must have every migration this hub knows, and no more:
106
/// the two schemas match only then.
107
async fn up_to_date(lite: &mut sqlx::SqliteConnection) -> Result<()> {
108
    let latest = sqlx::migrate!("./migrations").iter().map(|m| m.version).max().unwrap_or(0);
109
    let have: Option<i64> =
110
        sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations WHERE success = TRUE").fetch_one(&mut *lite).await?;
111
    match have {
112
        Some(v) if v == latest => Ok(()),
113
        Some(v) => bail!("the SQLite hub is at migration {v}, this one at {latest}: run the same version of the hub on both"),
114
        None => bail!("not a hub's database: it has no migrations"),
115
    }
116
}
117
118
async fn columns(tx: &mut sqlx::PgConnection, table: &str) -> Result<Vec<Col>> {
119
    let rows: Vec<(String, String, bool)> = sqlx::query_as(
120
        "SELECT CAST(a.attname AS TEXT), format_type(a.atttypid, a.atttypmod), \
121
           EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = t.oid AND k.confrelid = t.oid AND k.contype = 'f' AND a.attnum = ANY (k.conkey)) \
122
         FROM pg_attribute a JOIN pg_class t ON t.oid = a.attrelid \
123
         WHERE t.relname = $1 AND t.relnamespace = CAST(current_schema() AS regnamespace) \
124
           AND a.attnum > 0 AND NOT a.attisdropped AND a.attgenerated = '' ORDER BY a.attnum",
125
    )
126
    .bind(table)
127
    .fetch_all(&mut *tx)
128
    .await?;
129
    Ok(rows
130
        .into_iter()
131
        .map(|(name, ty, self_ref)| {
132
            let kind = match ty.as_str() {
133
                "bigint" | "integer" | "smallint" => Kind::Int,
134
                "boolean" => Kind::Bool,
135
                "double precision" | "real" => Kind::Real,
136
                "bytea" => Kind::Blob,
137
                _ => Kind::Text,
138
            };
139
            Col { name, kind, self_ref }
140
        })
141
        .collect())
142
}
143
144
fn read(row: &SqliteRow, i: usize, kind: Kind) -> Result<Value> {
145
    Ok(match kind {
146
        Kind::Int => Value::Int(row.try_get(i)?),
147
        Kind::Bool => Value::Bool(row.try_get::<Option<i64>, _>(i)?.map(|v| v != 0)),
148
        Kind::Real => Value::Real(row.try_get(i)?),
149
        Kind::Blob => Value::Blob(row.try_get(i)?),
150
        Kind::Text => Value::Text(row.try_get(i)?),
151
    })
152
}
153
154
fn list(cols: &[&Col]) -> String {
155
    cols.iter().map(|c| format!("\"{}\"", c.name)).collect::<Vec<_>>().join(", ")
156
}
157
158
async fn copy_table(lite: &mut sqlx::SqliteConnection, tx: &mut sqlx::PgConnection, table: &str, cols: &[Col]) -> Result<i64> {
159
    use futures::TryStreamExt;
160
    let all: Vec<&Col> = cols.iter().collect();
161
    let now: Vec<&Col> = cols.iter().filter(|c| !c.self_ref).collect();
162
    let id_at = cols.iter().position(|c| c.name == "id");
163
    let sql = format!("SELECT {} FROM \"{table}\" ORDER BY rowid", list(&all));
164
    // Streamed, a batch at a time: a table (Flow's logs) can be large.
165
    let mut rows = sqlx::query(&sql).fetch(&mut *lite);
166
    let (mut n, mut batch, mut later) = (0i64, Vec::with_capacity(BATCH), Vec::new());
167
    loop {
168
        let row = rows.try_next().await?;
169
        if let Some(row) = &row {
170
            let mut values = Vec::with_capacity(now.len());
171
            let mut refs = Vec::new();
172
            for (i, c) in cols.iter().enumerate() {
173
                let v = read(row, i, c.kind).with_context(|| format!("read {table}.{}", c.name))?;
174
                if c.self_ref {
175
                    if matches!(v, Value::Int(Some(_))) {
176
                        refs.push((c.name.clone(), v));
177
                    }
178
                } else {
179
                    values.push(v);
180
                }
181
            }
182
            if !refs.is_empty() {
183
                let Some(i) = id_at else { bail!("{table} points at itself but has no id") };
184
                later.push((read(row, i, Kind::Int)?, refs));
185
            }
186
            batch.push(values);
187
            n += 1;
188
        }
189
        if batch.len() == BATCH || (row.is_none() && !batch.is_empty()) {
190
            let mut q = sqlx::QueryBuilder::<super::Db>::new(format!("INSERT INTO \"{table}\" ({}) ", list(&now)));
191
            q.push_values(batch.drain(..), |mut b, values| {
192
                for v in values {
193
                    match v {
194
                        Value::Int(v) => b.push_bind(v),
195
                        Value::Bool(v) => b.push_bind(v),
196
                        Value::Real(v) => b.push_bind(v),
197
                        Value::Blob(v) => b.push_bind(v),
198
                        Value::Text(v) => b.push_bind(v),
199
                    };
200
                }
201
            });
202
            q.build().execute(&mut *tx).await?;
203
        }
204
        if row.is_none() {
205
            break;
206
        }
207
    }
208
    for (id, refs) in later {
209
        let Value::Int(id) = id else { continue };
210
        for (name, v) in refs {
211
            if let Value::Int(v) = v {
212
                sqlx::query(&format!("UPDATE \"{table}\" SET \"{name}\" = $1 WHERE id = $2")).bind(v).bind(id).execute(&mut *tx).await?;
213
            }
214
        }
215
    }
216
    Ok(n)
217
}
218
219
async fn copy_search(lite: &mut sqlx::SqliteConnection, tx: &mut sqlx::PgConnection) -> Result<i64> {
220
    let rows: Vec<(String, i64, i64, i64, String, String)> =
221
        sqlx::query_as("SELECT kind, repo_id, target_id, number, title, body FROM search").fetch_all(&mut *lite).await?;
222
    for chunk in rows.chunks(BATCH) {
223
        let mut q = sqlx::QueryBuilder::<super::Db>::new("INSERT INTO search (kind, repo_id, target_id, number, title, body) ");
224
        q.push_values(chunk, |mut b, (kind, repo_id, target_id, number, title, body)| {
225
            b.push_bind(kind).push_bind(repo_id).push_bind(target_id).push_bind(number).push_bind(title).push_bind(body);
226
        });
227
        q.build().execute(&mut *tx).await?;
228
    }
229
    Ok(rows.len() as i64)
230
}