Skip to content

Commit 9a4d01c

Browse files
authored
fix(migrations): opt-in dirty auto-heal, raise timeout default, failure alarm (#1124)
* fix(migrations): opt-in dirty auto-heal, raise timeout default, failure alarm Harden the lazy-on-connect migration path that caused a prod outage: a slow migration (index build on a growing table) blew the 20s timeout mid-run, golang-migrate left schema_migrations dirty, and since dirty is terminal every later cold start fail-opened and served 500s on any query needing the unapplied columns (070-073 never applied). Three changes: 1. Default-on dirty auto-heal (internal/database/postgres/migrations/migrate.go). New maybeAutoHealDirty runs after maybeForceMigrationVersion and before Up(): when the DB is dirty it Force()s the CURRENT recorded version to clear the flag, then Up() re-applies the pending tail, so a cold start self-recovers instead of staying broken until a manual force (the multi-hour outage shape). Enabled by default; CUDLY_MIGRATION_AUTOHEAL=false is the escape hatch. Forces the current version, NEVER lower: forcing below already-applied seed migrations re-runs guards that raise on a second run (e.g. 000059 "Purchaser already exists"). The app ALWAYS starts: if auto-heal still can't reach head, the error propagates to ensureDB which fail-opens (existing behavior) and the alarm fires -- never a crash-loop. CUDLY_FORCE_MIGRATION_VERSION still takes precedence. Relies on migrations being idempotent (documented + tested). 2. Raise defaultMigrationsTimeout 20s -> 120s (internal/server/app.go), still well under the 300s Lambda limit, so a normal index build can't be killed mid-run. CUDLY_MIGRATION_TIMEOUT override unchanged. 3. CloudWatch migration-failure alarm (terraform/modules/compute/aws/lambda). Log metric filter on the "Migration failed" line + alarm, since the app fail-opens and AWS/Lambda Errors stays clean during a broken migration. Notification target optional via alarm_sns_topic_arn (default []); reuses the existing alarm/metric-filter patterns, no new SNS infra. Fix latent migration bug surfaced by the new idempotency test: migration 000067 widened savings_snapshots.account_id BEFORE dropping the materialized views that depend on it, so the full stack failed to reach head on a fresh DB ("cannot alter type of a column used by a view"). Moved the view DROPs ahead of the ALTER COLUMN. This moved the migrations integration suite from 4/37 to 28/42 passing; the remaining failures are pre-existing and unrelated (users role column, savings-plans split assertions). Tests: - TestMigrations_FullStackIdempotent (integration): migrate to head twice, second run is a clean no-op and not dirty. - TestMigrations_AutoHealDirty (integration): dirty DB self-heals by default and with =true; CUDLY_MIGRATION_AUTOHEAL=false disables it so a dirty DB errors with the dirty flag intact. - TestResolveMigrationsTimeout (unit): default is 120s, honors the override, falls back on invalid/non-positive input. Spec specs/migration-resilience.md updated for the new default, the auto-heal flag, precedence, and the alarm. * fix(migrations): extract migrator recovery hooks to fix gocyclo RunMigrations hit cyclomatic complexity 11 (gocyclo budget is 10) after the default-on dirty auto-heal added a second pre-Up error branch, failing the pre-commit CI hook. Extract migrator creation plus the operator-force and auto-heal hooks into newMigratorWithRecovery, dropping RunMigrations to 9 while preserving hook ordering and error propagation. The helper closes the migrator on its own error paths so callers never receive a half-initialized migrator. * test(migrations): enforce all migrations are transactional + address CR Add a static guard test that reads every *.up.sql / *.down.sql under the migrations dir and fails if any file contains a statement PostgreSQL refuses to run inside a transaction block (CREATE/DROP INDEX CONCURRENTLY, REINDEX, VACUUM, ALTER SYSTEM, CREATE/DROP DATABASE, CREATE TABLESPACE, REFRESH MATERIALIZED VIEW CONCURRENTLY). golang-migrate runs each migration file in one implicit transaction, so forbidding non-transactional statements guarantees a failed migration rolls back atomically with no partial schema. This is orthogonal to the dirty-flag auto-heal in this PR; it closes the partial-apply hole. The scanner strips SQL comments and dollar-quoted function/DO bodies before matching, so CONCURRENTLY inside a stored function (e.g. migration 000003's refresh_savings_materialized_views) is correctly treated as benign. Files may opt out via the marker comment "-- migrate:no-transaction"; such files are skipped with a loud warning and MUST be applied via the deploy-time runner (issue #1125), never the in-request auto-migrate path. Also address the CodeRabbit nitpick on specs/migration-resilience.md: clarify that alarm_sns_topic_arn is a list of SNS topic ARNs.
1 parent e97187a commit 9a4d01c

11 files changed

Lines changed: 671 additions & 47 deletions

File tree

‎internal/database/postgres/migrations/000067_analytics_snapshot_correctness.up.sql‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@
3535
-- target state rather than no-op'ing over a wrong column type (project rule
3636
-- feedback_migration_full_restore).
3737

38+
-- ------------------------------------------------------------------
39+
-- H3 (prerequisite): drop the materialized views FIRST.
40+
-- monthly_savings_summary / daily_savings_trend / provider_savings_summary
41+
-- all reference savings_snapshots.account_id, so the H4 ALTER COLUMN TYPE
42+
-- below fails with "cannot alter type of a column used by a view or rule"
43+
-- while they still exist. They are unconditionally recreated further down,
44+
-- so dropping them up front is safe and makes this migration applyable on a
45+
-- fresh DB (where an earlier migration already created them). DROP ... IF
46+
-- EXISTS keeps the step idempotent across re-runs / auto-heal.
47+
-- ------------------------------------------------------------------
48+
DROP MATERIALIZED VIEW IF EXISTS provider_savings_summary CASCADE;
49+
DROP MATERIALIZED VIEW IF EXISTS daily_savings_trend CASCADE;
50+
DROP MATERIALIZED VIEW IF EXISTS monthly_savings_summary CASCADE;
51+
3852
-- ------------------------------------------------------------------
3953
-- H4: widen account_id on the partitioned parent.
4054
-- Postgres propagates the type change to all existing partitions.
@@ -71,12 +85,9 @@ ALTER TABLE savings_snapshots ALTER COLUMN coverage_percentage DROP DEFAULT;
7185
-- DROP + CREATE because a materialized view's column list / GROUP BY
7286
-- cannot be altered in place. Unique indexes are recreated for the
7387
-- CONCURRENTLY refresh path. AVG(coverage_percentage) now skips NULLs
74-
-- automatically (H2).
88+
-- automatically (H2). The DROPs ran above (before the ALTER COLUMN) so
89+
-- the CREATEs below land on a clean slate.
7590
-- ------------------------------------------------------------------
76-
DROP MATERIALIZED VIEW IF EXISTS provider_savings_summary CASCADE;
77-
DROP MATERIALIZED VIEW IF EXISTS daily_savings_trend CASCADE;
78-
DROP MATERIALIZED VIEW IF EXISTS monthly_savings_summary CASCADE;
79-
8091
CREATE MATERIALIZED VIEW monthly_savings_summary AS
8192
SELECT
8293
DATE_TRUNC('month', timestamp) as month,

‎internal/database/postgres/migrations/migrate.go‎

Lines changed: 138 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,27 +31,14 @@ const defaultAdminGroupID = "00000000-0000-5000-8000-000000000001"
3131
// adminEmail is optional - if provided, admin user will be created after migrations complete
3232
// adminPassword is optional - if provided, admin is created with hashed password and active=true
3333
func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath string, adminEmail string, adminPassword string) error {
34-
// Get database connection string from pool config (without admin email parameter - RDS Proxy doesn't support options)
35-
dsn := buildMigrateDSN(pool.Config(), "")
36-
37-
// Create migrator
38-
m, err := migrate.New(
39-
fmt.Sprintf("file://%s", migrationsPath),
40-
dsn,
41-
)
34+
// Create the migrator and run the pre-Up recovery hooks (operator force,
35+
// then default-on dirty auto-heal). Kept in a helper so RunMigrations stays
36+
// under the cyclomatic-complexity budget as recovery paths grow.
37+
m, err := newMigratorWithRecovery(pool, migrationsPath)
4238
if err != nil {
43-
return fmt.Errorf("failed to create migrator: %w", err)
44-
}
45-
defer m.Close()
46-
47-
// One-shot operator recovery: if CUDLY_FORCE_MIGRATION_VERSION is set,
48-
// call Force(N) before Up(). Clears the dirty flag and pins state to
49-
// the given version. Used to recover from a partially-applied
50-
// migration without direct DB access. Remove the env var after the
51-
// next successful deploy.
52-
if err := maybeForceMigrationVersion(m); err != nil {
5339
return err
5440
}
41+
defer m.Close()
5542

5643
// Run migrations
5744
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
@@ -85,6 +72,52 @@ func RunMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath strin
8572
return nil
8673
}
8774

