|
1 |
+ |
//! CI guard for migration safety hygiene (forward-only).
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! sqlx checksums every *applied* migration, so historical migrations can never
|
|
4 |
+ |
//! be edited — retrofitting `CONCURRENTLY` / `IF NOT EXISTS` onto migrations
|
|
5 |
+ |
//! `001..=HIGH_WATER` is impossible without breaking the checksum guard. This
|
|
6 |
+ |
//! test therefore freezes the existing set and enforces the conventions only on
|
|
7 |
+ |
//! NEW migrations (number > `HIGH_WATER`):
|
|
8 |
+ |
//!
|
|
9 |
+ |
//! 1. A `CREATE INDEX` on a high-write "growth" table must be `CONCURRENTLY`.
|
|
10 |
+ |
//! A plain `CREATE INDEX` takes an `ACCESS EXCLUSIVE` lock and blocks writes
|
|
11 |
+ |
//! for the whole build; on a grown `transactions`/`page_views` table that is
|
|
12 |
+ |
//! a production write-stall.
|
|
13 |
+ |
//! 2. `CREATE INDEX CONCURRENTLY` cannot run inside a transaction, so any file
|
|
14 |
+ |
//! that uses it must opt out of sqlx's per-migration transaction by starting
|
|
15 |
+ |
//! with the exact bytes `-- no-transaction` (sqlx-core 0.8 `source.rs:127`
|
|
16 |
+ |
//! does `sql.starts_with("-- no-transaction")` — it must be the file prefix,
|
|
17 |
+ |
//! no leading blank line).
|
|
18 |
+ |
//! 3. `CREATE TABLE` / `CREATE INDEX` must be `IF NOT EXISTS` (re-run safety —
|
|
19 |
+ |
//! a partially-applied migration set can be re-run without erroring).
|
|
20 |
+ |
//!
|
|
21 |
+ |
//! Bump `HIGH_WATER` only after deliberately reviewing every migration at or below
|
|
22 |
+ |
//! the new mark and accepting its state.
|
|
23 |
+ |
//!
|
|
24 |
+ |
//! Run with: cargo test --test migration_hygiene
|
|
25 |
+ |
|
|
26 |
+ |
use std::fs;
|
|
27 |
+ |
use std::path::Path;
|
|
28 |
+ |
|
|
29 |
+ |
/// Highest migration number whose contents are grandfathered. Landed at the
|
|
30 |
+ |
/// current max during the Run 23 remediation (2026-07-04). Anything strictly
|
|
31 |
+ |
/// greater must satisfy the conventions above.
|
|
32 |
+ |
const HIGH_WATER: u32 = 167;
|
|
33 |
+ |
|
|
34 |
+ |
/// Tables large/hot enough that a blocking (non-concurrent) index build is a real
|
|
35 |
+ |
/// production write-stall. A `CREATE INDEX` touching one of these in a new
|
|
36 |
+ |
/// migration must be `CONCURRENTLY`.
|
|
37 |
+ |
const GROWTH_TABLES: &[&str] = &[
|
|
38 |
+ |
"transactions",
|
|
39 |
+ |
"page_views",
|
|
40 |
+ |
"follows",
|
|
41 |
+ |
"subscriptions",
|
|
42 |
+ |
"items",
|
|
43 |
+ |
"item_versions",
|
|
44 |
+ |
"sync_blobs",
|
|
45 |
+ |
"webhook_events",
|
|
46 |
+ |
"processed_webhook_events",
|
|
47 |
+ |
];
|
|
48 |
+ |
|
|
49 |
+ |
const MIGRATIONS_DIR: &str = "migrations";
|
|
50 |
+ |
|
|
51 |
+ |
#[test]
|
|
52 |
+ |
fn new_migrations_follow_index_and_rerun_safety_conventions() {
|
|
53 |
+ |
let mut violations = Vec::new();
|
|
54 |
+ |
|
|
55 |
+ |
let mut files: Vec<_> = fs::read_dir(MIGRATIONS_DIR)
|
|
56 |
+ |
.expect("read migrations dir")
|
|
57 |
+ |
.filter_map(|e| e.ok())
|
|
58 |
+ |
.map(|e| e.path())
|
|
59 |
+ |
.filter(|p| p.extension().is_some_and(|x| x == "sql"))
|
|
60 |
+ |
.collect();
|
|
61 |
+ |
files.sort();
|
|
62 |
+ |
|
|
63 |
+ |
for path in &files {
|
|
64 |
+ |
let Some(num) = leading_number(path) else {
|
|
65 |
+ |
violations.push(format!("{}: filename has no leading number", show(path)));
|
|
66 |
+ |
continue;
|
|
67 |
+ |
};
|
|
68 |
+ |
if num <= HIGH_WATER {
|
|
69 |
+ |
continue; // grandfathered — frozen by sqlx checksum
|
|
70 |
+ |
}
|
|
71 |
+ |
|
|
72 |
+ |
let raw = fs::read_to_string(path).expect("read migration");
|
|
73 |
+ |
let no_tx = raw.starts_with("-- no-transaction");
|
|
74 |
+ |
let stmts = statements(&raw);
|
|
75 |
+ |
|
|
76 |
+ |
for stmt in &stmts {
|
|
77 |
+ |
if let Some(idx) = parse_create_index(stmt) {
|
|
78 |
+ |
if idx.growth_target() && !idx.concurrently {
|
|
79 |
+ |
violations.push(format!(
|
|
80 |
+ |
"{}: CREATE INDEX on growth table `{}` must be CONCURRENTLY \
|
|
81 |
+ |
(plain build takes ACCESS EXCLUSIVE and stalls writes)",
|
|
82 |
+ |
show(path), idx.table
|
|
83 |
+ |
));
|
|
84 |
+ |
}
|
|
85 |
+ |
if idx.concurrently && !no_tx {
|
|
86 |
+ |
violations.push(format!(
|
|
87 |
+ |
"{}: uses CREATE INDEX CONCURRENTLY but the file does not start \
|
|
88 |
+ |
with `-- no-transaction` — sqlx will wrap it in a transaction \
|
|
89 |
+ |
and Postgres will reject it at runtime",
|
|
90 |
+ |
show(path)
|
|
91 |
+ |
));
|
|
92 |
+ |
}
|
|
93 |
+ |
if !idx.if_not_exists {
|
|
94 |
+ |
violations.push(format!(
|
|
95 |
+ |
"{}: CREATE INDEX must be IF NOT EXISTS for re-run safety",
|
|
96 |
+ |
show(path)
|
|
97 |
+ |
));
|
|
98 |
+ |
}
|
|
99 |
+ |
} else if is_create_table(stmt) && !has_if_not_exists(stmt) {
|
|
100 |
+ |
violations.push(format!(
|
|
101 |
+ |
"{}: CREATE TABLE must be IF NOT EXISTS for re-run safety",
|
|
102 |
+ |
show(path)
|
|
103 |
+ |
));
|
|
104 |
+ |
}
|
|
105 |
+ |
}
|
|
106 |
+ |
}
|
|
107 |
+ |
|
|
108 |
+ |
assert!(
|
|
109 |
+ |
violations.is_empty(),
|
|
110 |
+ |
"migration hygiene violations (see tests/migration_hygiene.rs for the rules):\n{}",
|
|
111 |
+ |
violations.join("\n")
|
|
112 |
+ |
);
|
|
113 |
+ |
}
|
|
114 |
+ |
|
|
115 |
+ |
/// The guard must not silently pass because every new migration was skipped by a
|
|
116 |
+ |
/// wrong `HIGH_WATER`: assert the mark is not below the real maximum on disk.
|
|
117 |
+ |
/// (If someone adds `168_*.sql`, the mark stays at 167 and 168 is checked — good.
|
|
118 |
+ |
/// This only fires if `HIGH_WATER` is set *above* the newest file, which would
|
|
119 |
+ |
/// disable the guard entirely.)
|
|
120 |
+ |
#[test]
|
|
121 |
+ |
fn high_water_mark_is_not_ahead_of_disk() {
|
|
122 |
+ |
let max = fs::read_dir(MIGRATIONS_DIR)
|
|
123 |
+ |
.expect("read migrations dir")
|
|
124 |
+ |
.filter_map(|e| e.ok())
|
|
125 |
+ |
.filter_map(|e| leading_number(&e.path()))
|
|
126 |
+ |
.max()
|
|
127 |
+ |
.expect("at least one migration");
|
|
128 |
+ |
assert!(
|
|
129 |
+ |
HIGH_WATER <= max,
|
|
130 |
+ |
"HIGH_WATER ({HIGH_WATER}) is ahead of the newest migration ({max}) — \
|
|
131 |
+ |
the hygiene guard would skip every file. Lower HIGH_WATER."
|
|
132 |
+ |
);
|
|
133 |
+ |
}
|
|
134 |
+ |
|
|
135 |
+ |
// ---- helpers -------------------------------------------------------------
|
|
136 |
+ |
|
|
137 |
+ |
fn show(p: &Path) -> String {
|
|
138 |
+ |
p.file_name().unwrap().to_string_lossy().into_owned()
|
|
139 |
+ |
}
|
|
140 |
+ |
|
|
141 |
+ |
fn leading_number(p: &Path) -> Option<u32> {
|
|
142 |
+ |
let name = p.file_name()?.to_str()?;
|
|
143 |
+ |
let digits: String = name.chars().take_while(|c| c.is_ascii_digit()).collect();
|
|
144 |
+ |
digits.parse().ok()
|
|
145 |
+ |
}
|
|
146 |
+ |
|
|
147 |
+ |
/// Split into statements: strip `--` line comments and `/* */` block comments,
|
|
148 |
+ |
/// lowercase, collapse whitespace, split on `;`.
|
|
149 |
+ |
fn statements(raw: &str) -> Vec<String> {
|
|
150 |
+ |
// Strip block comments.
|
|
151 |
+ |
let mut no_block = String::with_capacity(raw.len());
|
|
152 |
+ |
let bytes = raw.as_bytes();
|
|
153 |
+ |
let mut i = 0;
|
|
154 |
+ |
while i < bytes.len() {
|
|
155 |
+ |
if i + 1 < bytes.len() && &raw[i..i + 2] == "/*" {
|
|
156 |
+ |
if let Some(end) = raw[i..].find("*/") {
|
|
157 |
+ |
i += end + 2;
|
|
158 |
+ |
no_block.push(' ');
|
|
159 |
+ |
continue;
|
|
160 |
+ |
}
|
|
161 |
+ |
break;
|
|
162 |
+ |
}
|
|
163 |
+ |
no_block.push(bytes[i] as char);
|
|
164 |
+ |
i += 1;
|
|
165 |
+ |
}
|
|
166 |
+ |
// Strip line comments and normalize.
|
|
167 |
+ |
let cleaned: String = no_block
|
|
168 |
+ |
.lines()
|
|
169 |
+ |
.map(|l| match l.find("--") {
|
|
170 |
+ |
Some(pos) => &l[..pos],
|
|
171 |
+ |
None => l,
|
|
172 |
+ |
})
|
|
173 |
+ |
.collect::<Vec<_>>()
|
|
174 |
+ |
.join(" ");
|
|
175 |
+ |
let norm = cleaned.to_lowercase();
|
|
176 |
+ |
norm.split(';')
|
|
177 |
+ |
.map(|s| s.split_whitespace().collect::<Vec<_>>().join(" "))
|
|
178 |
+ |
.filter(|s| !s.is_empty())
|
|
179 |
+ |
.collect()
|
|
180 |
+ |
}
|
|
181 |
+ |
|
|
182 |
+ |
fn is_create_table(stmt: &str) -> bool {
|
|
183 |
+ |
stmt.starts_with("create table") || stmt.starts_with("create unlogged table")
|
|
184 |
+ |
}
|
|
185 |
+ |
|
|
186 |
+ |
fn has_if_not_exists(stmt: &str) -> bool {
|
|
187 |
+ |
stmt.contains("if not exists")
|
|
188 |
+ |
}
|
|
189 |
+ |
|
|
190 |
+ |
struct IndexStmt {
|
|
191 |
+ |
concurrently: bool,
|
|
192 |
+ |
if_not_exists: bool,
|
|
193 |
+ |
table: String,
|
|
194 |
+ |
}
|
|
195 |
+ |
|
|
196 |
+ |
impl IndexStmt {
|
|
197 |
+ |
fn growth_target(&self) -> bool {
|
|
198 |
+ |
GROWTH_TABLES.contains(&self.table.as_str())
|
|
199 |
+ |
}
|
|
200 |
+ |
}
|
|
201 |
+ |
|
|
202 |
+ |
/// Parse a `CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] name ON table ...`.
|
|
203 |
+ |
/// Returns None if the statement is not a create-index.
|
|
204 |
+ |
fn parse_create_index(stmt: &str) -> Option<IndexStmt> {
|
|
205 |
+ |
let tokens: Vec<&str> = stmt.split_whitespace().collect();
|
|
206 |
+ |
// Must begin create [unique] index
|
|
207 |
+ |
let idx_pos = tokens.iter().position(|t| *t == "index")?;
|
|
208 |
+ |
if tokens.first() != Some(&"create") {
|
|
209 |
+ |
return None;
|
|
210 |
+ |
}
|
|
211 |
+ |
match idx_pos {
|
|
212 |
+ |
1 => {} // create index
|
|
213 |
+ |
2 if tokens.get(1) == Some(&"unique") => {} // create unique index
|
|
214 |
+ |
_ => return None,
|
|
215 |
+ |
}
|
|
216 |
+ |
let after: Vec<&str> = tokens[idx_pos + 1..].to_vec();
|
|
217 |
+ |
let concurrently = after.first() == Some(&"concurrently");
|
|
218 |
+ |
let if_not_exists = stmt.contains("if not exists");
|
|
219 |
+ |
// Table name is the token following `on`.
|
|
220 |
+ |
let on_pos = after.iter().position(|t| *t == "on")?;
|
|
221 |
+ |
let raw_table = after.get(on_pos + 1)?;
|
|
222 |
+ |
let table = raw_table
|
|
223 |
+ |
.trim_matches(|c| c == '"' || c == '(')
|
|
224 |
+ |
.split('(')
|
|
225 |
+ |
.next()
|
|
226 |
+ |
.unwrap_or(raw_table)
|
|
227 |
+ |
.to_string();
|
|
228 |
+ |
Some(IndexStmt {
|
|
229 |
+ |
concurrently,
|
|
230 |
+ |
if_not_exists,
|
|
231 |
+ |
table,
|
|
232 |
+ |
})
|
|
233 |
+ |
}
|
|
234 |
+ |
|
|
235 |
+ |
// ---- self-tests: prove the detector fires, so the on-disk pass is not vacuous ----
|
|
236 |
+ |
|
|
237 |
+ |
#[cfg(test)]
|
|
238 |
+ |
mod detector_tests {
|
|
239 |
+ |
use super::*;
|
|
240 |
+ |
|
|
241 |
+ |
fn only(stmt: &str) -> String {
|
|
242 |
+ |
statements(stmt).into_iter().next().unwrap()
|
|
243 |
+ |
}
|
|
244 |
+ |
|
|
245 |
+ |
#[test]
|
|
246 |
+ |
fn flags_plain_index_on_growth_table() {
|
|
247 |
+ |
let idx = parse_create_index(&only(
|
|
248 |
+ |
"CREATE INDEX IF NOT EXISTS idx_x ON transactions (seller_user_id);",
|
|
249 |
+ |
))
|
|
250 |
+ |
.unwrap();
|
|
251 |
+ |
assert!(idx.growth_target());
|
|
252 |
+ |
assert!(!idx.concurrently, "plain build must be caught");
|
|
253 |
+ |
assert!(idx.if_not_exists);
|
|
254 |
+ |
}
|
|
255 |
+ |
|
|
256 |
+ |
#[test]
|
|
257 |
+ |
fn accepts_concurrent_index_on_growth_table() {
|
|
258 |
+ |
let idx = parse_create_index(&only(
|
|
259 |
+ |
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_x ON transactions (created_at);",
|
|
260 |
+ |
))
|
|
261 |
+ |
.unwrap();
|
|
262 |
+ |
assert!(idx.growth_target());
|
|
263 |
+ |
assert!(idx.concurrently);
|
|
264 |
+ |
assert!(idx.if_not_exists);
|
|
265 |
+ |
}
|
|
266 |
+ |
|
|
267 |
+ |
#[test]
|
|
268 |
+ |
fn small_table_index_is_not_a_growth_target() {
|
|
269 |
+ |
let idx =
|
|
270 |
+ |
parse_create_index(&only("CREATE INDEX IF NOT EXISTS i ON promo_codes (id);")).unwrap();
|
|
271 |
+ |
assert!(!idx.growth_target(), "non-hot table needn't be concurrent");
|
|
272 |
+ |
}
|
|
273 |
+ |
|
|
274 |
+ |
#[test]
|
|
275 |
+ |
fn detects_missing_if_not_exists() {
|
|
276 |
+ |
let idx = parse_create_index(&only("CREATE INDEX i ON items (slug);")).unwrap();
|
|
277 |
+ |
assert!(!idx.if_not_exists);
|
|
278 |
+ |
assert!(idx.growth_target());
|
|
279 |
+ |
}
|
|
280 |
+ |
|
|
281 |
+ |
#[test]
|
|
282 |
+ |
fn no_transaction_directive_must_be_file_prefix() {
|
|
283 |
+ |
// sqlx does sql.starts_with("-- no-transaction") — a leading blank line breaks it.
|
|
284 |
+ |
assert!("-- no-transaction\nCREATE INDEX CONCURRENTLY ...".starts_with("-- no-transaction"));
|
|
285 |
+ |
assert!(!"\n-- no-transaction\n...".starts_with("-- no-transaction"));
|
|
286 |
+ |
}
|
|
287 |
+ |
|
|
288 |
+ |
#[test]
|
|
289 |
+ |
fn unique_index_parsed_and_table_extracted_without_paren() {
|
|
290 |
+ |
let idx = parse_create_index(&only(
|
|
291 |
+ |
"CREATE UNIQUE INDEX CONCURRENTLY i ON subscriptions(user_id);",
|
|
292 |
+ |
))
|
|
293 |
+ |
.unwrap();
|
|
294 |
+ |
assert_eq!(idx.table, "subscriptions");
|
|
295 |
+ |
assert!(idx.concurrently);
|
|
296 |
+ |
}
|
|
297 |
+ |
|
|
298 |
+ |
#[test]
|
|
299 |
+ |
fn create_table_without_ine_is_flagged() {
|
|
300 |
+ |
let s = only("CREATE TABLE foo (id UUID PRIMARY KEY);");
|
|
301 |
+ |
assert!(is_create_table(&s));
|
|
302 |
+ |
assert!(!has_if_not_exists(&s));
|
|
303 |
+ |
}
|
|
304 |
+ |
|
|
305 |
+ |
#[test]
|
|
306 |
+ |
fn line_and_block_comments_are_stripped() {
|
|
307 |
+ |
let s = statements("/* c */ CREATE INDEX i ON items (a); -- trailing\n");
|
|
308 |
+ |
assert_eq!(s.len(), 1);
|
|
309 |
+ |
assert!(parse_create_index(&s[0]).is_some());
|
|
310 |
+ |
}
|
|
311 |
+ |
}
|