mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]>
497 lines
18 KiB
Go
497 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// MUL-2956 — concurrent migration race test.
|
|
//
|
|
// PR multica-ai/multica#3658 (MUL-2923) added a Postgres advisory lock
|
|
// around the migration loop to serialize concurrent runners. This file
|
|
// is the live-Postgres test that proves the lock is actually doing its
|
|
// job. We run N goroutines that all call runMigrations against the same
|
|
// database with the same options, and assert:
|
|
//
|
|
// 1. Pending: when migrations have NOT been applied, every goroutine
|
|
// returns nil and exactly one application of each migration lands
|
|
// in the bookkeeping table — no duplicate-key blow-ups, no missing
|
|
// rows, and (since our test fixtures are deliberately non-idempotent
|
|
// bare CREATE TABLE / ALTER TABLE) no "relation already exists"
|
|
// failures from the SQL itself, which would prove the lock isn't
|
|
// serializing.
|
|
// 2. Already applied: rerunning the same N-way race against the just-
|
|
// populated bookkeeping table sends every goroutine down the EXISTS
|
|
// no-op path; nobody re-applies anything and the underlying schema
|
|
// is unchanged.
|
|
// 3. Lock serialization: while one connection holds the same advisory
|
|
// lock externally, every concurrent runMigrations is observed to
|
|
// wait, and only after the external holder releases does the lock
|
|
// get acquired. This catches the regression where the lock would
|
|
// get attached to a random pooled connection (the bug fixed in
|
|
// MUL-2923 / #3658) and effectively become a no-op.
|
|
//
|
|
// The test connects to whatever DATABASE_URL points at (default
|
|
// postgres://multica:multica@localhost:5432/multica?sslmode=disable),
|
|
// matching the harness pattern already used in
|
|
// server/internal/handler/handler_test.go. If Postgres is unreachable the
|
|
// suite skips cleanly, the same way every other live-Postgres test in the
|
|
// repo skips, so CI without a database sees SKIP rather than failure.
|
|
//
|
|
// Each test isolates itself by creating a unique throwaway schema
|
|
// (migrate_test_<timestamp>_<rand>) and using a unique advisory-lock
|
|
// key per run. That means the test never touches the real
|
|
// schema_migrations table and never blocks behind a real production
|
|
// migration runner sharing the same database. The schema is dropped
|
|
// during cleanup.
|
|
|
|
const (
|
|
defaultTestDatabaseURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
|
|
// concurrentRunners is the goroutine count for the race tests. Set
|
|
// large enough that a missing lock would reliably trip on a multi-
|
|
// core box with -race, but small enough to keep the suite fast on a
|
|
// single shared Postgres.
|
|
concurrentRunners = 16
|
|
// raceTestTimeout bounds every individual concurrent step; if the
|
|
// lock implementation regresses into a deadlock we fail loudly
|
|
// instead of hanging the suite.
|
|
raceTestTimeout = 60 * time.Second
|
|
)
|
|
|
|
func testDatabaseURL() string {
|
|
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
|
|
return dbURL
|
|
}
|
|
return defaultTestDatabaseURL
|
|
}
|
|
|
|
func openTestPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
dbURL := testDatabaseURL()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
pool, err := pgxpool.New(ctx, dbURL)
|
|
if err != nil {
|
|
t.Skipf("could not connect to %s: %v", dbURL, err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
pool.Close()
|
|
t.Skipf("database not reachable at %s: %v", dbURL, err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
return pool
|
|
}
|
|
|
|
// fixture is the per-test sandbox: a private schema, a unique advisory
|
|
// lock key, and a temp directory full of deliberately non-idempotent
|
|
// migration SQL files.
|
|
type fixture struct {
|
|
pool *pgxpool.Pool
|
|
schema string
|
|
tableFQN string // e.g. "migrate_test_xyz"."schema_migrations"
|
|
lockKey int64
|
|
files []string // sorted .up.sql paths
|
|
versions []string // matching versions
|
|
tableNames []string // distinct test tables each migration creates
|
|
}
|
|
|
|
func newFixture(t *testing.T) *fixture {
|
|
t.Helper()
|
|
pool := openTestPool(t)
|
|
|
|
// Unique schema and lock key per test invocation. We salt with both
|
|
// nanos and a process-local random so re-running with -count=N still
|
|
// gets a distinct sandbox per iteration even if the wall clock has
|
|
// not visibly advanced.
|
|
suffix := fmt.Sprintf("%d_%d", time.Now().UnixNano(), rand.Uint32())
|
|
schema := "migrate_test_" + suffix
|
|
tableFQN := schema + ".schema_migrations"
|
|
// Random non-zero positive int64. The high bit is masked off to
|
|
// keep this in the same numeric range pg_advisory_lock expects, and
|
|
// the OR with 1 guarantees we never end up at zero. Collision with
|
|
// the production migrationAdvisoryLockKey constant is not strictly
|
|
// impossible — both are int64 — but the probability is on the order
|
|
// of 1 in 2^62, which is negligible for a unit-test sandbox.
|
|
lockKey := int64(rand.Uint64()&0x7fffffffffffffff) | 1
|
|
|
|
ctx := context.Background()
|
|
if _, err := pool.Exec(ctx, fmt.Sprintf(`CREATE SCHEMA %s`, pgx.Identifier{schema}.Sanitize())); err != nil {
|
|
t.Fatalf("create schema: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
// Use a fresh context so cleanup still runs if the test ctx was
|
|
// cancelled. Drop CASCADE to take everything down even when a
|
|
// half-applied migration left orphan tables behind.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if _, err := pool.Exec(ctx, fmt.Sprintf(`DROP SCHEMA IF EXISTS %s CASCADE`, pgx.Identifier{schema}.Sanitize())); err != nil {
|
|
t.Logf("drop schema %s: %v", schema, err)
|
|
}
|
|
})
|
|
|
|
// Build a small set of deliberately non-idempotent migrations. Each
|
|
// one creates a distinct table inside our schema. Bare CREATE TABLE
|
|
// (no IF NOT EXISTS) and ALTER TABLE ADD COLUMN (no IF NOT EXISTS)
|
|
// guarantee that if two goroutines actually ran the same migration
|
|
// in parallel, the second one would error with "relation already
|
|
// exists" / "column already exists" — which is exactly the failure
|
|
// signature we want the test to catch when the lock regresses.
|
|
dir := t.TempDir()
|
|
const numFiles = 5
|
|
files := make([]string, 0, numFiles)
|
|
versions := make([]string, 0, numFiles)
|
|
tableNames := make([]string, 0, numFiles)
|
|
for i := 0; i < numFiles; i++ {
|
|
version := fmt.Sprintf("%03d_test_%s", i+1, suffix)
|
|
tableName := fmt.Sprintf("t_%s_%d", suffix, i+1)
|
|
// Reference both the schema and the table, then add a column in
|
|
// a follow-up statement. Either statement run twice (i.e.
|
|
// concurrent re-application by another goroutine that won the
|
|
// race past the EXISTS check) would error.
|
|
body := fmt.Sprintf(
|
|
"CREATE TABLE %s.%s (id BIGSERIAL PRIMARY KEY);\n"+
|
|
"ALTER TABLE %s.%s ADD COLUMN payload TEXT NOT NULL DEFAULT '';\n",
|
|
pgx.Identifier{schema}.Sanitize(), pgx.Identifier{tableName}.Sanitize(),
|
|
pgx.Identifier{schema}.Sanitize(), pgx.Identifier{tableName}.Sanitize(),
|
|
)
|
|
path := filepath.Join(dir, version+".up.sql")
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatalf("write migration: %v", err)
|
|
}
|
|
files = append(files, path)
|
|
versions = append(versions, version)
|
|
tableNames = append(tableNames, tableName)
|
|
}
|
|
sort.Strings(files)
|
|
|
|
return &fixture{
|
|
pool: pool,
|
|
schema: schema,
|
|
tableFQN: tableFQN,
|
|
lockKey: lockKey,
|
|
files: files,
|
|
versions: versions,
|
|
tableNames: tableNames,
|
|
}
|
|
}
|
|
|
|
func (f *fixture) opts() runOptions {
|
|
return runOptions{
|
|
Direction: "up",
|
|
Files: f.files,
|
|
SchemaMigrationsTable: f.tableFQN,
|
|
AdvisoryLockKey: f.lockKey,
|
|
}
|
|
}
|
|
|
|
// appliedVersions returns the versions recorded in the bookkeeping
|
|
// table, sorted ascending. Empty slice means the table is empty (or
|
|
// does not yet exist, which the helper reports as a fatal error).
|
|
func (f *fixture) appliedVersions(t *testing.T) []string {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
rows, err := f.pool.Query(ctx,
|
|
fmt.Sprintf(`SELECT version FROM %s ORDER BY version`,
|
|
pgx.Identifier{f.schema, "schema_migrations"}.Sanitize()))
|
|
if err != nil {
|
|
t.Fatalf("read schema_migrations: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
var got []string
|
|
for rows.Next() {
|
|
var v string
|
|
if err := rows.Scan(&v); err != nil {
|
|
t.Fatalf("scan version: %v", err)
|
|
}
|
|
got = append(got, v)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
t.Fatalf("rows.Err: %v", err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// tableExists checks that a given table is present inside the fixture
|
|
// schema. Used to confirm migrations actually executed, not just that
|
|
// the bookkeeping rows landed.
|
|
func (f *fixture) tableExists(t *testing.T, name string) bool {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
var exists bool
|
|
if err := f.pool.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM information_schema.tables
|
|
WHERE table_schema = $1 AND table_name = $2
|
|
)`, f.schema, name).Scan(&exists); err != nil {
|
|
t.Fatalf("check table %s.%s: %v", f.schema, name, err)
|
|
}
|
|
return exists
|
|
}
|
|
|
|
// TestRunMigrationsConcurrentPending fires N goroutines at runMigrations
|
|
// against a fresh schema where none of the migrations have been applied
|
|
// yet. The advisory lock must serialize them so that exactly one of
|
|
// them executes each CREATE TABLE / ALTER TABLE and exactly one row
|
|
// per migration lands in schema_migrations. If the lock is broken,
|
|
// either the SQL fails ("relation already exists") or the bookkeeping
|
|
// table picks up duplicate-key violations on the version primary key.
|
|
func TestRunMigrationsConcurrentPending(t *testing.T) {
|
|
f := newFixture(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), raceTestTimeout)
|
|
defer cancel()
|
|
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
// errgroup limits concurrency only when SetLimit is called; we want
|
|
// every goroutine running at once so they all queue on the lock.
|
|
for i := 0; i < concurrentRunners; i++ {
|
|
g.Go(func() error {
|
|
return runMigrations(gctx, f.pool, f.opts())
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
t.Fatalf("concurrent runMigrations(up) on pending schema returned error: %v", err)
|
|
}
|
|
|
|
got := f.appliedVersions(t)
|
|
if want := f.versions; !equalStrings(got, want) {
|
|
t.Fatalf("schema_migrations contents = %v, want %v", got, want)
|
|
}
|
|
for _, tbl := range f.tableNames {
|
|
if !f.tableExists(t, tbl) {
|
|
t.Fatalf("expected table %s.%s to exist after concurrent up, missing", f.schema, tbl)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRunMigrationsConcurrentAlreadyApplied first applies the
|
|
// migrations once (single-threaded, to establish a clean baseline) and
|
|
// then fires N goroutines at runMigrations again. Every goroutine must
|
|
// hit the EXISTS no-op path and return nil, the bookkeeping table must
|
|
// stay exactly the way the baseline left it, and the underlying tables
|
|
// must not have been touched (no duplicate CREATE / ALTER blow-ups).
|
|
//
|
|
// This is the path that matters in production: most pod restarts find
|
|
// the database fully migrated and just need to confirm-and-skip.
|
|
func TestRunMigrationsConcurrentAlreadyApplied(t *testing.T) {
|
|
f := newFixture(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), raceTestTimeout)
|
|
defer cancel()
|
|
|
|
// Baseline single-threaded run.
|
|
if err := runMigrations(ctx, f.pool, f.opts()); err != nil {
|
|
t.Fatalf("baseline runMigrations: %v", err)
|
|
}
|
|
baseline := f.appliedVersions(t)
|
|
if !equalStrings(baseline, f.versions) {
|
|
t.Fatalf("baseline schema_migrations = %v, want %v", baseline, f.versions)
|
|
}
|
|
|
|
// Concurrent re-run: every goroutine should hit the EXISTS no-op
|
|
// branch and return nil.
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
for i := 0; i < concurrentRunners; i++ {
|
|
g.Go(func() error {
|
|
return runMigrations(gctx, f.pool, f.opts())
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
t.Fatalf("concurrent runMigrations(up) on already-applied schema returned error: %v", err)
|
|
}
|
|
|
|
got := f.appliedVersions(t)
|
|
if !equalStrings(got, baseline) {
|
|
t.Fatalf("schema_migrations changed after concurrent re-run: got %v, want %v", got, baseline)
|
|
}
|
|
}
|
|
|
|
// TestRunMigrationsAdvisoryLockSerializes proves the lock genuinely
|
|
// blocks contenders. We acquire the same advisory key on a side
|
|
// connection BEFORE spawning any runMigrations goroutine, then start N
|
|
// goroutines and watch how many of them have made it past the lock
|
|
// acquire. The expectation:
|
|
//
|
|
// - While the side connection holds the lock, zero goroutines have
|
|
// completed (we observe via a small delay + count-check).
|
|
// - The moment the side connection releases the lock, the goroutines
|
|
// start unblocking and finish in well under the test timeout.
|
|
//
|
|
// If the advisory lock had regressed back to attaching to a random
|
|
// pooled connection (the original MUL-2923 bug), the side-held lock
|
|
// would not actually block a fresh pool.Acquire from grabbing its own
|
|
// connection without the lock, and the goroutines would all complete
|
|
// while the lock was still "held" — which is exactly what this test
|
|
// detects.
|
|
func TestRunMigrationsAdvisoryLockSerializes(t *testing.T) {
|
|
f := newFixture(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), raceTestTimeout)
|
|
defer cancel()
|
|
|
|
// Acquire the lock on a pinned side connection. We use a *pgx.Conn
|
|
// (not pool.Acquire) so the lock holder is not reachable through
|
|
// the pool the runMigrations goroutines draw from — the lock is
|
|
// session-scoped and we want the behaviour to be "the next pool
|
|
// connection that calls pg_advisory_lock blocks", not "the same
|
|
// connection re-enters". (pg_advisory_lock is reentrant on the same
|
|
// session, so re-acquiring on the same conn would not actually
|
|
// prove serialization.)
|
|
dbURL := testDatabaseURL()
|
|
holder, err := pgx.Connect(ctx, dbURL)
|
|
if err != nil {
|
|
t.Fatalf("side connect: %v", err)
|
|
}
|
|
defer holder.Close(context.Background())
|
|
if _, err := holder.Exec(ctx, "SELECT pg_advisory_lock($1)", f.lockKey); err != nil {
|
|
t.Fatalf("side acquire lock: %v", err)
|
|
}
|
|
|
|
var done int64
|
|
var startedAt = time.Now()
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
for i := 0; i < concurrentRunners; i++ {
|
|
g.Go(func() error {
|
|
err := runMigrations(gctx, f.pool, f.opts())
|
|
atomic.AddInt64(&done, 1)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// Watchdog: while the side holder still has the lock, no
|
|
// runMigrations goroutine should have completed. We sample for a
|
|
// generous window (1 s) — much longer than the trivial migration
|
|
// set takes to apply on the unlocked path — to give a regressed
|
|
// implementation room to incorrectly succeed.
|
|
const observeWindow = 1 * time.Second
|
|
const observeStep = 50 * time.Millisecond
|
|
deadline := time.Now().Add(observeWindow)
|
|
for time.Now().Before(deadline) {
|
|
if n := atomic.LoadInt64(&done); n != 0 {
|
|
t.Fatalf("advisory lock did not block: %d/%d goroutines finished while side connection held the lock for %s",
|
|
n, concurrentRunners, time.Since(startedAt))
|
|
}
|
|
time.Sleep(observeStep)
|
|
}
|
|
|
|
// Release the side lock and wait for all goroutines to finish.
|
|
if _, err := holder.Exec(ctx, "SELECT pg_advisory_unlock($1)", f.lockKey); err != nil {
|
|
t.Fatalf("side release lock: %v", err)
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
t.Fatalf("concurrent runMigrations after lock release returned error: %v", err)
|
|
}
|
|
|
|
// Sanity: state ended up as the pending case did — exactly one
|
|
// application of every migration.
|
|
if got, want := f.appliedVersions(t), f.versions; !equalStrings(got, want) {
|
|
t.Fatalf("schema_migrations after lock-release race = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// TestRunMigrationsConcurrentMixedPoolStress runs the pending case
|
|
// against a deliberately under-sized pool to put pressure on the
|
|
// "every runner needs its own pinned connection for the lock" code
|
|
// path. If runMigrations ever regresses into using pool.Exec (which
|
|
// could give the lock and the migration steps different connections),
|
|
// this test will deadlock or produce SQL races. Pool size strictly
|
|
// less than runners is the interesting configuration.
|
|
func TestRunMigrationsConcurrentMixedPoolStress(t *testing.T) {
|
|
dbURL := testDatabaseURL()
|
|
cfg, err := pgxpool.ParseConfig(dbURL)
|
|
if err != nil {
|
|
t.Skipf("parse DATABASE_URL: %v", err)
|
|
}
|
|
// Small pool: half the runner count, minimum 2. This forces
|
|
// runners to wait on pgxpool.Acquire AND on pg_advisory_lock,
|
|
// exercising the same connection lifecycle a real multi-replica
|
|
// startup would.
|
|
cfg.MaxConns = int32(concurrentRunners / 2)
|
|
if cfg.MaxConns < 2 {
|
|
cfg.MaxConns = 2
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), raceTestTimeout)
|
|
defer cancel()
|
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
|
if err != nil {
|
|
t.Skipf("could not open small pool: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
if err := pool.Ping(ctx); err != nil {
|
|
t.Skipf("small pool not reachable: %v", err)
|
|
}
|
|
|
|
// Reuse the standard fixture's schema/files/lock-key wiring but
|
|
// substitute the small-pool *pgxpool.Pool so the test exercises a
|
|
// different connection budget.
|
|
big := newFixture(t)
|
|
f := *big
|
|
f.pool = pool
|
|
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
var startedOnce sync.Once
|
|
startedAt := time.Time{}
|
|
for i := 0; i < concurrentRunners; i++ {
|
|
g.Go(func() error {
|
|
startedOnce.Do(func() { startedAt = time.Now() })
|
|
return runMigrations(gctx, f.pool, f.opts())
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
t.Fatalf("small-pool concurrent runMigrations error after %s: %v", time.Since(startedAt), err)
|
|
}
|
|
if got, want := big.appliedVersions(t), big.versions; !equalStrings(got, want) {
|
|
t.Fatalf("small-pool schema_migrations = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func equalStrings(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// TestRunMigrationsRejectsInvalidDirection pins the direction
|
|
// whitelist contract: anything other than "up" or "down" must error
|
|
// before runMigrations touches the pool. This prevents the subtle bug
|
|
// where an empty or typo'd direction silently fell through to the
|
|
// "down" branch (`opts.Direction == "up"` is false → else branch
|
|
// handles it as a rollback).
|
|
//
|
|
// The check runs ahead of any pool/conn use, so passing nil is safe
|
|
// and lets this case execute without a live Postgres.
|
|
func TestRunMigrationsRejectsInvalidDirection(t *testing.T) {
|
|
bad := []string{"", "UP", "DOWN", "rollback", "x", " up "}
|
|
for _, dir := range bad {
|
|
err := runMigrations(context.Background(), nil, runOptions{Direction: dir})
|
|
if err == nil {
|
|
t.Errorf("direction %q: want error, got nil", dir)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid direction") {
|
|
t.Errorf("direction %q: error %q does not mention 'invalid direction'", dir, err)
|
|
}
|
|
}
|
|
}
|