Skip to content

fix(migrations): make deploy-time DDL lose the lock race, not user traffic - #5040

Open
RSO wants to merge 1 commit into
mainfrom
fix/migration-lock-safety
Open

fix(migrations): make deploy-time DDL lose the lock race, not user traffic#5040
RSO wants to merge 1 commit into
mainfrom
fix/migration-lock-safety

Conversation

@RSO

@RSO RSO commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Why

Migration 0204_drop_cost_insight_tables deadlocked against live traffic on five consecutive production deploys (12:37, 13:39, 13:53, 13:58 UTC on Aug 5, plus manual retries), killing 13 user requests — KILOCODE-WEB-27KW. Each merge to main re-attempted the still-pending migration and re-fired, so promote-app was blocked for ~1h40m. A failed migration in this setup is not inert.

The cause is relation-level lock inversion, not a bug in the migration. DROP TABLE takes ACCESS EXCLUSIVE on the target and on every table it references by foreign key, because dropping the constraint removes the referential-integrity trigger that lives on the parent. 0204 dropped 12 tables holding 21 foreign keys into kilocode_users (12), organizations (8), and microdollar_usage (1), so a single statement needed ACCESS EXCLUSIVE on two of the busiest tables in the schema:

migration:   AccessExclusive(kilocode_users) held  ──waits──> AccessExclusive(organizations)
app SELECT:  AccessShare(organizations)      held  ──waits──> AccessShare(kilocode_users)

getUserOrganizationsWithSeats reads organizationsorganization_membershipskilocode_usersorganization_invitations, taking AccessShare one relation at a time in query order, which closes the cycle. PostgreSQL's deadlock detector aborts whichever party notices the cycle — in practice user requests, because production has lock_timeout = 0 and the migration just sits in the queue.

The PR description for #5024 reasoned about incoming FKs and readers of the dropped tables and concluded it was "safe to run at any point relative to the deploy". That is true for correctness but says nothing about locking, and the outgoing FKs are what bit.

Measured locally against deadlock_timeout = 1000ms, holding ACCESS EXCLUSIVE on kilocode_users from another session:

Result
With lock_timeout=500ms ERROR: canceling statement due to lock timeout at 0.63s
Without (production today) sits in the lock queue 2.60s — 2.6× past deadlock_timeout

What this does

Applies migrations with a runner that sets lock_timeout below deadlock_timeout, so the migration surrenders the lock queue before the detector can pick a victim, and retries lock failures so a transient collision costs seconds instead of a deploy.

A custom runner is required: drizzle-kit migrate builds its own pg.Pool and zod-strips unknown dbCredentials keys, so lock_timeout cannot be injected via drizzle.config.ts. It also discarded every error field — the failed deploy printed only applying migrations...undefined, which is why Sentry was the only way to learn this was a deadlock.

The runner delegates to the same drizzle-orm migrator, so migration semantics are unchanged. It uses a single pg.Client rather than a Pool because the migrator runs the whole batch in one transaction on one connection, keeping SET lock_timeout in force with no pool checkout to race against. (pool.on('connect') was the first approach and tripped a pg deprecation that becomes an error in pg@9.)

Behaviour worth reviewing:

  • Refuses to start when lock_timeout is 0 or ≥ deadlock_timeout, asserted against pg_settings rather than assumed.
  • Retries only 40P01, 55P03, 40001 (10 attempts, linear backoff to 15s). Real migration bugs still fail on attempt one.
  • 11 existing migrations break out of the transaction with a bare COMMIT; for CREATE INDEX CONCURRENTLY. Replaying those would leave earlier statements applied but unrecorded, so they are detected and run with retries disabled, with a log line saying so.
  • Tunable via MIGRATION_LOCK_TIMEOUT and MIGRATION_MAX_ATTEMPTS.

Documentation records the rules this incident exposed: the FK-parent locking rule and the per-constraint decomposition that avoids it, the single-transaction batching hazard, and the folderMillis skip hazard.

