migrateDeployOrThrow() in api-events/src/encore.service.ts, which runs prisma migrate deploy). A migration that waits on a lock holds the boot, and a migration that fails leaves a row that blocks every later deploy. This page is the rule for writing lock-taking DDL and the runbook for the failure.
What Prisma does (verified on Prisma 7.8.0, Postgres 14 and 18)
migrate deploydoes not wrap a file in one transaction. Unless the file has an explicitBEGINandCOMMIT, each statement runs on its own. A bareSET LOCAL lock_timeout = '5s';at the top of a file has no effect:SHOW lock_timeouton the next statement returns0(wait forever).- A
DROP TABLEof a table with a foreign key to a live table takesACCESS EXCLUSIVEon the referenced table, and waits indefinitely when something holds a conflicting lock. Every request that touches the referenced table queues behind it. - With an explicit
BEGIN; SET LOCAL lock_timeout = '...'; ... COMMIT;the timeout fires and the script rolls back. - A rolled-back script still leaves a failed
_prisma_migrationsrow (finished_atandrolled_back_atboth null). Every latermigrate deploythen fails withP3009until someone runsprisma migrate resolve --rolled-back <name>. - Prisma reports the aborted transaction, not the timeout. The surfaced error is
current transaction is aborted, commands ignored until end of transaction block, not thelock timeouttext. Do not grep for the cause in the Prisma output; read the Postgres log forcanceling statement due to lock timeout. migrateDeploy.tsretries only lock-contention text (P1002, advisory lock) and P1001. It does not retry a lock-timeout failure, and since the failed row blocks the next attempt, it could not help if it did.P3009is logged with the migration name and a pointer to this page, never retried, and the process exits 1 (encore.service.ts), so the revision fails to start.
SET LOCAL migrations are grandfathered in api-events/src/db/migrationLockSafety.baseline.json. They already ran. Do not edit them (see pitfall 17); add a new migration.
Required pattern for lock-taking DDL
Any migration that takes a lock stronger thanROW EXCLUSIVE on a live table (DROP TABLE, ALTER TABLE ... DROP, ALTER TABLE ... ADD CONSTRAINT, CREATE INDEX without CONCURRENTLY, type changes) uses all of these:
- An explicit
BEGIN;as the first statement andCOMMIT;as the last.SET LOCALis only valid inside it. SET LOCAL lock_timeout = '2s';right afterBEGIN.- Each risky statement in its own
DOblock that retries a bounded number of times onlock_not_available(SQLSTATE55P03) ordeadlock_detected(40P01), withpg_sleepbetween attempts. TheDOblock’s innerBEGIN ... EXCEPTIONis a subtransaction, so a caught deadlock rolls back only that attempt and the next one starts clean. - A defined outcome when the attempts run out, chosen by whether the change is optional:
- Optional cleanup (dropping an obsolete table or column that no running code reads): a non-fatal
RAISE NOTICE. The object stays, and a later idempotent migration drops it once the table is quiet. The migration never fails on lock contention. The template below does this. - Required change (an
ADD CONSTRAINT, a type change, or a drop that the new code depends on): never skip silently. A skipped statement still commits, Prisma records the migration as applied, and it is never retried, so the schema the new code needs would be missing. Replace theRAISE NOTICEwithRAISE EXCEPTION 'users_email_key not added after % attempts', attempt;. The transaction rolls back, the deploy fails, and you recover with the P3009 runbook below. Where a failed deploy is not acceptable, split the work: ship the change in a later migration or a separate step that you confirm has finished.
- Optional cleanup (dropping an obsolete table or column that no running code reads): a non-fatal
IF EXISTS/IF NOT EXISTSon every statement, so a replay and a later retry are no-ops (.cursor/rules/sql-migration-idempotence.mdc).
lock_timeoutapplies to each lock wait, not to the whole script.statement_timeoutis a separate setting.- Locks acquired earlier in the transaction are held until
COMMIT, including across thepg_sleep. Keep the transaction small, and put the lock-taking statements first. - The
EXCEPTIONblock rolls back only its own subtransaction, so the transaction stays usable andCOMMITstill succeeds. - Put statements that need no retry (backfills,
CREATE TABLE IF NOT EXISTS) in the same transaction, outside theDOblocks. - A
DROP TABLEthat has a foreign key to a live table: drop the constraint in its own retryingDOblock first, so the wait is on the child table.
Copy-paste template (optional cleanup)
DO block once per statement so each gets its own attempt budget.
Audit table_config seeds
api-events/scripts/audit/generateTableConfigSeed.ts writes every *_audit_table_config_seed migration. Its last step re-bakes the audit triggers with audit.sync_triggers(), which drops and recreates a trigger on every audited table and so takes ACCESS EXCLUSIVE on each one. The re-bake is a required change, so a seed generated from this template fails rather than skips:
- The seed runs in one
BEGIN…COMMITwithSET LOCAL lock_timeout = '2s'. - Before the re-bake it locks every table the re-bake touches, in name order: the tables with an
audit_log_changetrigger, the re-attach list, and theorg_parent_pathparents. - A lock timeout rolls back that round’s subtransaction, which releases every lock taken in the round. The block then sleeps for
min(attempts on that table, 5)seconds and starts a new round. No lock is held during the sleep. - Each table gets 5 attempts and the rounds stop at 20. When either budget runs out, the block raises
audit trigger refresh could not lock <table> ...(SQLSTATE55P03). The whole seed rolls back and the deploy fails. Recover with the P3009 runbook below.
--new uses the template above. npm run check:audit-table-config-seed accepts the newest seed in either shape, as long as its manifest rows match. scripts/audit/generateTableConfigSeed.test.ts checks the emitted SQL, and src/db/auditTableConfigSeedLock.integration.test.ts replays it, holds a lock against it and checks that it fails or finishes.
Check
npm run check:migration-lock-safety (in api-events, part of checks:static, pre-commit and CI) fails on a migration not in the baseline that:
- uses
SET LOCALin a statement that is not between an explicitBEGINand itsCOMMIT, or - contains
DROP TABLEorALTER TABLE ... DROPoutside aDOblock and outside aBEGIN…COMMITspan.
BEGIN / START TRANSACTION and COMMIT / END / ROLLBACK statement by statement, so a SET LOCAL or DROP before the BEGIN or after the COMMIT is still reported. Text inside single-quoted literals is ignored.
SET LOCAL and DROP inside a stored function body (CREATE FUNCTION ... AS $$ ... $$) or a string do not run during the migration and are not reported. A DO block runs, so its SET LOCAL is.
The baseline can only fall. FROZEN_CUTOFF and FROZEN_GRANDFATHERED_COUNT live in migrationLockSafety.ts, not in the JSON, and the check fails unless the JSON matches both. An entry whose file now passes must be removed from migrationLockSafety.baseline.json, and the same change lowers the count constant. Adding an entry, or moving the cutoff, is a code change a reviewer sees, and it fails the check. Do not add a new migration to the baseline; fix the migration.
Recover from P3009
Symptom: a new api-events revision exits during boot, and the log hasP3009 and migrate found failed migrations in the target database.
1. Reach the environment’s database
The repo has no wrapper, proxy script or bastion for dev, qa or prod databases, and CI must not runprisma migrate deploy against them (docs/engineering/ci-cd/overview.mdx). What it does define is where each environment’s connection string lives. Terraform (infra-gcp/modules/events/main.tf) mounts the Secret Manager secret ${env}-DatabaseUrl as DATABASE_URL:
docs/engineering/backend/database.mdx). Get access from whoever owns that network path before an incident, not during one.
Locally the database is the shared Docker Postgres: DATABASE_URL=postgresql://postgres:postgres@localhost:5433/soundcheck?schema=public.
psql rejects the Prisma-only ?schema=public query parameter. Use psql "${DATABASE_URL%%\?*}".
2. Identify the failed migration
The boot log names it. To confirm against the table:3. Decide what the failed script left behind
- A script with an explicit
BEGIN/COMMITrolled back completely. Nothing it did is in the database. - A legacy script without
BEGINran statement by statement. Earlier statements committed. Inspect the schema, then either finish the rest by hand and mark it applied (--appliedin step 4), or roll it back and let it re-run (it must be replay-safe, which the idempotence policy requires of new migrations).
4. Resolve, verify, redeploy
Run fromapi-events/ with DATABASE_URL exported. Use the pinned local CLI, not an unpinned npx prisma. scripts/migrate.mjs only wraps deploy, status and reset, so resolve is run directly.
db:migrate:status should list <migration_name> as pending and no failed migrations. Then redeploy: start a new revision (api-events-deploy.yml, or restart the service), which boots and runs migrate deploy, or run npm run db:migrate yourself.
Before redeploying, make sure the script cannot fail the same way:
- No environment has applied it. The rolled-back migration was never applied, so you may edit its SQL to the pattern above, then redeploy.
- Another environment already applied it (so the file must stay as it is, pitfall 17), and it keeps failing in this one. A later repair migration cannot help: after
--rolled-back,migrate deployruns the unchanged file again before any newer migration. Pick one:- Remove the blocking condition, then redeploy the unchanged file. For a lock timeout, find the holder with
SELECT pid, state, query, xact_start FROM pg_stat_activity WHERE state <> 'idle' ORDER BY xact_start;(the Postgres log line iscanceling statement due to lock timeout), end that transaction or wait for it, and redeploy in a quiet window. - Finish the change by hand in this database (run the file’s statements yourself in one
BEGIN…COMMIT, with the same lock handling), then mark it applied with./node_modules/.bin/prisma migrate resolve --applied <migration_name> --config prisma.config.tsinstead of--rolled-back. Confirm the resulting schema matches what the other environments have before you do.
- Remove the blocking condition, then redeploy the unchanged file. For a lock timeout, find the holder with