Files
simstudioai--sim/packages/db/scripts/migrate.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

326 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getPostgresErrorCode } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { backoffWithJitter } from '@sim/utils/retry'
import { drizzle } from 'drizzle-orm/postgres-js'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import postgres from 'postgres'
import { runScriptMigrations } from '../script-migrations/index'
/**
* Concurrent-index convention: plain `CREATE INDEX` write-blocks large/hot
* tables, and CONCURRENTLY cannot run inside drizzle's migration transaction.
* For indexes on big tables, edit the generated SQL to:
*
* COMMIT;--> statement-breakpoint
* SET lock_timeout = 0;--> statement-breakpoint
* CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_name" ON "table" (...);--> statement-breakpoint
* SET lock_timeout = '5s';
*
* The embedded COMMIT ends the batch transaction, so everything after it (in
* this and later pending files) runs in autocommit and must be idempotent
* (`IF NOT EXISTS` etc.) — a failed run replays unjournaled files from the top.
* A failed CONCURRENTLY build leaves an INVALID index that `IF NOT EXISTS`
* skips; `warnOnInvalidIndexes` below surfaces those.
*/
/**
* Prefer a direct (non-pooled) DSN: session advisory locks and session `SET`s
* are unsupported through PgBouncer transaction pooling. Falls back to
* DATABASE_URL for setups that connect directly anyway.
*/
const url = process.env.MIGRATION_DATABASE_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)
}
/**
* The pid guard is only sound on a direct connection — through transaction
* pooling, consecutive statements legitimately land on different backends.
*/
const hasDirectMigrationUrl = Boolean(process.env.MIGRATION_DATABASE_URL)
/**
* `max_lifetime: null` pins the session for the whole run: the postgres-js
* default recycles the connection after 3060 min, silently dropping the
* session advisory lock and `SET`s.
*/
const client = postgres(url, {
max: 1,
connect_timeout: 10,
max_lifetime: null,
connection: { application_name: 'sim-migrate' },
})
/**
* Cross-process migration lock. drizzle's `migrate()` has no built-in lock, so
* concurrent runners (one per app replica at deploy time) must be serialized.
* Acquisition is a bounded try-lock loop: a plain `pg_advisory_lock` wait let
* one wedged runner silently hang every other runner and the whole deploy.
*/
const MIGRATION_LOCK_KEY = 4_961_002_270n
const LOCK_ACQUIRE_DEADLINE_MS = 30 * 60_000
const LOCK_RETRY_INTERVAL_MS = 5_000
/**
* Max time a migration statement may queue for a table lock (SQLSTATE 55P03 on
* expiry). Without it, DDL waiting on an AccessExclusiveLock queues every other
* query on the table behind it — a table-wide stall for the whole wait.
*/
const DDL_LOCK_TIMEOUT = '5s'
const MAX_MIGRATE_ATTEMPTS = 8
const MIGRATE_RETRY_BACKOFF = { baseMs: 2_000, maxMs: 30_000 } as const
const CONNECT_MAX_ATTEMPTS = 10
const CONNECT_RETRY_BACKOFF = { baseMs: 1_000, maxMs: 15_000 } as const
/**
* Error codes that mean the database was momentarily unreachable rather than
* the migration being wrong: chiefly `53300` (too_many_connections — every
* non-superuser slot was taken, surfaced as "remaining connection slots are
* reserved for roles with the SUPERUSER attribute"), the `08xxx`
* connection_exception class, and the postgres-js driver's own transport
* codes. These are retried while opening the session; anything else is fatal.
*/
const TRANSIENT_CONNECT_CODES = new Set([
'53300',
'53400',
'CONNECT_TIMEOUT',
'CONNECTION_CLOSED',
'CONNECTION_DESTROYED',
'CONNECTION_ENDED',
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'EHOSTUNREACH',
'ENOTFOUND',
])
function isTransientConnectError(error: unknown): boolean {
const code = getPostgresErrorCode(error)
if (!code) return false
return TRANSIENT_CONNECT_CODES.has(code) || code.startsWith('08')
}
/** Backend pid of the lock-holding session; a change means the lock was lost. */
let lockSessionPid = 0
try {
await connectWithRetry()
await acquireMigrationLock()
try {
await runMigrationsWithRetry()
console.log('Migrations applied successfully.')
await assertLockSessionHeld()
await runScriptMigrations(client)
await warnOnInvalidIndexes()
} finally {
await releaseMigrationLock()
}
} catch (error) {
console.error('ERROR: Migration failed.')
printMigrationError(error)
process.exit(1)
} finally {
await client.end()
}
/**
* Open the migration session before taking the advisory lock, retrying
* transient connection failures with bounded backoff. The deploy database can
* briefly exhaust every non-superuser connection slot at peak (`53300`); the
* migration is a single short-lived session, so waiting out a spike that frees
* within seconds is far safer than failing the whole deploy. Non-transient
* errors (auth, unknown host config, etc.) still fail fast.
*/
async function connectWithRetry(): Promise<void> {
for (let attempt = 1; ; attempt++) {
try {
await client`SELECT 1`
return
} catch (error) {
if (!isTransientConnectError(error) || attempt >= CONNECT_MAX_ATTEMPTS) throw error
const delayMs = backoffWithJitter(attempt, null, CONNECT_RETRY_BACKOFF)
console.warn(
`WARN: database unavailable (${getPostgresErrorCode(error)}); ` +
`attempt ${attempt}/${CONNECT_MAX_ATTEMPTS}, retrying in ${Math.round(delayMs)}ms.`
)
await sleep(delayMs)
}
}
}
/**
* Acquire the cross-process migration lock, failing loudly after the deadline
* instead of blocking forever behind a wedged runner.
*/
async function acquireMigrationLock(): Promise<void> {
const deadline = Date.now() + LOCK_ACQUIRE_DEADLINE_MS
for (;;) {
const [{ locked, pid }] =
await client`SELECT pg_try_advisory_lock(${MIGRATION_LOCK_KEY}) AS locked, pg_backend_pid() AS pid`
if (locked) {
lockSessionPid = pid
return
}
if (Date.now() >= deadline) {
throw new Error(
`Timed out after ${LOCK_ACQUIRE_DEADLINE_MS}ms waiting for the migration advisory lock; ` +
'another runner is likely stuck mid-migration. Investigate before retrying.'
)
}
await sleep(LOCK_RETRY_INTERVAL_MS)
}
}
/**
* Run pending migrations, retrying on lock timeout (55P03, found anywhere in
* the wrapped `cause` chain). Each attempt re-verifies the lock session (pid)
* and re-asserts the session timeouts — a migration file may have changed them,
* and `SET` cannot be parameterized, hence `client.unsafe` with constants.
* Replays are safe: drizzle rolls the batch back on failure, and post-COMMIT
* CONCURRENTLY statements are idempotent by convention.
*/
/**
* Verify the session still holds the migration advisory lock: a changed
* backend pid means the connection was recycled and the lock silently dropped.
* Only sound on a direct connection — see `hasDirectMigrationUrl`.
*/
async function assertLockSessionHeld(): Promise<void> {
if (!hasDirectMigrationUrl) return
const [{ pid }] = await client`SELECT pg_backend_pid() AS pid`
if (pid !== lockSessionPid) {
throw new Error(
`Database session changed mid-run (backend pid ${lockSessionPid} -> ${pid}); ` +
'the migration advisory lock was lost. Aborting so a fresh runner can retry safely.'
)
}
}
async function runMigrationsWithRetry(): Promise<void> {
for (let attempt = 1; ; attempt++) {
await assertLockSessionHeld()
await client.unsafe('SET statement_timeout = 0')
await client.unsafe(`SET lock_timeout = '${DDL_LOCK_TIMEOUT}'`)
try {
await migrate(drizzle(client), { migrationsFolder: './migrations' })
return
} catch (error) {
const isLockTimeout = getPostgresErrorCode(error) === '55P03'
if (!isLockTimeout || attempt >= MAX_MIGRATE_ATTEMPTS) throw error
const delayMs = backoffWithJitter(attempt, null, MIGRATE_RETRY_BACKOFF)
console.warn(
`WARN: migration DDL hit lock_timeout (attempt ${attempt}/${MAX_MIGRATE_ATTEMPTS}); ` +
`retrying in ${Math.round(delayMs)}ms.`
)
await sleep(delayMs)
}
}
}
/**
* A failed CONCURRENTLY build leaves an INVALID index that `IF NOT EXISTS`
* silently skips forever — surface it (warn only; the migration committed).
*/
async function warnOnInvalidIndexes(): Promise<void> {
try {
const rows = await client`
SELECT n.nspname AS schema, c.relname AS index
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid
`
for (const row of rows) {
console.warn(
`WARN: invalid index ${row.schema}.${row.index} — a CONCURRENTLY build failed partway. ` +
'Drop and rebuild it; IF NOT EXISTS will keep skipping it.'
)
}
} catch (checkError) {
console.warn('WARN: could not check for invalid indexes.', checkError)
}
}
/**
* Unlock errors are swallowed: the session lock auto-releases on disconnect,
* and a thrown unlock would falsely report a committed migration as failed.
*/
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)
}
}