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:
DenozordecandCursor committed 2026-11-02 17:23:59 +07:00
1 parent 924f111e48
commit f8e0520817
197 files changed
+18655 -1416

No files matched your search

+267 -23
View File
@@ -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
}
+63
View File
@@ -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
View File
@@ -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:"))
+34
View 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)
}
}
+227
View File
@@ -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)
}
}