Skip to main content

max / goingson

5.7 KB · 158 lines History Blame Raw
1 //! The schema migration runner.
2 //!
3 //! # Why the ledger table is still called `_sqlx_migrations`
4 //!
5 //! It is not a leftover. Every GoingsOn install in the field has that table with
6 //! one row per applied migration, written by `sqlx::migrate!` before the move to
7 //! rusqlite. Renaming it, or writing a fresh ledger beside it, would make an
8 //! upgraded install believe it had applied nothing and re-run all 64 migrations
9 //! against a populated database -- which fails at the first `CREATE TABLE`, and
10 //! would be worse if it didn't.
11 //!
12 //! So this runner adopts the existing ledger verbatim: same table name, same
13 //! columns, same semantics, and the same checksum function (sha384 over the raw
14 //! file bytes, verified against sqlx 0.9 in `migration_checksum_tests.rs`). An
15 //! upgraded install reads its own 64 rows, finds nothing pending, and does
16 //! nothing. That is the entire compatibility requirement, and it is why the
17 //! checksum algorithm is not a free choice.
18
19 use std::time::Instant;
20
21 use rusqlite::{Connection, OptionalExtension};
22 use sha2::{Digest, Sha384};
23
24 include!(concat!(env!("OUT_DIR"), "/migrations.rs"));
25
26 /// The ledger table. Named by sqlx; see the module docs before changing it.
27 const LEDGER: &str = "_sqlx_migrations";
28
29 /// A migration that failed to record itself needs a human, not a retry.
30 #[derive(Debug, thiserror::Error)]
31 pub enum MigrateError {
32 #[error("database error running migrations: {0}")]
33 Db(#[from] rusqlite::Error),
34
35 #[error("could not check out a connection to run migrations: {0}")]
36 Pool(#[from] r2d2::Error),
37
38 #[error(
39 "migration {version} ({description}) was already applied, but its file has changed since. \
40 Applied migrations are immutable -- add a new migration instead of editing a shipped one."
41 )]
42 ChecksumMismatch { version: i64, description: String },
43
44 #[error(
45 "migration {0} is partially applied; fix it and remove its row from the `{LEDGER}` table"
46 )]
47 Dirty(i64),
48
49 #[error("migration {version} ({description}) failed: {source}")]
50 Apply {
51 version: i64,
52 description: String,
53 #[source]
54 source: rusqlite::Error,
55 },
56 }
57
58 /// sha384 of a migration's bytes, matching what sqlx recorded.
59 pub(crate) fn checksum(sql: &str) -> Vec<u8> {
60 Sha384::digest(sql.as_bytes()).to_vec()
61 }
62
63 /// Every migration compiled into this binary, in version order.
64 pub(crate) fn all() -> impl Iterator<Item = (i64, &'static str, &'static str)> {
65 MIGRATIONS.iter().copied()
66 }
67
68 /// Create the ledger if absent. The DDL is sqlx 0.9's verbatim, so this is a
69 /// no-op on an install that sqlx already set up.
70 fn ensure_ledger(conn: &Connection) -> Result<(), rusqlite::Error> {
71 conn.execute_batch(&format!(
72 "CREATE TABLE IF NOT EXISTS {LEDGER} (
73 version BIGINT PRIMARY KEY,
74 description TEXT NOT NULL,
75 installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
76 success BOOLEAN NOT NULL,
77 checksum BLOB NOT NULL,
78 execution_time BIGINT NOT NULL
79 );"
80 ))
81 }
82
83 /// Apply every migration not yet in the ledger.
84 ///
85 /// Each migration and its ledger row commit together, so a crash mid-run leaves
86 /// the database at a migration boundary rather than half-applied.
87 #[tracing::instrument(skip_all)]
88 pub fn run(conn: &mut Connection) -> Result<(), MigrateError> {
89 ensure_ledger(conn)?;
90
91 // A `success = false` row means a previous run died between applying a
92 // migration and committing its ledger row. sqlx wrote these; this runner
93 // cannot (it commits both together), but an install upgraded from sqlx may
94 // carry one, and it still needs a human.
95 let dirty: Option<i64> = conn
96 .query_row(
97 &format!("SELECT version FROM {LEDGER} WHERE success = false ORDER BY version LIMIT 1"),
98 [],
99 |row| row.get(0),
100 )
101 .optional()?;
102 if let Some(version) = dirty {
103 return Err(MigrateError::Dirty(version));
104 }
105
106 let applied: std::collections::BTreeMap<i64, Vec<u8>> = {
107 let mut stmt = conn.prepare(&format!(
108 "SELECT version, checksum FROM {LEDGER} ORDER BY version"
109 ))?;
110 let rows = stmt.query_map([], |row| {
111 Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
112 })?;
113 rows.collect::<Result<_, _>>()?
114 };
115
116 for (version, description, sql) in all() {
117 let digest = checksum(sql);
118
119 if let Some(recorded) = applied.get(&version) {
120 // Already applied. The only question left is whether the file still
121 // hashes to what was recorded; if not, a shipped migration was
122 // edited and every install that ran it now disagrees with this one.
123 if *recorded != digest {
124 return Err(MigrateError::ChecksumMismatch {
125 version,
126 description: description.to_owned(),
127 });
128 }
129 continue;
130 }
131
132 tracing::info!(version, description, "applying migration");
133 let started = Instant::now();
134 let tx = conn.transaction()?;
135 tx.execute_batch(sql)
136 .map_err(|source| MigrateError::Apply {
137 version,
138 description: description.to_owned(),
139 source,
140 })?;
141 tx.execute(
142 &format!(
143 "INSERT INTO {LEDGER} (version, description, success, checksum, execution_time)
144 VALUES (?1, ?2, TRUE, ?3, ?4)"
145 ),
146 rusqlite::params![
147 version,
148 description,
149 digest,
150 i64::try_from(started.elapsed().as_nanos()).unwrap_or(i64::MAX),
151 ],
152 )?;
153 tx.commit()?;
154 }
155
156 Ok(())
157 }
158