75+
// newMigratorWithRecovery builds the golang-migrate migrator for migrationsPath
76+
// and runs the pre-Up recovery hooks in order: the one-shot operator force
77+
// (CUDLY_FORCE_MIGRATION_VERSION) first, then the default-on dirty auto-heal.
78+
// On success it returns a migrator the caller must Close(); on any error it
79+
// closes the migrator itself and returns the error so the caller never sees a
80+
// half-initialized migrator.
81+
//
82+
// Ordering note: maybeAutoHealDirty runs AFTER maybeForceMigrationVersion so an
83+
// explicit force always takes precedence (it pins+cleans first, leaving nothing
84+
// dirty for auto-heal to act on). The auto-heal error propagates so a heal
85+
// failure is recorded (and the app fail-opens in ensureDB) rather than being
86+
// masked by the later dirty check.
87+
func newMigratorWithRecovery(pool *pgxpool.Pool, migrationsPath string) (*migrate.Migrate, error) {
88+
// Get database connection string from pool config (without admin email parameter - RDS Proxy doesn't support options)
89+
dsn := buildMigrateDSN(pool.Config(), "")
90+
91+
m, err := migrate.New(
92+
fmt.Sprintf("file://%s", migrationsPath),
93+
dsn,
94+
)
95+
if err != nil {
96+
return nil, fmt.Errorf("failed to create migrator: %w", err)
97+
}
98+
99+
// One-shot operator recovery: if CUDLY_FORCE_MIGRATION_VERSION is set,
100+
// call Force(N) before Up(). Clears the dirty flag and pins state to
101+
// the given version. Used to recover from a partially-applied
102+
// migration without direct DB access. Remove the env var after the
103+
// next successful deploy.
104+
if err := maybeForceMigrationVersion(m); err != nil {
105+
m.Close()
106+
return nil, err
107+
}
108+
109+
// Default-on dirty auto-heal: when the schema_migrations row is dirty,
110+
// clear the dirty flag at the CURRENT recorded version so the subsequent
111+
// Up() re-applies any pending migrations, letting a cold start self-recover
112+
// instead of staying broken until a manual force.
113+
if err := maybeAutoHealDirty(m); err != nil {
114+
m.Close()
115+
return nil, err
116+
}
117+
118+
return m, nil
119+
}
120+
88121
// ensureAdminUser creates the admin user if it doesn't exist.
89122
// When password is provided, the admin is created with a bcrypt-hashed password and active=true.
90123
// When password is empty, the admin is created inactive and must use password reset to log in.
@@ -292,6 +325,93 @@ func maybeForceMigrationVersion(m *migrate.Migrate) error {
292325
return nil
293326
}
294327

328+
// maybeAutoHealDirty self-recovers a dirty schema_migrations row: when the DB
329+
// is dirty it calls m.Force(currentRecordedVersion) to clear the dirty flag at
330+
// the version golang-migrate last recorded, and the caller's subsequent m.Up()
331+
// re-applies only the still-pending migrations. This runs ONCE per boot, so a
332+
// cold start that finds a dirty DB self-heals instead of staying broken until
333+
// an operator manually forces a version (the multi-hour outage this addresses).
334+
//
335+
// DEFAULT-ON (not opt-in). The whole outage was "the app started but stayed
336+
// broken for hours needing a manual force", and TestMigrations_FullStackIdempotent
337+
// proves re-running migrations is safe, so auto-heal runs by default. Set the
338+
// escape hatch CUDLY_MIGRATION_AUTOHEAL=false (any strconv.ParseBool falsey
339+
// value) to disable it in an environment whose migrations are not idempotent.
340+
// When disabled, a dirty DB is left untouched here and surfaces as the usual
341+
// "database is in dirty state" error after Up(); the caller (app.go ensureDB)
342+
// still FAIL-OPENS on that error so the app always starts -- a broken schema
343+
// surfaces via /health + the CloudWatch alarm, never via a crash-loop.
344+
//
345+
// CRITICAL -- Force to the CURRENT recorded version, NEVER a lower one. Up()
346+
// then applies only the pending tail. Forcing BELOW already-applied migrations
347+
// would re-run seed/data migrations, and some RAISE on a second run (e.g.
348+
// 000059 errors with "a group named Purchaser already exists with a different
349+
// id" because 000064 already relocated it). Force(current)+Up() is the only
350+
// safe shape; Force(lower) would turn a recoverable dirty state into a hard
351+
// failure. (Proven live during the incident.)
352+
//
353+
// IDEMPOTENCY INVARIANT: Force(version) clears the dirty marker WITHOUT rolling
354+
// back the partial effects of the interrupted migration, so when Up() re-runs
355+
// the pending tail those migrations must tolerate already-applied state
356+
// (CREATE ... IF NOT EXISTS, DROP ... IF EXISTS, DO-blocks that no-op when the
357+
// target already exists). The full-stack idempotency test guards this for the
358+
// whole directory as migrations are added.
359+
//
360+
// CUDLY_FORCE_MIGRATION_VERSION still takes precedence: it runs earlier and
361+
// leaves the row clean, so this is a no-op when both are set. If auto-heal
362+
// itself fails (e.g. Force errors, or the re-applied Up() still fails), the
363+
// error propagates to ensureDB, which fail-opens AND records the failure so
364+
// the migration-failed alarm fires -- the app still starts.
365+
//
366+
// The ParseBool gate is duplicated from internal/server.getEnvBool rather than
367+
// imported because the migrations package must not depend on internal/server,
368+
// which would invert the dependency direction.
369+
func maybeAutoHealDirty(m *migrate.Migrate) error {
370+
if !autoHealEnabled() {
371+
return nil
372+
}
373+
374+
version, dirty, err := m.Version()
375+
if err == migrate.ErrNilVersion {
376+
// No migrations recorded yet -> nothing to heal.
377+
return nil
378+
}
379+
if err != nil {
380+
return fmt.Errorf("auto-heal: failed to read migration version: %w", err)
381+
}
382+
if !dirty {
383+
return nil
384+
}
385+
386+
// Force the CURRENT recorded version (never lower -- see the doc comment),
387+
// then let the caller's Up() re-apply only the pending tail.
388+
log.Printf("Database is DIRTY at version %d: auto-heal forcing the current version %d to clear the dirty flag, then re-applying pending migrations (set CUDLY_MIGRATION_AUTOHEAL=false to disable)", version, version)
389+
if err := m.Force(int(version)); err != nil {
390+
return fmt.Errorf("auto-heal: failed to force version %d to clear dirty flag: %w", version, err)
391+
}
392+
log.Printf("Auto-heal cleared dirty flag at version %d; proceeding to re-apply pending migrations", version)
393+
return nil
394+
}
395+
396+
// autoHealEnabled reports whether dirty auto-heal should run. DEFAULT-ON: it
397+
// returns true unless CUDLY_MIGRATION_AUTOHEAL is explicitly set to a
398+
// strconv.ParseBool falsey value (0/f/false/...). Unset, empty, or unparseable
399+
// values keep auto-heal enabled. Kept local to the migrations package to avoid
400+
// an inverted dependency on internal/server (see maybeAutoHealDirty).
401+
func autoHealEnabled() bool {
402+
v := os.Getenv("CUDLY_MIGRATION_AUTOHEAL")
403+
if v == "" {
404+
return true
405+
}
406+
b, err := strconv.ParseBool(v)
407+
if err != nil {
408+
// Unparseable value -> keep the safe default (enabled) rather than
409+
// silently disabling self-recovery on a typo.
410+
return true
411+
}
412+
return b
413+
}
414+
295415
// RollbackMigrations rolls back N migrations
296416
func RollbackMigrations(ctx context.Context, pool *pgxpool.Pool, migrationsPath string, steps int) error {
297417
if steps <= 0 {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
//go:build integration
2+
// +build integration
3+
4+
package migrations_test
5+
6+
import (
7+
"context"
8+
"testing"
9+
10+
"github.com/LeanerCloud/CUDly/internal/database/postgres/migrations"
11+
"github.com/LeanerCloud/CUDly/internal/database/postgres/testhelpers"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// markDirty forces schema_migrations into the dirty state at the given version,
17+
// reproducing what golang-migrate leaves behind when a migration is interrupted
18+
// mid-run (Lambda timeout, ENI drop, etc.). golang-migrate keeps exactly one
19+
// row in this table.
20+
func markDirty(ctx context.Context, t *testing.T, container *testhelpers.PostgresContainer, version uint) {
21+
t.Helper()
22+
_, err := container.DB.Exec(ctx,
23+
`UPDATE schema_migrations SET version = $1, dirty = true`, version)
24+
require.NoError(t, err, "failed to mark schema_migrations dirty")
25+
}
26+
27+
// TestMigrations_AutoHealDirty covers the DEFAULT-ON CUDLY_MIGRATION_AUTOHEAL
28+
// behavior from migrate.go:
29+
//
30+
// - BY DEFAULT (flag unset), a dirty DB is healed: Force(current) clears the
31+
// dirty flag and the subsequent Up() reaches head clean -- a cold start
32+
// self-recovers without operator intervention.
33+
// - With CUDLY_MIGRATION_AUTOHEAL=false, auto-heal is disabled and a dirty DB
34+
// still returns an error with the dirty flag left intact (escape hatch).
35+
//
36+
// In both cases the Force target is the CURRENT recorded version (never lower),
37+
// which is the only safe shape -- forcing below already-applied migrations
38+
// would re-run guarded seed migrations that raise on a second run.
39+
func TestMigrations_AutoHealDirty(t *testing.T) {
40+
ctx := context.Background()
41+
migrationsPath := getMigrationsPath()
42+
43+
t.Run("by default a dirty DB self-heals and head is reached", func(t *testing.T) {
44+
container, err := testhelpers.SetupPostgresContainer(ctx, t)
45+
require.NoError(t, err)
46+
defer container.Cleanup(ctx)
47+
pool := container.DB.Pool()
48+
49+
// Migrate to head, then simulate an interrupted migration by forcing
50+
// the dirty flag at the head version.
51+
require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", ""))
52+
headVersion, _, err := migrations.GetMigrationVersion(ctx, pool, migrationsPath)
53+
require.NoError(t, err)
54+
require.Greater(t, headVersion, uint(0))
55+
markDirty(ctx, t, container, headVersion)
56+
57+
// Sanity: confirm the DB is genuinely dirty before healing.
58+
_, dirtyBefore, err := migrations.GetMigrationVersion(ctx, pool, migrationsPath)
59+
require.NoError(t, err)
60+
require.True(t, dirtyBefore, "precondition: DB must be dirty before the heal run")
61+
62+
// Flag explicitly unset: auto-heal is default-on, so the re-run must
63+
// self-recover. t.Setenv auto-restores and forbids t.Parallel().
64+
t.Setenv("CUDLY_MIGRATION_AUTOHEAL", "")
65+
require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", ""),
66+
"default-on auto-heal should clear the dirty flag and reach head without error")
67+
68+
versionAfter, dirtyAfter, err := migrations.GetMigrationVersion(ctx, pool, migrationsPath)
69+
require.NoError(t, err)
70+
assert.False(t, dirtyAfter, "auto-heal must clear the dirty flag")
71+
assert.Equal(t, headVersion, versionAfter, "auto-heal must leave the DB at head")
72+
})
73+
74+
t.Run("with CUDLY_MIGRATION_AUTOHEAL=true a dirty DB self-heals", func(t *testing.T) {
75+
container, err := testhelpers.SetupPostgresContainer(ctx, t)
76+
require.NoError(t, err)
77+
defer container.Cleanup(ctx)
78+
pool := container.DB.Pool()
79+
80+
require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", ""))
81+
headVersion, _, err := migrations.GetMigrationVersion(ctx, pool, migrationsPath)
82+
require.NoError(t, err)
83+
markDirty(ctx, t, container, headVersion)
84+
85+
t.Setenv("CUDLY_MIGRATION_AUTOHEAL", "true")
86+
require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", ""),
87+
"explicit true should also clear the dirty flag and reach head")
88+
89+
versionAfter, dirtyAfter, err := migrations.GetMigrationVersion(ctx, pool, migrationsPath)
90+
require.NoError(t, err)
91+
assert.False(t, dirtyAfter, "auto-heal must clear the dirty flag")
92+
assert.Equal(t, headVersion, versionAfter, "auto-heal must leave the DB at head")
93+
})
94+
95+
t.Run("CUDLY_MIGRATION_AUTOHEAL=false disables auto-heal and a dirty DB errors", func(t *testing.T) {
96+
container, err := testhelpers.SetupPostgresContainer(ctx, t)
97+
require.NoError(t, err)
98+
defer container.Cleanup(ctx)
99+
pool := container.DB.Pool()
100+
101+
require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", ""))
102+
headVersion, _, err := migrations.GetMigrationVersion(ctx, pool, migrationsPath)
103+
require.NoError(t, err)
104+
markDirty(ctx, t, container, headVersion)
105+
106+
// Escape hatch: the falsey value disables auto-heal, so the dirty error
107+
// must surface (the caller fail-opens on it; the app still starts).
108+
t.Setenv("CUDLY_MIGRATION_AUTOHEAL", "false")
109+
err = migrations.RunMigrations(ctx, pool, migrationsPath, "", "")
110+
require.Error(t, err, "CUDLY_MIGRATION_AUTOHEAL=false must disable auto-heal so a dirty DB errors")
111+
112+
_, dirtyAfter, verr := migrations.GetMigrationVersion(ctx, pool, migrationsPath)
113+
require.NoError(t, verr)
114+
assert.True(t, dirtyAfter, "with auto-heal disabled, the dirty flag must be left intact")
115+
})
116+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//go:build integration
2+
// +build integration
3+
4+
package migrations_test
5+
6+
import (
7+
"context"
8+
"testing"
9+
10+
"github.com/LeanerCloud/CUDly/internal/database/postgres/migrations"
11+
"github.com/LeanerCloud/CUDly/internal/database/postgres/testhelpers"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// TestMigrations_FullStackIdempotent proves the entire migration stack is
17+
// idempotent: running it to head twice against a fresh DB leaves the second
18+
// run a no-op and the schema_migrations row clean (not dirty).
19+
//
20+
// This is the invariant the opt-in CUDLY_MIGRATION_AUTOHEAL path relies on
21+
// (maybeAutoHealDirty in migrate.go Force()s past a dirty version and lets
22+
// Up() re-apply the pending tail). If a migration were NOT idempotent, a
23+
// re-apply would error or corrupt data, so this test guards the whole
24+
// directory as new migrations are added.
25+
func TestMigrations_FullStackIdempotent(t *testing.T) {
26+
ctx := context.Background()
27+
migrationsPath := getMigrationsPath()
28+
29+
container, err := testhelpers.SetupPostgresContainer(ctx, t)
30+
require.NoError(t, err)
31+
defer container.Cleanup(ctx)
32+
pool := container.DB.Pool()
33+
34+
// First run: migrate to head on a fresh DB.
35+
require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", ""),
36+
"first migration run to head should succeed on a fresh DB")
37+
38+
versionAfterFirst, dirtyAfterFirst, err := migrations.GetMigrationVersion(ctx, pool, migrationsPath)
39+
require.NoError(t, err)
40+
require.False(t, dirtyAfterFirst, "DB must not be dirty after the first run")
41+
require.Greater(t, versionAfterFirst, uint(0), "head version should be > 0")
42+
43+
// Second run: must be a no-op (golang-migrate's m.Up() returns ErrNoChange,
44+
// which RunMigrations swallows) and must not flip the dirty flag.
45+
require.NoError(t, migrations.RunMigrations(ctx, pool, migrationsPath, "", ""),
46+
"second migration run must be a clean no-op (ErrNoChange), proving idempotency")
47+
48+
versionAfterSecond, dirtyAfterSecond, err := migrations.GetMigrationVersion(ctx, pool, migrationsPath)
49+
require.NoError(t, err)
50+
assert.False(t, dirtyAfterSecond, "DB must not be dirty after the second (no-op) run")
51+
assert.Equal(t, versionAfterFirst, versionAfterSecond,
52+
"version must be unchanged after a no-op second run")
53+
}

0 commit comments

Comments
 (0)