Repository navigation
docs(db): clarify migration transaction and recovery comments - #1281
Conversation
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>
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>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rB5. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 1 approving independent review(s) on |
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:
newCancelableMigrator(internal/database/postgres/migrations/migrate_cancel.go:118-148) builds the golang-migrate v4.19.1 pgx/v5 driver withConfig{DatabaseName: ...}only, soMultiStatementEnabledis false. No DSN in the repo setsx-multi-statement, andbuildMigrateDSN(migrate.go:582) only addssslmode. With that setting,Postgres.Run(database/pgx/v5/pgx.go:252-270) callsio.ReadAllon the file and passes it torunStatement(pgx.go:272-302), which makes a singlep.conn.ExecContext(ctx, query)call with no arguments. The driver adds no BEGIN/COMMIT.stdlibExecContextcallsConn.Exec, andconn.go:515-517forcesQueryExecModeSimpleProtocolwhen there are no arguments. The whole file goes to PostgreSQL as one simple Query message.runMigrations(migrate.go:723-772) callsSetVersion(N, true)in its own committed transaction (pgx.go:339-371) beforeRun, and callsSetVersion(N, false)only afterRunsucceeds. A failed or interrupted file therefore rolls back completely but leavesdirty=trueat N.Up()refuses a dirty DB withErrDirty, whose text is "Dirty database version N. Fix and force version." (migrate.go:52-54,:276). OurmaybeAutoHealDirty(internal/database/postgres/migrations/migrate.go:407) fails loud and never clears the flag.Some recovery comments had also gone stale or were wrong:
newMigratorWithRecovery's inline comment still described the old Force-and-continue auto-heal.readUp(migrate.go:532-541) fails with "no migration found for version" when the forced version has no file.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 (RunMigrationsfromensureDB), and manual fail-loud dirty-state recovery. It also states three facts that were missing before:-- migrate:no-transactionmarker is read only by this test, not by the runner;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.Errorfmessage text changed to match. The guard's logic, patterns and opt-out are unchanged.migrate.go: corrected comments onRunMigrations,newMigratorWithRecovery,maybeForceMigrationVersionandmaybeAutoHealDirty, 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):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.sqlfile (deleted afterwards):CREATE INDEX CONCURRENTLY x ON y (z);still failsTestMigrationsAreTransactional(with the new message).-- migrate:no-transactionmarker 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.RunMigrationswith temp migration dirs (not committed):CREATE TABLE t874_a ...; SELECT 1/0;returned an error, leftversion=1 dirty=true, and the table was absent. A failing later statement rolls back the earlier one.BEGIN; ... COMMIT;also left the table absent anddirty=true.CREATE TABLE ...; CREATE INDEX CONCURRENTLY ...failed with "CREATE INDEX CONCURRENTLY cannot run inside a transaction block", left the table absent anddirty=true.CREATE INDEX CONCURRENTLY ...succeeded (dirty=false, index exists). The guard rejects this case as policy, not because PostgreSQL refuses it.CUDLY_MIGRATION_AUTOHEAL=falsethe dirty DB gavefailed to run migrations: Dirty database version 1. Fix and force version.With the default, it gave theauto-heal: migration 1 is dirty; ...instructions.CUDLY_FORCE_MIGRATION_VERSION=2gaveno migration found for version 2.=1re-applied 3 and ended clean.I also ran psql against my own postgres:16 container:
REFRESH MATERIALIZED VIEW CONCURRENTLYand plainREINDEX TABLEsucceed insideBEGIN ... 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/maina14dc8f2plus these heads, merged in this order:fix/issue-72andfix/issue-312conflict on one line ininternal/api/handler_dashboard.go(both change thecalculateCommitmentMetricscall). The tree used #72's side with #312's validatedprovidervariable; 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:936996ad:go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without--build-tags integration.tests/e2eis a separate module, so it was vetted under its owne2etag.gocyclo -over 10is silent on the changed non-test files.-tags integration -count=1, 60-minute local timeout because the VM was shared):936996ad, these pass:internal/server,internal/credentials,internal/scheduler,internal/database/postgres/migrations,internal/purchaseandinternal/api.internal/configpasses exceptTestPostgresStoreDB_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).26795376, same branches at earlier heads):internal/analytics,internal/auth,internal/commitmentopts,internal/databaseandcmd/lambda/clear-rate-limitall pass. The later fixes don't touch those packages.httpToLambdaRequestsignature change;scripts/setup-git-secrets.shpatterns 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.Out of scope (noted, not changed)
000074_repair_partial_migration_058_067.up.sql:18-19repeats "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:177says 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 printsCUDLY_FORCE_MIGRATION_VERSION=N-1as 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.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