Blast radius

Only deploy-production.yml:78 and deploy-staging.yml:74 change. pnpm drizzle migrate is untouched, so local development, test:db, verify-bootstrap, chromatic, setup-smoke, and kiloclaw dev-start behave exactly as before.

The trade to weigh: deploys can now fail with 55P03 where they previously succeeded after a long lock wait. That is intended — the deploy absorbs the failure instead of user requests — but a busy database can cost a deploy retry.

Verification

  • pnpm --filter @kilocode/db typecheck and lint — clean. oxfmt applied, git diff --check clean.
  • 20 unit tests in packages/db/src/migrate.test.ts — pass.
  • Bootstrapped a scratch database: 206 migrations applied on attempt 1, second run a clean no-op, all 11 transaction-breaking migrations correctly identified.
  • Both refusal paths exit 1 with a readable message; 900ms proceeds.
  • pnpm drizzle:verify-bootstrap still passes (untouched path).
  • Lock mechanism proven end-to-end against local PostgreSQL (table above).

Not full pnpm validate — the repo-wide suite is unrelated to this diff. The checks above are targeted.

Known gap, pre-existing and not fixed here

packages/db tests do not run anywhere. apps/web/jest.config.ts:38 lists packages/db/src/**/*.test.ts in testMatch, but roots defaults to apps/web, so jest never crawls that directory — jest --listTests returns zero matches there. Five test files are dead, and the new test file is dead with them; it was run via an explicit --roots override.

I deliberately did not fix this, because enabling roots surfaces two pre-existing failures that need real decisions:

  • packages/db/src/client.test.ts:39 expects pg.Pool called with { connectionString, max: 1 }, but createDrizzleClient now spreads getDatabaseClientConfig (host/user/password/ssl).
  • packages/db/src/schema.test.ts:1418 reads migrations/0204_brainy_baron_strucker.sql, which does not exist — 0204 is 0204_drop_cost_insight_tables.sql. A 0204 numbering collision, the same slot as the incident migration.

Happy to do that as a focused follow-up. Until then, treat the new coverage as real but not CI-enforced.

Deliberately not included

App-layer retry of 40P01/40001 in the shared web db wrapper. It would have made all 13 events invisible and helps beyond migrations, but it changes behaviour for every query in apps/web and belongs in its own PR.

…affic

Migration 0204 deadlocked against live traffic on five consecutive
production deploys, killing 13 user requests across
/api/users/notifications and /api/webhooks/github before it landed.

The cause was relation-level lock inversion. DROP TABLE takes ACCESS
EXCLUSIVE on the target and on every table it references by foreign key,
because dropping the constraint removes the referential-integrity
trigger on the parent. 0204 dropped 12 tables holding 21 foreign keys
into kilocode_users, organizations, and microdollar_usage, so it needed
ACCESS EXCLUSIVE on two of the busiest tables in the schema at once.
Ordinary queries touching both in the other order completed the cycle,
and the deadlock detector aborts whichever party notices it -- in
practice user requests rather than the migration.

Production has lock_timeout = 0, so the migration sat in the lock queue
indefinitely and stayed exposed to the detector. Measured locally, a
conflicting statement waits 2.60s without lock_timeout versus failing at
0.63s with it, against a deadlock_timeout of 1000ms.

Apply migrations with a runner that sets lock_timeout below
deadlock_timeout, so the migration is the only casualty, and retries
lock failures so a transient collision costs seconds instead of a
deploy. A custom runner is required because drizzle-kit builds its own
pg.Pool and zod-strips unknown dbCredentials keys, so lock_timeout
cannot be injected through drizzle.config.ts. It also discarded every
error field: the failed deploy printed only "applying
migrations...undefined".

The runner delegates to the same drizzle-orm migrator, so migration
semantics are unchanged. It uses a single pg.Client rather than a Pool
because the migrator runs the whole batch in one transaction on one
connection, which keeps SET lock_timeout in force with no pool checkout
to race against.

