feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
924f111e48
commit
f8e0520817
197 files changed
+18655
-1416
No files matched your search
+267
-23
@@ -8,48 +8,292 @@ import (
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ApplyPostgresMigrations runs ordered *.up.sql from an embedded subtree (idempotent via schema_migrations).
|
||||
func ApplyPostgresMigrations(ctx context.Context, pool *pgxpool.Pool, fsys fs.FS, subdir string) error {
|
||||
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT NOT NULL PRIMARY KEY)`); err != nil {
|
||||
return fmt.Errorf("db: schema_migrations: %w", err)
|
||||
}
|
||||
const (
|
||||
postgresMigrationLockKey int64 = 0x45766f4247504d47 // "EvoBGPMG", scoped to the database.
|
||||
migrationLockTimeout = 30 * time.Second
|
||||
migrationCleanupTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// PostgreSQL migrations are transaction-only. Non-transactional statements
|
||||
// need an explicit, separately reviewed execution path.
|
||||
|
||||
// ApplyPostgresMigrations runs ordered *.up.sql from an embedded subtree.
|
||||
// Each migration and its version row commit atomically under a session advisory lock.
|
||||
func ApplyPostgresMigrations(ctx context.Context, pool *pgxpool.Pool, fsys fs.FS, subdir string) (retErr error) {
|
||||
entries, err := fs.ReadDir(fsys, subdir)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("db: read migrations: %w", err)
|
||||
}
|
||||
var ups []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
continue
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
ups = append(ups, e.Name())
|
||||
}
|
||||
ups = append(ups, e.Name())
|
||||
}
|
||||
sort.Strings(ups)
|
||||
// Include pool acquisition in the lock timeout. The lock precedes even
|
||||
// schema_migrations creation, because another process may start from an empty DB.
|
||||
lockCtx, cancelLock := context.WithTimeout(ctx, migrationLockTimeout)
|
||||
conn, err := pool.Acquire(lockCtx)
|
||||
if err != nil {
|
||||
cancelLock()
|
||||
return fmt.Errorf("db: acquire migration connection: %w", err)
|
||||
}
|
||||
defer conn.Release()
|
||||
if _, err := conn.Exec(lockCtx, `SELECT pg_advisory_lock($1)`, postgresMigrationLockKey); err != nil {
|
||||
cancelLock()
|
||||
// Cancellation may race with a successful server-side lock acquisition.
|
||||
closeCtx, closeCancel := context.WithTimeout(context.Background(), migrationCleanupTimeout)
|
||||
defer closeCancel()
|
||||
if closeErr := conn.Conn().Close(closeCtx); closeErr != nil {
|
||||
return errors.Join(fmt.Errorf("db: acquire migration lock: %w", err), fmt.Errorf("db: close migration connection: %w", closeErr))
|
||||
}
|
||||
return fmt.Errorf("db: acquire migration lock: %w", err)
|
||||
}
|
||||
cancelLock()
|
||||
defer func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), migrationCleanupTimeout)
|
||||
defer cancel()
|
||||
var unlocked bool
|
||||
if err := conn.QueryRow(cleanupCtx, `SELECT pg_advisory_unlock($1)`, postgresMigrationLockKey).Scan(&unlocked); err != nil || !unlocked {
|
||||
if err == nil {
|
||||
err = errors.New("lock was not held")
|
||||
}
|
||||
retErr = errors.Join(retErr, fmt.Errorf("db: release migration lock: %w", err))
|
||||
// Never return a possibly locked session to the pool.
|
||||
closeCtx, closeCancel := context.WithTimeout(context.Background(), migrationCleanupTimeout)
|
||||
defer closeCancel()
|
||||
if closeErr := conn.Conn().Close(closeCtx); closeErr != nil {
|
||||
retErr = errors.Join(retErr, fmt.Errorf("db: close migration connection: %w", closeErr))
|
||||
}
|
||||
}
|
||||
}()
|
||||
if _, err := conn.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT NOT NULL PRIMARY KEY)`); err != nil {
|
||||
return fmt.Errorf("db: schema_migrations: %w", err)
|
||||
}
|
||||
for _, name := range ups {
|
||||
ver := strings.TrimSuffix(name, ".up.sql")
|
||||
var dummy int
|
||||
err := pool.QueryRow(ctx, `SELECT 1 FROM schema_migrations WHERE version = $1`, ver).Scan(&dummy)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
body, err := fs.ReadFile(fsys, path.Join(subdir, name))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("db: read migration %s: %w", name, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(body)); err != nil {
|
||||
if bad := unsupportedMigrationStatement(string(body)); bad != "" {
|
||||
return fmt.Errorf("db: migration %s uses unsupported non-transactional SQL %q", name, bad)
|
||||
}
|
||||
if err := applyPostgresMigration(ctx, conn, strings.TrimSuffix(name, ".up.sql"), body); err != nil {
|
||||
return fmt.Errorf("db: migrate %s: %w", name, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, ver); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unsupportedMigrationStatement inspects statement starts after masking SQL
|
||||
// comments and quoted strings. In particular, a comment after a semicolon
|
||||
// must not conceal COMMIT or another command that would escape our transaction.
|
||||
func unsupportedMigrationStatement(sql string) string {
|
||||
const (
|
||||
normal = iota
|
||||
singleQuote
|
||||
doubleQuote
|
||||
lineComment
|
||||
blockComment
|
||||
dollarQuote
|
||||
)
|
||||
state := normal
|
||||
blockDepth := 0
|
||||
dollarTag := ""
|
||||
escapeString := false
|
||||
var clean strings.Builder
|
||||
for i := 0; i < len(sql); {
|
||||
switch state {
|
||||
case normal:
|
||||
switch {
|
||||
case sql[i] == '\'':
|
||||
escapeString = i > 0 && (sql[i-1] == 'E' || sql[i-1] == 'e') && (i == 1 || !isSQLIdentifierByte(sql[i-2]))
|
||||
state = singleQuote
|
||||
clean.WriteByte(' ')
|
||||
i++
|
||||
case sql[i] == '"':
|
||||
state = doubleQuote
|
||||
clean.WriteByte(' ')
|
||||
i++
|
||||
case strings.HasPrefix(sql[i:], "--"):
|
||||
state = lineComment
|
||||
clean.WriteByte(' ')
|
||||
i += 2
|
||||
case strings.HasPrefix(sql[i:], "/*"):
|
||||
state = blockComment
|
||||
blockDepth = 1
|
||||
clean.WriteByte(' ')
|
||||
i += 2
|
||||
case sql[i] == '$' && (i == 0 || !isSQLIdentifierByte(sql[i-1])):
|
||||
end := i + 1
|
||||
if end < len(sql) && isDollarTagStartByte(sql[end]) {
|
||||
end++
|
||||
for end < len(sql) && isDollarTagByte(sql[end]) {
|
||||
end++
|
||||
}
|
||||
}
|
||||
if end < len(sql) && sql[end] == '$' {
|
||||
dollarTag = sql[i : end+1]
|
||||
state = dollarQuote
|
||||
clean.WriteByte(' ')
|
||||
i = end + 1
|
||||
} else {
|
||||
clean.WriteByte(sql[i])
|
||||
i++
|
||||
}
|
||||
default:
|
||||
clean.WriteByte(sql[i])
|
||||
i++
|
||||
}
|
||||
case singleQuote:
|
||||
switch {
|
||||
case escapeString && sql[i] == '\\' && i+1 < len(sql):
|
||||
i += 2
|
||||
case sql[i] == '\'' && i+1 < len(sql) && sql[i+1] == '\'':
|
||||
i += 2
|
||||
case sql[i] == '\'':
|
||||
state = normal
|
||||
i++
|
||||
default:
|
||||
i++
|
||||
}
|
||||
case doubleQuote:
|
||||
if sql[i] == '"' {
|
||||
if i+1 < len(sql) && sql[i+1] == '"' {
|
||||
i += 2
|
||||
} else {
|
||||
state = normal
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
case lineComment:
|
||||
if sql[i] == '\n' || sql[i] == '\r' {
|
||||
clean.WriteByte('\n')
|
||||
state = normal
|
||||
}
|
||||
i++
|
||||
case blockComment:
|
||||
switch {
|
||||
case strings.HasPrefix(sql[i:], "/*"):
|
||||
blockDepth++
|
||||
i += 2
|
||||
case strings.HasPrefix(sql[i:], "*/"):
|
||||
blockDepth--
|
||||
i += 2
|
||||
if blockDepth == 0 {
|
||||
state = normal
|
||||
}
|
||||
default:
|
||||
i++
|
||||
}
|
||||
case dollarQuote:
|
||||
if strings.HasPrefix(sql[i:], dollarTag) {
|
||||
i += len(dollarTag)
|
||||
state = normal
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, statement := range strings.Split(clean.String(), ";") {
|
||||
words := strings.Fields(strings.ToUpper(statement))
|
||||
if isUnsupportedMigrationCommand(words) {
|
||||
return strings.Join(words[:min(len(words), 4)], " ")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isSQLIdentifierByte(b byte) bool {
|
||||
return isDollarTagByte(b) || b == '$'
|
||||
}
|
||||
|
||||
func isDollarTagStartByte(b byte) bool {
|
||||
return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b == '_' || b >= 0x80
|
||||
}
|
||||
|
||||
func isDollarTagByte(b byte) bool {
|
||||
return isDollarTagStartByte(b) || b >= '0' && b <= '9'
|
||||
}
|
||||
|
||||
func isUnsupportedMigrationCommand(words []string) bool {
|
||||
if len(words) == 0 {
|
||||
return false
|
||||
}
|
||||
second := ""
|
||||
if len(words) > 1 {
|
||||
second = words[1]
|
||||
}
|
||||
switch words[0] {
|
||||
case "BEGIN", "COMMIT", "END", "ROLLBACK", "ABORT", "SAVEPOINT", "REINDEX", "VACUUM", "CLUSTER":
|
||||
return true
|
||||
case "START", "PREPARE":
|
||||
return second == "TRANSACTION"
|
||||
case "RELEASE":
|
||||
return second == "SAVEPOINT" || second != ""
|
||||
case "ALTER":
|
||||
return second == "SYSTEM"
|
||||
case "CREATE":
|
||||
if second == "DATABASE" || second == "TABLESPACE" {
|
||||
return true
|
||||
}
|
||||
idx := 2
|
||||
if second == "UNIQUE" {
|
||||
idx = 3
|
||||
}
|
||||
return len(words) > idx && words[idx-1] == "INDEX" && words[idx] == "CONCURRENTLY"
|
||||
case "DROP":
|
||||
return second == "DATABASE" || second == "TABLESPACE" || second == "INDEX" && len(words) > 2 && words[2] == "CONCURRENTLY"
|
||||
case "REFRESH":
|
||||
return len(words) > 3 && second == "MATERIALIZED" && words[2] == "VIEW" && words[3] == "CONCURRENTLY"
|
||||
case "DISCARD":
|
||||
return second == "ALL"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applyPostgresMigration(ctx context.Context, conn *pgxpool.Conn, version string, body []byte) (retErr error) {
|
||||
tx, err := conn.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if committed {
|
||||
return
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), migrationCleanupTimeout)
|
||||
defer cancel()
|
||||
if err := tx.Rollback(cleanupCtx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
||||
retErr = errors.Join(retErr, fmt.Errorf("db: rollback migration %s: %w", version, err))
|
||||
}
|
||||
}()
|
||||
var present int
|
||||
err = tx.QueryRow(ctx, `SELECT 1 FROM schema_migrations WHERE version = $1`, version).Scan(&present)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evobgp/migrations"
|
||||
)
|
||||
|
||||
func TestPostgresEmbeddedMigrationsTransactional(t *testing.T) {
|
||||
entries, err := fs.ReadDir(migrations.Postgres, "postgres")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !strings.HasSuffix(entry.Name(), ".up.sql") {
|
||||
continue
|
||||
}
|
||||
body, err := fs.ReadFile(migrations.Postgres, path.Join("postgres", entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bad := unsupportedMigrationStatement(string(body)); bad != "" {
|
||||
t.Errorf("%s contains unsupported transaction control or DDL: %q", entry.Name(), bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresMigrationRejectsTransactionControl(t *testing.T) {
|
||||
for _, statement := range []string{
|
||||
"COMMIT;", "END;", "ROLLBACK;", "ABORT;", "BEGIN;",
|
||||
"CREATE INDEX CONCURRENTLY idx ON t (id);", "VACUUM t;",
|
||||
"CREATE TABLE t(id int); /* boundary */ COMMIT; SELECT 1/0;",
|
||||
"/* comment */COMMIT;",
|
||||
"-- comment\nCOMMIT;",
|
||||
"/* nested /* comment */ still comment */ END;",
|
||||
"CREATE TABLE foo$tag$ (id int); COMMIT; -- $tag$",
|
||||
"-- comment\rCOMMIT;",
|
||||
`foo$E'abc\'; COMMIT;`,
|
||||
`éE'abc\'; COMMIT;`,
|
||||
} {
|
||||
if unsupportedMigrationStatement(statement) == "" {
|
||||
t.Errorf("did not reject %q", statement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresMigrationIgnoresQuotedControlWords(t *testing.T) {
|
||||
for _, statement := range []string{
|
||||
"SELECT 'COMMIT;'::text;",
|
||||
"SELECT $$COMMIT;$$;",
|
||||
"SELECT $tag$END;$tag$;",
|
||||
"SELECT E'ABORT;\\'';",
|
||||
"SELECT \"ROLLBACK\" FROM t;",
|
||||
"-- COMMIT;\nSELECT 1;",
|
||||
} {
|
||||
if bad := unsupportedMigrationStatement(statement); bad != "" {
|
||||
t.Errorf("rejected quoted or commented SQL %q: %s", statement, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-11
@@ -25,17 +25,7 @@ func OpenPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if max := os.Getenv("EVOBGP_DB_MAX_CONNS"); max != "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(max)); err == nil && n > 0 {
|
||||
cfg.MaxConns = int32(n)
|
||||
}
|
||||
}
|
||||
if min := os.Getenv("EVOBGP_DB_MIN_CONNS"); min != "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(min)); err == nil && n >= 0 {
|
||||
cfg.MinConns = int32(n)
|
||||
}
|
||||
}
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
applyPostgresPoolConfig(cfg)
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -47,6 +37,29 @@ func OpenPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// applyPostgresPoolConfig sets pool limits. An explicit EVOBGP_DB_MAX_CONNS is kept.
|
||||
// When it is unset, the pool defaults to 8 connections so it can cover the default
|
||||
// worker count instead of pgx's default of 4.
|
||||
func applyPostgresPoolConfig(cfg *pgxpool.Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if max := strings.TrimSpace(os.Getenv("EVOBGP_DB_MAX_CONNS")); max != "" {
|
||||
if n, err := strconv.Atoi(max); err == nil && n > 0 {
|
||||
cfg.MaxConns = int32(n)
|
||||
}
|
||||
} else {
|
||||
cfg.MaxConns = 8
|
||||
}
|
||||
if min := strings.TrimSpace(os.Getenv("EVOBGP_DB_MIN_CONNS")); min != "" {
|
||||
if n, err := strconv.Atoi(min); err == nil && n >= 0 {
|
||||
cfg.MinConns = int32(n)
|
||||
}
|
||||
}
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
cfg.MaxConnIdleTime = 5 * time.Minute
|
||||
}
|
||||
|
||||
// OpenSQLite applies sqlite/*.up.sql then opens the DB (foreign keys on).
|
||||
func OpenSQLite(ctx context.Context, filePath string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)", strings.TrimPrefix(filePath, "file:"))
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestApplyPostgresPoolConfigDefaultAndExplicit(t *testing.T) {
|
||||
t.Setenv("EVOBGP_DB_MAX_CONNS", "")
|
||||
t.Setenv("EVOBGP_DB_MIN_CONNS", "")
|
||||
cfg, err := pgxpool.ParseConfig("postgres://localhost/evobgp?sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyPostgresPoolConfig(cfg)
|
||||
if cfg.MaxConns != 8 {
|
||||
t.Fatalf("default max conns: %d", cfg.MaxConns)
|
||||
}
|
||||
if cfg.MaxConnIdleTime != 5*time.Minute {
|
||||
t.Fatalf("idle time: %s", cfg.MaxConnIdleTime)
|
||||
}
|
||||
|
||||
t.Setenv("EVOBGP_DB_MAX_CONNS", "25")
|
||||
cfg, err = pgxpool.ParseConfig("postgres://localhost/evobgp?sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyPostgresPoolConfig(cfg)
|
||||
if cfg.MaxConns != 25 {
|
||||
t.Fatalf("explicit max conns overridden: %d", cfg.MaxConns)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user