Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <[email protected]>
228 lines
7.4 KiB
Go
228 lines
7.4 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io/fs"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"evobgp/migrations"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func TestPostgresMigrationsIntegration(t *testing.T) {
|
|
pool := isolatedMigrationPool(t)
|
|
ctx := context.Background()
|
|
if err := ApplyPostgresMigrations(ctx, pool, migrations.Postgres, "postgres"); err != nil {
|
|
t.Fatalf("first migration pass: %v", err)
|
|
}
|
|
if err := ApplyPostgresMigrations(ctx, pool, migrations.Postgres, "postgres"); err != nil {
|
|
t.Fatalf("second migration pass: %v", err)
|
|
}
|
|
files, err := fs.ReadDir(migrations.Postgres, "postgres")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := 0
|
|
for _, file := range files {
|
|
if strings.HasSuffix(file.Name(), ".up.sql") {
|
|
want++
|
|
}
|
|
}
|
|
var got int
|
|
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM schema_migrations`).Scan(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("applied migrations = %d, want %d", got, want)
|
|
}
|
|
var extension string
|
|
if err := pool.QueryRow(ctx, `SELECT extname FROM pg_extension WHERE extname = 'pg_stat_statements'`).Scan(&extension); err != nil {
|
|
t.Fatalf("pg_stat_statements extension missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPostgresMigrationConcurrentApplyIntegration(t *testing.T) {
|
|
pool := isolatedMigrationPool(t)
|
|
files := fstest.MapFS{"sql/000001_probe.up.sql": {Data: []byte(`CREATE TABLE migration_probe (id integer PRIMARY KEY); SELECT pg_sleep(0.1);`)}}
|
|
var wg sync.WaitGroup
|
|
errors := make(chan error, 2)
|
|
for i := 0; i < 2; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
errors <- ApplyPostgresMigrations(context.Background(), pool, files, "sql")
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errors)
|
|
for err := range errors {
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
assertMigrationVersionCount(t, pool, 1)
|
|
}
|
|
|
|
func TestPostgresMigrationRollbackIntegration(t *testing.T) {
|
|
pool := isolatedMigrationPool(t)
|
|
files := fstest.MapFS{"sql/000001_broken.up.sql": {Data: []byte(`CREATE TABLE migration_partial (id integer); SELECT 1 / 0;`)}}
|
|
if err := ApplyPostgresMigrations(context.Background(), pool, files, "sql"); err == nil {
|
|
t.Fatal("expected migration error")
|
|
}
|
|
assertMigrationVersionCount(t, pool, 0)
|
|
var exists bool
|
|
if err := pool.QueryRow(context.Background(), `SELECT to_regclass('migration_partial') IS NOT NULL`).Scan(&exists); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists {
|
|
t.Fatal("failed migration left a table behind")
|
|
}
|
|
// A transaction terminator hidden behind a comment must be rejected before
|
|
// the first DDL statement can run.
|
|
files = fstest.MapFS{"sql/000001_broken.up.sql": {Data: []byte(`CREATE TABLE migration_partial (id integer); /* boundary */ COMMIT; SELECT 1 / 0;`)}}
|
|
if err := ApplyPostgresMigrations(context.Background(), pool, files, "sql"); err == nil || !strings.Contains(err.Error(), "unsupported non-transactional SQL") {
|
|
t.Fatalf("comment-hidden COMMIT error = %v", err)
|
|
}
|
|
assertMigrationVersionCount(t, pool, 0)
|
|
if err := pool.QueryRow(context.Background(), `SELECT to_regclass('migration_partial') IS NOT NULL`).Scan(&exists); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists {
|
|
t.Fatal("rejected migration left a table behind")
|
|
}
|
|
files = fstest.MapFS{"sql/000001_broken.up.sql": {Data: []byte(`CREATE TABLE foo$tag$ (id int); COMMIT; -- $tag$`)}}
|
|
if err := ApplyPostgresMigrations(context.Background(), pool, files, "sql"); err == nil || !strings.Contains(err.Error(), "unsupported non-transactional SQL") {
|
|
t.Fatalf("identifier-embedded dollar tag error = %v", err)
|
|
}
|
|
assertMigrationVersionCount(t, pool, 0)
|
|
if err := pool.QueryRow(context.Background(), `SELECT to_regclass('foo$tag$') IS NOT NULL`).Scan(&exists); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists {
|
|
t.Fatal("identifier-embedded dollar tag left a table behind")
|
|
}
|
|
}
|
|
|
|
func TestPostgresMigrationIdempotentIntegration(t *testing.T) {
|
|
pool := isolatedMigrationPool(t)
|
|
files := fstest.MapFS{"sql/000001_once.up.sql": {Data: []byte(`CREATE TABLE migration_once (id integer); INSERT INTO migration_once VALUES (1);`)}}
|
|
for i := 0; i < 2; i++ {
|
|
if err := ApplyPostgresMigrations(context.Background(), pool, files, "sql"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
assertMigrationVersionCount(t, pool, 1)
|
|
var rows int
|
|
if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM migration_once`).Scan(&rows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rows != 1 {
|
|
t.Fatalf("migration rows = %d, want 1", rows)
|
|
}
|
|
}
|
|
|
|
func TestPostgresMigrationLockCancellationIntegration(t *testing.T) {
|
|
pool := isolatedMigrationPool(t)
|
|
locker, err := pool.Acquire(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer locker.Release()
|
|
if _, err := locker.Exec(context.Background(), `SELECT pg_advisory_lock($1)`, postgresMigrationLockKey); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
_, _ = locker.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, postgresMigrationLockKey)
|
|
}()
|
|
files := fstest.MapFS{"sql/000001_probe.up.sql": {Data: []byte(`CREATE TABLE lock_probe (id integer);`)}}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
|
|
defer cancel()
|
|
err = ApplyPostgresMigrations(ctx, pool, files, "sql")
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("lock wait error = %v, want context deadline exceeded", err)
|
|
}
|
|
var exists bool
|
|
if err := pool.QueryRow(context.Background(), `SELECT to_regclass('schema_migrations') IS NOT NULL`).Scan(&exists); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists {
|
|
t.Fatal("schema_migrations was created before migration lock acquisition")
|
|
}
|
|
var unlocked bool
|
|
if err := locker.QueryRow(context.Background(), `SELECT pg_advisory_unlock($1)`, postgresMigrationLockKey).Scan(&unlocked); err != nil || !unlocked {
|
|
t.Fatalf("release held lock: unlocked=%v err=%v", unlocked, err)
|
|
}
|
|
if err := ApplyPostgresMigrations(context.Background(), pool, files, "sql"); err != nil {
|
|
t.Fatalf("apply after lock release: %v", err)
|
|
}
|
|
}
|
|
|
|
func isolatedMigrationPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
admin, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var nonce [8]byte
|
|
if _, err := rand.Read(nonce[:]); err != nil {
|
|
admin.Close()
|
|
t.Fatal(err)
|
|
}
|
|
schema := "evobgp_mig_test_" + hex.EncodeToString(nonce[:])
|
|
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schema); err != nil {
|
|
admin.Close()
|
|
t.Fatal(err)
|
|
}
|
|
var pool *pgxpool.Pool
|
|
t.Cleanup(func() {
|
|
if pool != nil {
|
|
pool.Close()
|
|
}
|
|
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cleanupCancel()
|
|
if _, err := admin.Exec(cleanupCtx, `DROP SCHEMA `+schema+` CASCADE`); err != nil {
|
|
t.Errorf("drop isolated schema %s: %v", schema, err)
|
|
}
|
|
admin.Close()
|
|
})
|
|
cfg, err := pgxpool.ParseConfig(dsn)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.ConnConfig.RuntimeParams == nil {
|
|
cfg.ConnConfig.RuntimeParams = make(map[string]string)
|
|
}
|
|
cfg.ConnConfig.RuntimeParams["search_path"] = schema
|
|
cfg.MaxConns = 3
|
|
pool, err = pgxpool.NewWithConfig(ctx, cfg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
func assertMigrationVersionCount(t *testing.T, pool *pgxpool.Pool, want int) {
|
|
t.Helper()
|
|
var got int
|
|
if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM schema_migrations`).Scan(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("migration versions = %d, want %d", got, want)
|
|
}
|
|
}
|