Skip to content

docs(db): clarify migration transaction and recovery comments - #1281

Draft
IAmJSD wants to merge 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-874
Draft

IAmJSD wants to merge 2 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-874

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Root cause

Several comments explained the migration path wrongly. They said the golang-migrate driver "wraps every migration in BEGIN ... COMMIT". It doesn't. The real mechanism, checked against the pinned code:

  • Driver execution. newCancelableMigrator (internal/database/postgres/migrations/migrate_cancel.go:118-148) builds the golang-migrate v4.19.1 pgx/v5 driver with Config{DatabaseName: ...} only, so MultiStatementEnabled is false. No DSN in the repo sets x-multi-statement, and buildMigrateDSN (migrate.go:582) only adds sslmode. With that setting, Postgres.Run (database/pgx/v5/pgx.go:252-270) calls io.ReadAll on the file and passes it to runStatement (pgx.go:272-302), which makes a single p.conn.ExecContext(ctx, query) call with no arguments. The driver adds no BEGIN/COMMIT.
  • Protocol. pgx v5.9.2 stdlib ExecContext calls Conn.Exec, and conn.go:515-517 forces QueryExecModeSimpleProtocol when there are no arguments. The whole file goes to PostgreSQL as one simple Query message.
  • Atomicity. PostgreSQL runs a multi-statement simple Query as one implicit transaction, unless the text has its own transaction control. 000040 and the 000101 down migration wrap the whole file in BEGIN/COMMIT, which is also atomic. A one-statement file is atomic because it is a single statement.
  • Dirty state. golang-migrate runMigrations (migrate.go:723-772) calls SetVersion(N, true) in its own committed transaction (pgx.go:339-371) before Run, and calls SetVersion(N, false) only after Run succeeds. A failed or interrupted file therefore rolls back completely but leaves dirty=true at N. Up() refuses a dirty DB with ErrDirty, whose text is "Dirty database version N. Fix and force version." (migrate.go:52-54, :276). Our maybeAutoHealDirty (internal/database/postgres/migrations/migrate.go:407) fails loud and never clears the flag.

Some recovery comments had also gone stale or were wrong:

Fix (comments only)

  • internal/database/postgres/migrations/migration_transactional_test.go: the package comment now separates driver execution, PostgreSQL file atomicity, the policy of keeping non-transactional statements out of the request-time path (RunMigrations from ensureDB), and manual fail-loud dirty-state recovery. It also states three facts that were missing before:

    The forbiddenPatterns comments now say that plain REINDEX and REFRESH MATERIALIZED VIEW CONCURRENTLY can run in a transaction block and are listed as policy. The dollar-body comment no longer claims a DO body runs "later, outside the migration transaction". The t.Logf/t.Errorf message text changed to match. The guard's logic, patterns and opt-out are unchanged.

  • migrate.go: corrected comments on RunMigrations, newMigratorWithRecovery, maybeForceMigrationVersion and maybeAutoHealDirty, covering the atomicity source, the fail-loud dirty check, the force-down target with gaps, and the real ErrDirty text. No code or error-string changes.

  • migrate_autoheal_test.go: two comments that quoted the wrong ErrDirty text.

  • 000109_...up.sql, 000111_...up.sql: the "runner wraps every migration in a transaction" sentence is replaced. Only SQL comment lines changed; executable statements are identical. This is safe for already-applied files: golang-migrate stores only (version, dirty) (pgx.go:357) and never checksums or re-reads applied files. No repo test or CI step hashes migration files. The pre-commit migration hook only checks for duplicate numbers.

Closes #874

Verification

Commands, run on this branch (fix/issue-874, 1720539):

export PATH=$HOME/.local/go/bin:$HOME/go/bin:$HOME/.local/bin:$PATH AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true
go build ./...                                   # ok
go vet ./... && go vet -tags integration ./...   # ok
go test -count=1 ./internal/database/postgres/migrations/   # ok
go test -count=1 -v -run TestMigrationsAreTransactional ./internal/database/postgres/migrations/
  # transactional-safety scan: 254 migration files scanned, 0 skipped (opt-out); PASS
go test ./internal/mocks/ -count=1               # ok
golangci-lint v2.10.1 run --allow-parallel-runners --build-tags integration ./internal/database/postgres/migrations/   # 0 issues
gocyclo -over 10 -ignore "_test\.go" internal/database/postgres/migrations/migrate.go   # no output

No regression test was added. This is a comments-only change and has no fail-before/pass-after. To check the guard still behaves the same, I ran a mutation probe with a temporary, uncommitted 999999_*.up.sql file (deleted afterwards):

  • A file with CREATE INDEX CONCURRENTLY x ON y (z); still fails TestMigrationsAreTransactional (with the new message).
  • The same file with the -- migrate:no-transaction marker is skipped with the warning (1 skipped).

