Skip to content

Decide against a non-transactional migration runner, and record the trigger - #166

Merged
guarzo merged 1 commit into
mainfrom
docs/migration-runner-decision
Aug 6, 2026
Merged

Decide against a non-transactional migration runner, and record the trigger#166
guarzo merged 1 commit into
mainfrom
docs/migration-runner-decision

Conversation

@guarzo

@guarzo guarzo commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Answers the open item in docs/design-sweep/SECOND-PASS.md §4b: does authGD need a migration runner that can apply statements outside a transaction (so CREATE INDEX CONCURRENTLY becomes expressible)?

No — and the reasoning now lives somewhere durable instead of in a PR thread.

CodeRabbit raised this on #163, it was answered there, and the reviewer withdrew the finding. This wasn't an open disagreement; it was a settled call recorded only as a "why deferred" table cell.

What reading the migrator changed

Two facts from drizzle-orm/pg-core/dialect.cjs make the case stronger than the deferral recorded, and correct it:

  1. The migrator wraps the entire pending batch in one transaction, not one per migration — session.transaction sits outside the for await loop. SECOND-PASS.md said "wraps every migration in one," which understated it.
  2. __drizzle_migrations is a high-water mark (order by created_at desc limit 1, compared with <), not a set of applied hashes. Anything that advances it out of order is not self-correcting.

So a custom runner's real risk isn't the INVALID index everyone focuses on — it's decoupling the DDL from the bookkeeping insert. A mid-batch failure then commits statements the high-water mark still sits behind; the retry re-runs them and dies on "already exists", wedging the deploy inside the gate that fly.toml runs on every release. A failed CONCURRENTLY build is precisely the case that triggers this. The one feature the runner would exist for is the one that detonates it.

Against that, the avoided cost is a sub-second SHARE lock — writes block, reads don't.

The COMMIT;-in-the-file hack is worse than inelegant: because the transaction spans the batch, a COMMIT; in one file also commits every pending migration before it and runs every one after it untransacted — a non-local effect that only appears on a deploy carrying more than one pending migration. It also requires hand-editing a generated migration, which this project forbids.

What's recorded

A new docs/ops.md section, Migrations run in one transaction — deliberately:

  • why the transaction/bookkeeping coupling is the property worth protecting
  • four checkable revisit triggers (~5M rows; a timed build >5s; multi-tenant; reaching for the manual procedure a third time)
  • the discipline that replaces machinery: time the build against production-sized data before adding any index to audit_log
  • if it fires: a watched out-of-band procedure, with its failure mode stated plainly — it needs one manual bookkeeping INSERT, and getting it wrong in either direction is bad (skip it → deploy fails on "already exists"; wrong created_at → silently masks other pending migrations)

Two prose corrections found while writing this up

code-reviewer caught both, and they were mine:

  • sync_run is never purged. purge.ts covers expired sessions, spent OAuth transactions, and dispatched outbox rows only. I'd called it "bounded" — it is unbounded too, just ~an order of magnitude slower than audit_log. Flagged in the doc, deliberately not fixed here: it wants its own retention policy and that's a separate change.
  • 0002/0003 build indexes against tables created in 0000, so those weren't necessarily empty either. The real distinguishing argument is table size, not emptiness.

Scope

No change to how migrations are applied — that's a red line and I didn't cross it. src/db/migrate.ts gains a comment only, pointing at the runbook from the file where someone will actually ask the question.

Verification

format:check  All matched files use Prettier code style!
typecheck     clean (no output)
lint          clean (no output)
build         succeeded
npm test      Test Files 80 passed (80) / Tests 1217 passed (1217)

80 test files vs #163's 77 baseline, so nothing silently dropped. npm run build run locally since CI is currently broken.

The documented bookkeeping command was proven end-to-end rather than by code-reading: extracted verbatim from ops.md, it emits 327e0000…74a8, 1786046498461 — byte-identical to the real __drizzle_migrations row in a live database with 0010 applied. (First draft of that command embedded a ' via ${"'"} inside a shell single-quoted string, which would have broken on paste; it now builds the quote with String.fromCharCode(39).)

Reviewer focus

docs/ops.md's claims about drizzle internals — they're the load-bearing part and they're all checkable against node_modules/drizzle-orm/pg-core/dialect.cjs and migrator.cjs. If the batch-transaction or high-water-mark reading is wrong, the recommendation changes.

…d the trigger

Closes the open item in docs/design-sweep/SECOND-PASS.md section 4b. CodeRabbit
raised it on #163, it was answered there, and the reviewer withdrew the finding
— but the reasoning lived only in a PR thread and a "why deferred" table cell.

Decision: keep drizzle's stock migrate(). Reading the migrator source makes the
case stronger than the deferral recorded:

- it wraps the ENTIRE pending batch in one transaction, not one per migration
  (pg-core/dialect.cjs: session.transaction sits outside the for-await loop)
- __drizzle_migrations is read as a high-water mark (order by created_at desc
  limit 1), not a set of applied hashes

So a custom runner's real risk is not the INVALID index, it is decoupling the
DDL from the bookkeeping insert: a mid-batch failure commits statements the
high-water mark still sits behind, the retry re-runs them and dies on "already
exists", and the deploy wedges inside the gate. A failed CONCURRENTLY build is
precisely the case that triggers it. Against that, the avoided cost is a
sub-second SHARE lock.

The COMMIT;-in-the-file hack is worse than inelegant: because the transaction
spans the batch, it also commits every pending migration before it and runs
every one after it untransacted.

If the trigger ever fires, apply such an index out-of-band, watched, with a
documented manual bookkeeping insert — failure mode stated in the doc.

Also corrects two prose claims found while writing this up: sync_run is not
purged (so it is unbounded too, just slower), and 0002/0003 build indexes
against tables created in 0000, so those were not necessarily empty either.

No change to how migrations are applied. src/db/migrate.ts gains a comment only.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 21 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b98f3a76-9d97-406a-8044-38e465ec967b

📥 Commits

Reviewing files that changed from the base of the PR and between e9ff584 and 4f71f91.

⛔ Files ignored due to path filters (1)
  • docs/design-sweep/SECOND-PASS.md is excluded by !docs/design-sweep/**
📒 Files selected for processing (2)
  • docs/ops.md
  • src/db/migrate.ts

Comment @coderabbitai help to get the list of available commands.

@guarzo
guarzo merged commit 4c543da into main Aug 6, 2026
1 check passed
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