ReviewOS

also looking at this

stacks/bun-query-builder

fix(migrate): apply each migration atomically, and one run at a time

#1076
Merged glennmichael123 wants to merge fix/migrate-transaction-lock into main
3 files +364 -54
packages/bun-query-builder/src/actions/migrate.tsmodified+156-46
Changes to packages/bun-query-builder/src/actions/migrate.ts
@@ -383,6 +383,88 @@ export async function generateMigration(dir?: string, opts: MigrateOptions = {})
383383
384384// `dir` is the models directory. This function runs the SQL corpus, which it
385385// finds from the workspace root, so it has no use for it — the parameter stays
386/**
387 * The advisory-lock name every migrate run contends on.
388 *
389 * Constant on purpose: two processes must pick the same key to serialise, and
390 * migrations are global to a database rather than per-model or per-directory.
391 */
392const MIGRATION_LOCK_KEY = 'bun-query-builder:migrate'
393
394/**
395 * Statements that cannot run inside a transaction, so a file containing one
396 * must be applied unwrapped.
397 *
398 * Postgres rejects `CREATE INDEX CONCURRENTLY` and `DROP INDEX CONCURRENTLY`
399 * inside a transaction block outright, and `VACUUM` likewise — and building an
400 * index concurrently is exactly what a migration on a live table SHOULD do, so
401 * refusing to support it is not an option. Files that use them keep the old
402 * unwrapped behaviour and say so, rather than failing on something they were
403 * right to write.
404 */
405const NON_TRANSACTIONAL_SQL = /\bCONCURRENTLY\b|^\s*VACUUM\b/im
406
407/**
408 * Whether this dialect can roll a failed migration file back.
409 *
410 * Postgres has fully transactional DDL. SQLite does too, for the statements a
411 * migration uses. MySQL does NOT: `CREATE TABLE`, `ALTER TABLE` and friends
412 * force an implicit commit, so wrapping a file there produces a transaction
413 * that silently commits mid-way and hands back false confidence — worse than
414 * not wrapping, because it looks like it worked.
415 */
416function supportsTransactionalDdl(dialect: SupportedDialect): boolean {
417 return dialect === 'postgres' || dialect === 'sqlite'
418}
419
420/**
421 * Run `fn` while holding the migration advisory lock.
422 *
423 * Two processes booting at once — an ordinary rolling deploy — both read the
424 * same pending set and both apply it. The lock makes the second wait for the
425 * first, then find nothing pending.
426 *
427 * The lock is taken on a RESERVED connection and released on the same one.
428 * That is not incidental: a Postgres session lock lives until it is released
429 * or the session ends, and a pooled connection does not end when it goes back
430 * to the pool. Taking it on a pooled builder would strand the lock on whatever
431 * connection happened to serve the call, and the next migrate would wait
432 * forever on a lock nobody meant to hold.
433 *
434 * SQLite has no advisory-lock primitive and a single-writer file, so it runs
435 * unlocked rather than throwing.
436 */
437async function withMigrationLock<T>(dialect: SupportedDialect, fn: () => Promise<T>): Promise<T> {
438 if (dialect === 'sqlite')
439 return await fn()
440
441 const qb = createQueryBuilder() as any
442 let reserved: any
443 try {
444 reserved = await qb.reserve()
445 }
446 catch {
447 // A driver that cannot reserve (or a mock) should not block migrating.
448 return await fn()
449 }
450
451 let held = false
452 try {
453 await reserved.advisoryLock(MIGRATION_LOCK_KEY)
454 held = true
455 return await fn()
456 }
457 finally {
458 try {
459 if (held)
460 await reserved.advisoryUnlock(MIGRATION_LOCK_KEY)
461 }
462 finally {
463 reserved.release?.()
464 }
465 }
466}
467
386468// because it is public API and every caller passes it.
387469// eslint-disable-next-line pickier/no-unused-vars
388470export async function executeMigration(dir?: string): Promise<boolean> {
@@ -417,58 +499,86 @@ export async function executeMigration(dir?: string): Promise<boolean> {
417499 // Create migrations table if it doesn't exist
418500 await createMigrationsTable(qb, dialect)
419501
420 // Get already executed migrations
421 const executedMigrations = await getExecutedMigrations(qb, dialect)
422
423 /*
424 * Every migration file is permanent, and every one that runs is recorded.
425 *
426 * Generated ALTERs used to be "transient": replayed on every migrate
427 * rather than recorded, then deleted from disk once applied. That made
428 * the corpus a log of one machine's session instead of the schema. Change
429 * an attribute, migrate, and the ALTER that carried the change was gone —
430 * so a teammate who cloned the repo and migrated got the table as it was
431 * BEFORE the change, with no error and no clue. The generator writes each
432 * change once (`createMigrationFile` refuses to write a statement the
433 * corpus already contains), so there is nothing left for replay to fix,
434 * and everything to lose by deleting the evidence.
435 */
436 const pending = scriptFiles.filter(file => !executedMigrations.includes(file))
437
438 if (pending.length === 0) {
439 info('-- No pending migrations to execute')
440 return true
441 }
502 // Everything from here runs under the migration lock, and the read of the
503 // ledger is deliberately INSIDE it: computing `pending` before locking is
504 // what lets two processes agree on the same work and then both do it.
505 return await withMigrationLock(dialect, async () => {
506 // Get already executed migrations
507 const executedMigrations = await getExecutedMigrations(qb, dialect)
508
509 /*
510 * Every migration file is permanent, and every one that runs is recorded.
511 *
512 * Generated ALTERs used to be "transient": replayed on every migrate
513 * rather than recorded, then deleted from disk once applied. That made
514 * the corpus a log of one machine's session instead of the schema. Change
515 * an attribute, migrate, and the ALTER that carried the change was gone —
516 * so a teammate who cloned the repo and migrated got the table as it was
517 * BEFORE the change, with no error and no clue. The generator writes each
518 * change once (`createMigrationFile` refuses to write a statement the
519 * corpus already contains), so there is nothing left for replay to fix,
520 * and everything to lose by deleting the evidence.
521 */
522 const pending = scriptFiles.filter(file => !executedMigrations.includes(file))
523
524 if (pending.length === 0) {
525 info('-- No pending migrations to execute')
526 return true
527 }
442528
443 info(`-- Executing ${pending.length} migration${pending.length === 1 ? '' : 's'}`)
529 info(`-- Executing ${pending.length} migration${pending.length === 1 ? '' : 's'}`)
444530
445 for (const file of pending) {
446 const filePath = join(sqlDir, file)
447 info(`-- Executing: ${file}`)
531 for (const file of pending) {
532 const filePath = join(sqlDir, file)
533 info(`-- Executing: ${file}`)
448534
449 try {
450 await qb.file(filePath)
451 await recordMigration(qb, file, dialect)
452 info(`-- ✓ Migration ${file} executed and recorded`)
453 }
454 catch (err) {
455 // A generated migration whose change is already in place is not a
456 // failure, it is a bookkeeping gap: the file was applied on a run that
457 // died before recording it, or under the old transient behaviour that
458 // never recorded it at all. Record it and carry on — the schema is
459 // where the file says it should be. Anything else, including every
460 // authored migration, fails loudly.
461 if (isGeneratedMigration(sqlDir, file) && isAlreadyAppliedError(err)) {
462 await recordMigration(qb, file, dialect)
463 info(`-- ✓ Migration ${file} was already applied; recorded it (${err instanceof Error ? err.message : String(err)})`)
464 continue
535 try {
536 // Apply the file and record it as ONE unit where the dialect allows.
537 //
538 // Unwrapped, a file is applied statement by statement: if statement 3
539 // throws, statements 1 and 2 are already committed and the file is
540 // neither applied nor cleanly re-runnable. Recording inside the same
541 // transaction closes the matching gap at the other end, where the
542 // schema changed but the process died before the ledger was written.
543 const wrappable = supportsTransactionalDdl(dialect)
544 && !NON_TRANSACTIONAL_SQL.test(readFileSync(filePath, 'utf-8'))
545
546 if (wrappable) {
547 await qb.transaction(async (tx: any) => {
548 await tx.file(filePath)
549 await recordMigration(tx, file, dialect)
550 })
551 }
552 else {
553 await qb.file(filePath)
554 await recordMigration(qb, file, dialect)
555 if (!supportsTransactionalDdl(dialect))
556 info(`-- (${dialect} commits DDL implicitly, so ${file} was not applied atomically)`)
557 else
558 info(`-- (${file} contains a statement that cannot run in a transaction, so it was not applied atomically)`)
559 }
560 info(`-- ✓ Migration ${file} executed and recorded`)
561 }
562 catch (err) {
563 // A generated migration whose change is already in place is not a
564 // failure, it is a bookkeeping gap: the file was applied on a run that
565 // died before recording it, or under the old transient behaviour that
566 // never recorded it at all. Record it and carry on — the schema is
567 // where the file says it should be. Anything else, including every
568 // authored migration, fails loudly.
569 if (isGeneratedMigration(sqlDir, file) && isAlreadyAppliedError(err)) {
570 await recordMigration(qb, file, dialect)
571 info(`-- ✓ Migration ${file} was already applied; recorded it (${err instanceof Error ? err.message : String(err)})`)
572 continue
573 }
574 console.error(`-- ✗ Migration ${file} failed:`, err)
575 throw err
465576 }
466 console.error(`-- ✗ Migration ${file} failed:`, err)
467 throw err
468577 }
469 }
470578
471 info('-- All migrations executed successfully')
579 info('-- All migrations executed successfully')
580 return true
581 })
472582 }
473583 catch (err) {
474584 console.error('-- Migration execution failed:', err)
packages/bun-query-builder/src/client.tsmodified+51-8
Changes to packages/bun-query-builder/src/client.ts
@@ -2496,6 +2496,20 @@ finally { reserved.release() }
24962496 advisoryLock?: (key: number | string) => Promise<void>
24972497 /** Try to take an advisory lock and return false if unavailable (PostgreSQL only). */
24982498 tryAdvisoryLock?: (key: number | string) => Promise<boolean>
2499 /**
2500 * Release an advisory lock taken on THIS builder's connection.
2501 *
2502 * Both lock helpers existed without this one, which made them a trap rather
2503 * than a tool: a Postgres session lock is held until it is released or the
2504 * SESSION ends, and a pooled connection does not end when it is returned to
2505 * the pool. So `advisoryLock()` on a pooled builder leaked the lock onto
2506 * whichever connection happened to serve it, and the next caller waited
2507 * forever on a lock nobody was holding on purpose.
2508 *
2509 * Take the lock on a `reserve()`d connection and release it through the same
2510 * builder.
2511 */
2512 advisoryUnlock?: (key: number | string) => Promise<boolean>
24992513 /** Get all relationships defined for a table. */
25002514 getRelationships?: (table: string) => Record<string, any>
25012515 /** Check if a table has a specific relationship. */
@@ -2781,6 +2795,23 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
27812795 const meta = state?.meta
27822796 const schema = state?.schema
27832797
2798 /**
2799 * The 32-bit key Postgres advisory locks take, derived from a string name.
2800 *
2801 * Extracted because lock, try-lock and unlock each carried their own copy:
2802 * three transcriptions of one hash, where any drift between them means
2803 * `advisoryUnlock` computes a different key than `advisoryLock` did and
2804 * silently releases nothing.
2805 */
2806 function advisoryLockKey(key: number | string): number {
2807 if (typeof key === 'number')
2808 return key
2809 const s = String(key)
2810 let hash = 7
2811 for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0
2812 return Math.abs(hash)
2813 }
2814
27842815 /**
27852816 * The hooks this builder should fire.
27862817 *
@@ -7025,10 +7056,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
70257056 },
70267057 async advisoryLock(key: number | string): Promise<void> {
70277058 if (config.dialect === 'postgres') {
7028 const s = String(key)
7029 let hash = 7
7030 for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0
7031 const k = typeof key === 'number' ? key : Math.abs(hash)
7059 const k = advisoryLockKey(key)
70327060 const q = _sql`SELECT pg_advisory_lock(${k})`
70337061 await runWithHooks<any[]>(q, 'raw')
70347062 return
@@ -7049,10 +7077,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
70497077 },
70507078 async tryAdvisoryLock(key: number | string): Promise<boolean> {
70517079 if (config.dialect === 'postgres') {
7052 const s = String(key)
7053 let hash = 7
7054 for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0
7055 const k = typeof key === 'number' ? key : Math.abs(hash)
7080 const k = advisoryLockKey(key)
70567081 const q = _sql`SELECT pg_try_advisory_lock(${k}) as ok`
70577082 const rows = await runWithHooks<any[]>(q, 'raw')
70587083 return Boolean(rows?.[0]?.ok)
@@ -7071,6 +7096,24 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
70717096 // when locks aren't held.
70727097 return false
70737098 },
7099 async advisoryUnlock(key: number | string): Promise<boolean> {
7100 // Must run on the same connection that took the lock — hence `_sql`,
7101 // and hence the requirement that callers hold a reserved builder.
7102 if (config.dialect === 'postgres') {
7103 const k = advisoryLockKey(key)
7104 const q = _sql`SELECT pg_advisory_unlock(${k}) as ok`
7105 const rows = await runWithHooks<any[]>(q, 'raw')
7106 return Boolean(rows?.[0]?.ok)
7107 }
7108 if (isMysqlLike(config.dialect)) {
7109 const lockName = `bqb:${String(key)}`
7110 const q = _sql`SELECT RELEASE_LOCK(${lockName}) AS ok`
7111 const rows = await runWithHooks<any[]>(q, 'raw')
7112 return Number(rows?.[0]?.ok) === 1
7113 }
7114 // SQLite never took one; mirror tryAdvisoryLock's graceful shape.
7115 return false
7116 },
70747117 unsafe(query: string, params?: any[]) {
70757118 // Use this builder's connection. Transaction callbacks receive a
70767119 // builder whose `_sql` is the reserved transaction connection; routing
packages/bun-query-builder/test/migrate-atomicity.test.tsadded+157-0
Changes to packages/bun-query-builder/test/migrate-atomicity.test.ts
@@ -0,0 +1,157 @@
1/**
2 * Regression coverage for stacksjs/bun-query-builder#1067.
3 *
4 * `executeMigration` applied each `.sql` file with no transaction and no lock,
5 * recording it afterwards. Two consequences, and they need different dialects
6 * to observe, which is worth stating because it is not obvious:
7 *
8 * - PARTIAL APPLICATION shows up on SQLite. The sqlite wrapper splits a file
9 * into statements and runs them one at a time, so a file that fails partway
10 * leaves the earlier statements committed. (Postgres does not reproduce it
11 * through this path: Bun sends the file as one multi-statement query, which
12 * the server already runs in an implicit transaction. The wrap still closes
13 * the apply/record gap there, and matters for anyone applying files another
14 * way — but the statement-level hole is a sqlite one.)
15 *
16 * - CONCURRENT RUNS need a real pooled driver, so that half is gated on a live
17 * Postgres. SQLite has no advisory-lock primitive and a single-writer file,
18 * so it has no collision to prevent.
19 */
20
21import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
22import { tmpdir } from 'node:os'
23import { join, resolve } from 'node:path'
24import process from 'node:process'
25import { SQL } from 'bun'
26import { describe, expect, it } from 'bun:test'
27
28const SRC = resolve(import.meta.dir, '../src/index.ts')
29const PG_URL = `postgres://${process.env.USER}@localhost:5432/postgres`
30
31let pgAvailable = false
32try {
33 const probe = new SQL(PG_URL)
34 await probe.unsafe('SELECT 1')
35 await probe.end()
36 pgAvailable = true
37}
38catch {
39 pgAvailable = false
40}
41
42/** A throwaway workspace with a migrations directory. */
43function workspace(files: Record<string, string>): string {
44 const dir = mkdtempSync(join(tmpdir(), 'qb-mig-'))
45 writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'probe' }))
46 const migrations = join(dir, 'database', 'migrations')
47 mkdirSync(migrations, { recursive: true })
48 for (const [name, body] of Object.entries(files))
49 writeFileSync(join(migrations, name), body)
50 return dir
51}
52
53function run(dir: string, name: string, script: string): { code: number, out: string, err: string } {
54 const path = join(dir, name)
55 writeFileSync(path, script)
56 const proc = Bun.spawnSync({ cmd: ['bun', path], cwd: dir, stdout: 'pipe', stderr: 'pipe', env: { ...process.env } })
57 return {
58 code: proc.exitCode ?? -1,
59 out: new TextDecoder().decode(proc.stdout).trim(),
60 err: new TextDecoder().decode(proc.stderr).trim(),
61 }
62}
63
64const lastJson = (out: string): any => JSON.parse(out.split('\n').filter(Boolean).at(-1) ?? '{}')
65
66describe('migration atomicity (#1067)', () => {
67 it('rolls the whole file back when one of its statements fails', () => {
68 // Statement 1 is valid, statement 2 is not. Unwrapped, `mig_a` survives the
69 // failure and the file is left neither applied nor cleanly re-runnable.
70 const dir = workspace({
71 '0000000001-partial.sql': [
72 'CREATE TABLE mig_a (id INTEGER PRIMARY KEY);',
73 'CREATE TABLE mig_b (id INTEGER PRIMARY KEY, oops NOT_A_TYPE CHECK());',
74 ].join('\n'),
75 })
76
77 const result = run(dir, 'probe.ts', `
78import { Database } from 'bun:sqlite'
79import { setConfig, executeMigration } from ${JSON.stringify(SRC)}
80
81setConfig({ dialect: 'sqlite', database: { database: './t.sqlite' }, verbose: false })
82try { await executeMigration(process.cwd()) } catch {}
83
84const db = new Database('./t.sqlite')
85// Explicit names, not a LIKE: 'mig_%' also matches the 'migrations' ledger.
86const tables = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('mig_a','mig_b')").all()
87console.log(JSON.stringify({ leftBehind: tables.map(r => r.name) }))
88`)
89
90 rmSync(dir, { recursive: true, force: true })
91 if (result.code !== 0)
92 throw new Error(`probe exited ${result.code}: ${result.err || result.out}`)
93
94 // Was ['mig_a'] — the first statement had already committed.
95 expect(lastJson(result.out).leftBehind).toEqual([])
96 })
97})
98
99describe.skipIf(!pgAvailable)('migration locking (#1067)', () => {
100 it('serialises concurrent runs instead of letting both apply', () => {
101 // Both processes start against an empty ledger, so without the lock both
102 // see the migration as pending, both run it, and the loser dies on
103 // 'relation already exists'.
104 // `pg_sleep` widens the window on purpose. Without it the two processes may
105 // simply not overlap, and the test passes whether or not the lock exists —
106 // a concurrency test that only sometimes exercises concurrency is worse
107 // than none. One second is far longer than the other process needs to reach
108 // the same point, so they are guaranteed to contend.
109 const dir = workspace({
110 '0000000001-concurrent.sql': [
111 'SELECT pg_sleep(1);',
112 'CREATE TABLE mig_concurrent (id serial primary key);',
113 ].join('\n'),
114 })
115
116 writeFileSync(join(dir, 'worker.ts'), `
117import { setConfig, executeMigration } from ${JSON.stringify(SRC)}
118setConfig({ dialect: 'postgres', database: { url: ${JSON.stringify(PG_URL)} }, verbose: false })
119await executeMigration(process.cwd())
120`)
121
122 const result = run(dir, 'probe.ts', `
123import { SQL } from 'bun'
124// Clear only this migration's traces — the ledger is shared with the rest of
125// the suite, so dropping it wholesale would delete other files' bookkeeping.
126const reset = new SQL(${JSON.stringify(PG_URL)})
127await reset.unsafe('DROP TABLE IF EXISTS mig_concurrent')
128await reset.unsafe("DELETE FROM migrations WHERE migration = '0000000001-concurrent.sql'").catch(() => {})
129await reset.end()
130
131const a = Bun.spawn({ cmd: ['bun', 'worker.ts'], cwd: process.cwd(), stdout: 'pipe', stderr: 'pipe' })
132const b = Bun.spawn({ cmd: ['bun', 'worker.ts'], cwd: process.cwd(), stdout: 'pipe', stderr: 'pipe' })
133const codes = await Promise.all([a.exited, b.exited])
134
135// Count only THIS migration. The suite shares one Postgres database, so other
136// files leave rows in the ledger and a total count is not ours to assert on.
137const check = new SQL(${JSON.stringify(PG_URL)})
138const [{ count }] = await check.unsafe(
139 "SELECT count(*)::int as count FROM migrations WHERE migration = '0000000001-concurrent.sql'",
140)
141await check.unsafe('DROP TABLE IF EXISTS mig_concurrent')
142await check.unsafe("DELETE FROM migrations WHERE migration = '0000000001-concurrent.sql'")
143await check.end()
144console.log(JSON.stringify({ codes, recorded: count }))
145`)
146
147 rmSync(dir, { recursive: true, force: true })
148 if (result.code !== 0)
149 throw new Error(`probe exited ${result.code}: ${result.err || result.out}`)
150
151 const { codes, recorded } = lastJson(result.out)
152 // Both succeed: the loser waits for the lock, then finds nothing pending.
153 expect(codes).toEqual([0, 0])
154 // And it is recorded once, not twice.
155 expect(recorded).toBe(1)
156 })
157})