I checked the claims empirically on real Postgres (postgres:16-alpine) with a throwaway integration test that went through migrations.RunMigrations with temp migration dirs (not committed):

  • CREATE TABLE t874_a ...; SELECT 1/0; returned an error, left version=1 dirty=true, and the table was absent. A failing later statement rolls back the earlier one.
  • The same with a whole-file BEGIN; ... COMMIT; also left the table absent and dirty=true.
  • CREATE TABLE ...; CREATE INDEX CONCURRENTLY ... failed with "CREATE INDEX CONCURRENTLY cannot run inside a transaction block", left the table absent and dirty=true.
  • A file containing only CREATE INDEX CONCURRENTLY ... succeeded (dirty=false, index exists). The guard rejects this case as policy, not because PostgreSQL refuses it.
  • With CUDLY_MIGRATION_AUTOHEAL=false the dirty DB gave failed to run migrations: Dirty database version 1. Fix and force version. With the default, it gave the auto-heal: migration 1 is dirty; ... instructions.
  • With migrations 000001 and 000003 and dirty at 3, CUDLY_FORCE_MIGRATION_VERSION=2 gave no migration found for version 2. =1 re-applied 3 and ended clean.

I also ran psql against my own postgres:16 container:

  • REFRESH MATERIALIZED VIEW CONCURRENTLY and plain REINDEX TABLE succeed inside BEGIN ... COMMIT.
  • REINDEX ... CONCURRENTLY, VACUUM, ALTER SYSTEM, CREATE TABLESPACE and DROP INDEX CONCURRENTLY are refused in a multi-statement query.
  • EXECUTE 'CREATE INDEX CONCURRENTLY ...' in a DO block fails with "cannot be executed from a function".

The existing TestRunMigrations_DeadlineStopsMigrationSQLBlockedByForeignLock (migrate_cancel_integration_test.go:102) already covers the interrupted-migration case (rolled back, dirty at N). I didn't re-run it; the combined-tree run covers it.

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope (noted, not changed)

  • 000074_repair_partial_migration_058_067.up.sql:18-19 repeats "golang-migrate wraps the file in a single transaction". The issue only named 000109 and 000111, so I left the other applied migrations alone. A maintainer may want the same one-line comment fix there.
  • 000003_analytics_partitions.up.sql:177 says REFRESH MATERIALIZED VIEW CONCURRENTLY "cannot run inside a transaction block". PostgreSQL 16 allows it; it is only refused on a never-populated view. Comment only, left as is.
  • maybeAutoHealDirty's runtime error message always prints CUDLY_FORCE_MIGRATION_VERSION=N-1 as the force-down target, which is wrong when N-1 has no file. The comments now explain this. Changing the message is a behavior change for its own issue.
  • The guard's REINDEX and REFRESH ... CONCURRENTLY entries are broader than PostgreSQL's rule. They were kept and documented as policy. Narrowing them is a guard change and needs a maintainer decision.
  • Real support for non-transactional migrations needs the deploy-time runner design in Move DB migrations off the request/connect path into a one-shot deploy-time runner #31.

Maintainers: please mirror the issue's triage labels onto this PR: impact/internal, triaged, priority/p3, severity/low, urgency/eventually, effort/s, type/docs.

🤖 Generated with Claude Code

The guard test, migrate.go and migrations 000109/000111 said the
golang-migrate driver wraps each file in BEGIN/COMMIT. It does not: the
pgx/v5 driver passes the whole file to one argument-less ExecContext,
pgx sends that over the simple query protocol, and PostgreSQL runs a
multi-statement simple query as one implicit transaction. The comments
now separate driver execution, PostgreSQL file atomicity, the policy of
keeping non-transactional statements out of request-time migrations, and
the manual fail-loud dirty-state recovery.

Also corrects stale recovery comments: maybeAutoHealDirty no longer
clears the dirty flag, the AUTOHEAL=false error text is golang-migrate's
"Dirty database version N. Fix and force version.", the force-down target
is the previous existing migration version (numbering has gaps), the
deploy-time runner is LeanerCloud#31 in this repository (the old LeanerCloud#1125 reference
pointed at an unrelated PR), and REINDEX / REFRESH MATERIALIZED VIEW
CONCURRENTLY are policy entries rather than statements PostgreSQL refuses
in a transaction. No runtime or guard behavior changes; only comments,
plus the guard test's log and failure message text.

Closes LeanerCloud#874

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added impact/internal Team-internal only triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline effort/s Hours type/docs Documentation labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Labels

effort/s Hours impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/docs Documentation urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs(db): clarify migration transaction and recovery comments

1 participant