mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
* fix(db): serialize concurrent migrations with a Postgres advisory lock Deployments start N app replicas at once, each with a migration sidecar. drizzle migrate() has no cross-process lock, so all N read __drizzle_migrations, all see the same migration pending, and all apply it concurrently — one wins, the losers run the same DDL against already-mutated state and exit 1 (e.g. DROP TABLE "form" -> table does not exist / TaskFailedToStart). Wrap migrate() in a session-level pg_advisory_lock so runners serialize: the winner migrates, the losers block, then re-read and find nothing pending. Session locks auto-release on disconnect, so a crashed runner never wedges the lock. * fix(db): guard pg_advisory_unlock so it cannot mask a successful migration If the explicit unlock throws (e.g. connection drops in the window after migrate() commits), the exception bubbled to the outer catch and exited 1 — falsely reporting a failed migration to the deploy orchestrator. The session lock auto-releases on disconnect anyway, so swallow and log instead. * refactor(db): move unlock-guard rationale to TSDoc helper
160 lines
5.4 KiB
TypeScript
160 lines
5.4 KiB
TypeScript
import { drizzle } from 'drizzle-orm/postgres-js'
|
|
import { migrate } from 'drizzle-orm/postgres-js/migrator'
|
|
import postgres from 'postgres'
|
|
|
|
/**
|
|
* Concurrent-index convention (avoid write-blocking index builds on large tables)
|
|
* --------------------------------------------------------------------------------
|
|
* drizzle-kit emits plain `CREATE INDEX`, which takes a SHARE lock and blocks all
|
|
* writes for the build duration — on a big, write-hot table (e.g.
|
|
* workflow_execution_logs, usage_log) that stalls every in-flight workflow
|
|
* completion for minutes. drizzle wraps each migration in a transaction, and
|
|
* `CREATE INDEX CONCURRENTLY` cannot run inside a transaction block.
|
|
*
|
|
* So, after generating a migration that adds an index on a large/hot table, edit
|
|
* the generated SQL to end drizzle's transaction first, then build concurrently
|
|
* and idempotently:
|
|
*
|
|
* COMMIT;--> statement-breakpoint
|
|
* CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_name" ON "table" (...);
|
|
*
|
|
* Notes:
|
|
* - Put the `COMMIT` breakpoint AFTER all transactional DDL (ALTER TABLE/TYPE)
|
|
* in the file and only the concurrent CREATE INDEX statements below it.
|
|
* - Use `IF NOT EXISTS` (and make sibling DDL idempotent, e.g.
|
|
* `ADD COLUMN IF NOT EXISTS`, `ADD VALUE IF NOT EXISTS`) so a re-run after a
|
|
* failed CONCURRENTLY build is safe — fresh DBs and re-applies both work.
|
|
* - CONCURRENTLY only takes a SHARE UPDATE EXCLUSIVE lock (allows reads/writes).
|
|
* - Always validate on staging before prod; a failed CONCURRENTLY build can
|
|
* leave an INVALID index that must be dropped and rebuilt.
|
|
*/
|
|
|
|
const url = process.env.DATABASE_URL
|
|
if (!url) {
|
|
console.error('ERROR: Missing DATABASE_URL environment variable.')
|
|
console.error('Ensure packages/db/.env is configured.')
|
|
process.exit(1)
|
|
}
|
|
|
|
const client = postgres(url, { max: 1, connect_timeout: 10 })
|
|
|
|
/**
|
|
* Cross-process migration lock key (a stable, app-wide 64-bit constant).
|
|
*
|
|
* drizzle's `migrate()` has no built-in lock, so when a deployment starts N app
|
|
* replicas at once — each with a migration sidecar — all N read
|
|
* `__drizzle_migrations`, all see the same migration pending, and all try to apply
|
|
* it concurrently. One wins; the losers run the same DDL against already-mutated
|
|
* state and die (e.g. `DROP TABLE "form"` → `table "form" does not exist`,
|
|
* exit 1 / TaskFailedToStart).
|
|
*
|
|
* A session-level `pg_advisory_lock` serializes runners: the first to acquire it
|
|
* migrates while the rest block, then each loser acquires the lock, re-reads
|
|
* `__drizzle_migrations`, finds nothing pending, and exits cleanly. Session locks
|
|
* auto-release if the connection drops, so a crashed runner never wedges the lock.
|
|
*/
|
|
const MIGRATION_LOCK_KEY = 4_961_002_270n
|
|
|
|
try {
|
|
// statement_timeout=0: index builds (esp. CONCURRENTLY on large tables) can run
|
|
// far longer than the app default; a migration must never be killed mid-build.
|
|
await client`SET statement_timeout = 0`
|
|
await client`SELECT pg_advisory_lock(${MIGRATION_LOCK_KEY})`
|
|
try {
|
|
await migrate(drizzle(client), { migrationsFolder: './migrations' })
|
|
console.log('Migrations applied successfully.')
|
|
} finally {
|
|
await releaseMigrationLock()
|
|
}
|
|
} catch (error) {
|
|
console.error('ERROR: Migration failed.')
|
|
printMigrationError(error)
|
|
process.exit(1)
|
|
} finally {
|
|
await client.end()
|
|
}
|
|
|
|
/**
|
|
* Release the advisory lock without ever failing the process. The session-level
|
|
* lock auto-releases when the connection closes, so a thrown unlock — e.g. the
|
|
* connection dropped right after `migrate()` committed — must be swallowed.
|
|
* Letting it reach the outer `catch` would exit 1 and falsely report a
|
|
* successful migration as failed to the deploy orchestrator.
|
|
*/
|
|
async function releaseMigrationLock(): Promise<void> {
|
|
try {
|
|
await client`SELECT pg_advisory_unlock(${MIGRATION_LOCK_KEY})`
|
|
} catch (unlockError) {
|
|
console.error(
|
|
'WARN: pg_advisory_unlock failed; the session lock will auto-release on disconnect.',
|
|
unlockError
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Print every diagnostic field a Postgres driver puts on a thrown error. The default
|
|
* `error.message` loses the constraint name, affected table/column, PG code, and hint —
|
|
* which are usually what you need to diagnose a failed migration.
|
|
*/
|
|
function printMigrationError(error: unknown): void {
|
|
if (!(error instanceof Error)) {
|
|
console.error(error)
|
|
return
|
|
}
|
|
|
|
console.error(`message: ${error.message}`)
|
|
|
|
const pgFields = [
|
|
'code',
|
|
'severity',
|
|
'severity_local',
|
|
'detail',
|
|
'hint',
|
|
'schema',
|
|
'schema_name',
|
|
'table',
|
|
'table_name',
|
|
'column',
|
|
'column_name',
|
|
'constraint',
|
|
'constraint_name',
|
|
'data_type',
|
|
'where',
|
|
'internal_query',
|
|
'internal_position',
|
|
'position',
|
|
'routine',
|
|
'file',
|
|
'line',
|
|
] as const
|
|
|
|
const err = error as Record<string, unknown>
|
|
for (const field of pgFields) {
|
|
const value = err[field]
|
|
if (value !== undefined && value !== null && value !== '') {
|
|
console.error(`${field}: ${String(value)}`)
|
|
}
|
|
}
|
|
|
|
if (err.query && typeof err.query === 'string') {
|
|
console.error('\nfailing query:')
|
|
console.error(err.query)
|
|
}
|
|
|
|
if (err.parameters !== undefined) {
|
|
console.error('\nparameters:')
|
|
console.error(err.parameters)
|
|
}
|
|
|
|
if (error.cause) {
|
|
console.error('\ncause:')
|
|
printMigrationError(error.cause)
|
|
}
|
|
|
|
if (error.stack) {
|
|
console.error('\nstack:')
|
|
console.error(error.stack)
|
|
}
|
|
}
|