Files
EvoBGP/internal/db/apply.go
T
DenozordecandCursor f8e0520817 feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:23:59 +07:00

300 lines
8.3 KiB
Go

package db
import (
"context"
"errors"
"fmt"
"io/fs"
"path"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
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 fmt.Errorf("db: read migrations: %w", err)
}
var ups []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
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 {
body, err := fs.ReadFile(fsys, path.Join(subdir, name))
if err != nil {
return fmt.Errorf("db: read migration %s: %w", name, err)
}
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)
}
}
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
}