chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
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 30–60 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user