Behaviour worth knowing:

- It refuses to start when lock_timeout is 0 or >= deadlock_timeout,
  asserted against pg_settings rather than assumed.
- Only 40P01, 55P03, and 40001 are retried. Real migration bugs still
  fail on the first attempt.
- 11 existing migrations break out of the transaction with a bare
  COMMIT; for CREATE INDEX CONCURRENTLY. Replaying those would leave
  earlier statements applied but unrecorded, so they are detected and
  run with retries disabled.

Only the production and staging deploy jobs are switched over. `pnpm
drizzle migrate` is untouched, so local development, test:db,
verify-bootstrap, chromatic, setup-smoke, and kiloclaw dev-start behave
exactly as before.

Documentation records the rules this incident exposed: the FK-parent
locking rule and the per-constraint decomposition that avoids it, the
single-transaction batching hazard, and the folderMillis skip hazard
(the migrator compares each journal entry against max(created_at), so
an entry older than the newest applied migration is skipped
permanently and silently).
await client.connect();

try {
await client.query(`SET lock_timeout = '${lockTimeout}'`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: MIGRATION_LOCK_TIMEOUT is interpolated directly into SQL

lockTimeout comes straight from the environment and is string-interpolated into the SET lock_timeout = '...' statement. In the deploy workflow the value is operator-controlled, so this is not remotely exploitable — but this runner executes against production with broad privileges, and any value containing a quote breaks or injects SQL. SET cannot take bound parameters, but set_config can, which removes the interpolation entirely and leaves format validation to PostgreSQL:

Suggested change
await client.query(`SET lock_timeout = '${lockTimeout}'`);
await client.query('SELECT set_config($1, $2, false)', ['lock_timeout', lockTimeout]);

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

);
const row = ledgerRowSchema.safeParse(result.rows[0]);
appliedThrough = row.success ? Number(row.data.max_created_at ?? 0) : 0;
} catch {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Broad catch {} treats any ledger query failure as "fresh database"

If select max(created_at) ... from drizzle.__drizzle_migrations fails for any reason other than a missing ledger (transient connection error, permission problem, lock timeout), this catch silently sets appliedThrough = 0. The log then claims every migration since the beginning is pending, and because the old COMMIT-breaking migrations now appear in pending, retries are silently disabled for the whole run. The failure direction is safe (no incorrect application), but the loss of the retry safety net is invisible. Consider narrowing the catch to the undefined-relation error code (42P01) or at least logging the swallowed error.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 2 Issues Found | Recommendation: Address before merge

Executive Summary

The lock-safe migration runner is well-designed and correctly detects all 11 transaction-breaking migrations, but the deploy-time DDL tool interpolates an environment variable directly into a SQL statement in packages/db/src/migrate.ts.

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
packages/db/src/migrate.ts 271 MIGRATION_LOCK_TIMEOUT env value interpolated raw into SET lock_timeout = '...' SQL

SUGGESTION

File Line Issue
packages/db/src/migrate.ts 233 Broad catch {} treats any ledger query failure as fresh database, silently disabling retries
Files Reviewed (10 files)
  • packages/db/src/migrate.ts - 2 issues
  • packages/db/src/migrate-cli.ts - 0 issues
  • packages/db/src/migrate.test.ts - 0 issues
  • package.json - 0 issues
  • .github/workflows/deploy-production.yml - 0 issues
  • .github/workflows/deploy-staging.yml - 0 issues
  • packages/db/AGENTS.md - 0 issues
  • .kilo/skills/database-migrations/SKILL.md - 0 issues
  • DEVELOPMENT.md - 0 issues
  • REVIEW.md - 0 issues

Fix these issues in Kilo Cloud


Reviewed by kimi-k3 · Input: 156.6K · Output: 26.5K · Cached: 614.4K

Review guidance: REVIEW.md from base branch main

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant