Use golang-migrate-compatible watermark tracking in migrations

This commit is contained in:
Emil
2026-08-02 18:37:15 +03:00
parent b281d5811a
commit 9fe81bc531
3 changed files with 36 additions and 37 deletions
@@ -68,12 +68,12 @@ func TestRunProvisionsDatabaseSchemaAndEnvFile(t *testing.T) {
t.Fatalf("connect to provisioned database: %v", err)
}
defer func() { _ = conn.Close(ctx) }()
var count int
if err := conn.QueryRow(ctx, "SELECT count(*) FROM schema_migrations").Scan(&count); err != nil {
var watermark int64
if err := conn.QueryRow(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&watermark); err != nil {
t.Fatalf("read schema_migrations: %v", err)
}
if count < 13 {
t.Errorf("schema_migrations has %d rows, want >= 13", count)
if watermark < 13 {
t.Errorf("schema watermark = %d, want >= 13", watermark)
}
envContent, err := os.ReadFile(envPath)
if err != nil {
@@ -672,16 +672,16 @@ func TestMigrateProvisionsAndIsIdempotent(t *testing.T) {
t.Fatalf("second migrate (idempotent): %v", err)
}
pool := testPool(t)
var count int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM schema_migrations").Scan(&count); err != nil {
t.Fatalf("read schema_migrations: %v", err)
}
migrations, err := listMigrations()
if err != nil {
t.Fatal(err)
}
if count != len(migrations) {
t.Errorf("schema_migrations has %d rows, want %d", count, len(migrations))
var watermark int64
if err := pool.QueryRow(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_migrations").Scan(&watermark); err != nil {
t.Fatalf("read schema_migrations: %v", err)
}
if watermark != int64(len(migrations)) {
t.Errorf("schema watermark = %d, want %d", watermark, len(migrations))
}
var hasJobs bool
if err := pool.QueryRow(ctx,
@@ -72,12 +72,13 @@ func listMigrations() ([]migration, error) {
return migrations, nil
}
// Migrate applies every embedded migration that is not yet recorded in the
// schema_migrations table, so the binary provisions its own schema. It is
// idempotent and safe to run concurrently: a PostgreSQL advisory lock
// serializes migrators, and each migration file runs as its own transaction
// (the files carry explicit BEGIN/COMMIT, matching the golang-migrate format
// the CLI and CI still use).
// Migrate applies every embedded migration above the recorded schema version,
// so the binary provisions its own schema. It is idempotent and interoperates
// with the golang-migrate CLI: both tools use the same schema_migrations
// watermark table (single row: version + dirty flag), and a PostgreSQL
// advisory lock serializes concurrent migrators. Each migration file runs as
// its own transaction (the files carry explicit BEGIN/COMMIT, matching the
// golang-migrate format the CLI and CI still use).
func Migrate(ctx context.Context, databaseURL string, log *slog.Logger) error {
migrations, err := listMigrations()
if err != nil {
@@ -102,31 +103,20 @@ func Migrate(ctx context.Context, databaseURL string, log *slog.Logger) error {
defer func() { _, _ = conn.Exec(ctx, "SELECT pg_advisory_unlock(82473911)") }()
if _, err := conn.Exec(ctx,
"CREATE TABLE IF NOT EXISTS schema_migrations (version bigint PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())",
"CREATE TABLE IF NOT EXISTS schema_migrations (version bigint PRIMARY KEY, dirty boolean NOT NULL DEFAULT false)",
); err != nil {
return fmt.Errorf("ensure schema_migrations: %w", err)
}
applied := map[int64]bool{}
rows, err := conn.Query(ctx, "SELECT version FROM schema_migrations")
if err != nil {
return fmt.Errorf("read applied migrations: %w", err)
}
for rows.Next() {
var version int64
if err := rows.Scan(&version); err != nil {
rows.Close()
return fmt.Errorf("scan applied migration: %w", err)
}
applied[version] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("read applied migrations: %w", err)
var applied int64
if err := conn.QueryRow(ctx,
"SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
).Scan(&applied); err != nil {
return fmt.Errorf("read applied schema version: %w", err)
}
for _, item := range migrations {
if applied[int64(item.version)] {
if int64(item.version) <= applied {
continue
}
if log != nil {
@@ -135,11 +125,20 @@ func Migrate(ctx context.Context, databaseURL string, log *slog.Logger) error {
if _, err := conn.Exec(ctx, item.sql); err != nil {
return fmt.Errorf("apply migration %s: %w", item.name, err)
}
if _, err := conn.Exec(ctx,
"INSERT INTO schema_migrations (version) VALUES ($1)", item.version,
); err != nil {
// Advance the watermark to the single-row golang-migrate layout.
tag, err := conn.Exec(ctx,
"UPDATE schema_migrations SET version = $1, dirty = false", item.version)
if err != nil {
return fmt.Errorf("record migration %s: %w", item.name, err)
}
if tag.RowsAffected() == 0 {
if _, err := conn.Exec(ctx,
"INSERT INTO schema_migrations (version, dirty) VALUES ($1, false)",
item.version,
); err != nil {
return fmt.Errorf("record migration %s: %w", item.name, err)
}
}
}
return nil
}