feat(migrate): configure embedded Goose providers

Use provider-local SQL and Go migrations, injected slog logging, and PostgreSQL session locking. Exercise timestamp migration flows and failures with SQLite and PostgreSQL, including concurrent startup, and require PostgreSQL integration coverage in CI.

Refs #7
This commit is contained in:
end committed 2026-10-27 11:50:49 -07:00
1 parent 46c4416ed4
commit badeefb9a9
13 files changed
+686

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
# migrate
`go.auvem.com/corestack/migrate` sets the house configuration for
[Goose's Provider API](https://pressly.github.io/goose/documentation/provider/).
It returns `*goose.Provider` directly: Goose owns execution, version tracking,
status, and errors. There is no lifecycle dependency or background work.
Embed migrations in the application's binary and run them before serving traffic:
```go
package app
import (
"context"
"database/sql"
"embed"
"log/slog"
"github.com/pressly/goose/v3"
"go.auvem.com/corestack/migrate"
)
//go:embed migrations/*.sql
var migrations embed.FS
func migrateDatabase(ctx context.Context, db *sql.DB, logger *slog.Logger) error {
provider, err := migrate.New(db, goose.DialectPostgres, migrations, "migrations", logger)
if err != nil {
return err
}
_, err = provider.Up(ctx)
return err
}
```
The caller opens and closes `db`, selects a matching database driver and dialect,
supplies a nonnil logger, and controls the operation context/deadline. Construction
is offline; SQL is parsed lazily by Goose. `Provider.Close` also closes `db`, so do
not defer it when sharing the pool with the application. Use `"."` for SQL files
at the embedded root. Files in the selected directory are discovered directly,
without recursively searching subdirectories.
Use unique positive versions in filenames such as
`20261027114500_create_users.sql`; sequential versions such as `00007_users.sql`
also work. Never derive a version from a file count or add one to the current
version. Keep applied migrations immutable and ship the complete history.
Goose rejects newly introduced migrations below the current version by default.
```sql
-- +goose Up
CREATE TABLE users (id BIGINT PRIMARY KEY);
-- +goose Down
DROP TABLE users;
```
Goose runs each migration in a transaction by default. Its annotations support
statement blocks and explicit `NO TRANSACTION` where required; such migrations
and nontransactional database DDL can leave partial effects on failure. Down
migrations execute application-authored SQL and may delete data.
| Operation | Provider method |
| --- | --- |
| Apply all pending migrations | `Up(ctx)` |
| Apply pending versions through a target, inclusive | `UpTo(ctx, version)` |
| Roll back versions above a target; zero removes all | `DownTo(ctx, version)` |
| Inspect each migration's state | `Status(ctx)` |
| Current database version | `GetDBVersion(ctx)` |
| Current and latest available versions | `GetVersions(ctx)` |
| Detect pending migrations | `HasPending(ctx)` |
Targets are numeric version bounds, so gaps and timestamps work without special
handling. Up on a fully migrated database succeeds with no results. Operations
may create Goose's `goose_db_version` table even when only inspecting state.
Execution errors retain Goose's details; use `errors.As` with
`*goose.PartialError` to inspect applied and failed migrations. Transactional
failure leaves the database at the last committed migration version. Operation
logs use the injected slog logger; callers handle and log returned errors.
For Go functions, pass migrations created with `goose.NewGoMigration(version,
up, down)` as the trailing arguments to `migrate.New`. Use `goose.GoFunc.RunTx`
for transactional work or `RunDB` for work requiring its own connections. Each
provider should receive its own migration instances. Versions must be unique
across SQL and Go migrations. Global registration is disabled; no `init` or
`goose.AddMigration` calls are needed.
Postgres providers automatically use Goose's default session advisory locker.
Concurrent startup calls from separate providers/processes serialize migration
execution and recheck applied versions under the lock. The default lock ID
serializes Goose migrations throughout the same database, including different
schemas; lock acquisition retries for about five minutes and unlock retries for
about one minute, subject to the operation context. Use a direct connection or
a session-pooling proxy: transaction pooling cannot preserve session locks.
`RunDB` Go migrations need spare pool connections while the locked session is
held; avoid a one-connection pool for them.
Other dialects have no cross-provider/process lock configured by this package.
Serialize migration runs externally, for example with a single deployment job.
SQLite tests validate the SQL convention, not concurrent daemon startup safety.
Run the integration suite against an existing Postgres database with schema
creation privileges:
```sh
MIGRATE_POSTGRES_DSN='postgres://user:password@localhost/testdb?sslmode=disable' go test -race ./migrate
```
Tests create and clean only random schemas they own. Without a DSN, Postgres
tests skip locally; in CI a missing DSN fails, and the CI job supplies a Postgres
service. SQLite tests always run in process using temporary databases.
+56
View File
@@ -0,0 +1,56 @@
// Package migrate configures Goose providers for embedded application migrations.
// Applications own their database, migration files, and startup ordering.
package migrate
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"log/slog"
"github.com/pressly/goose/v3"
"github.com/pressly/goose/v3/lock"
)
// New returns a provider for SQL files in dir and optional provider-local Go
// migrations. Pass "." for files at the embedded root and a nonnil logger.
// Construction does not connect to db; the caller owns and closes db.
//
// PostgreSQL uses Goose's session advisory locker with its default lock ID and
// retry limits. Other dialects require the caller to serialize migration runs
// across providers/processes. PostgreSQL connections must preserve session
// affinity (transaction-pooling proxies cannot safely hold session locks).
//
// Use the returned Provider's Up, UpTo, DownTo, Status, GetDBVersion, GetVersions,
// and HasPending methods directly. Versions may be sequential or timestamps;
// Goose orders and tracks them. Global Go migration registration is ignored.
func New(db *sql.DB, dialect goose.Dialect, files embed.FS, dir string, logger *slog.Logger, migrations ...*goose.Migration) (*goose.Provider, error) {
for i, migration := range migrations {
if migration == nil {
return nil, fmt.Errorf("migrate: Go migration %d must not be nil", i)
}
}
root, err := fs.Sub(files, dir)
if err != nil {
return nil, fmt.Errorf("migrate: directory %q: %w", dir, err)
}
opts := []goose.ProviderOption{
goose.WithDisableGlobalRegistry(true),
goose.WithGoMigrations(migrations...),
goose.WithSlog(logger),
goose.WithVerbose(true),
}
if dialect == goose.DialectPostgres {
locker, err := lock.NewPostgresSessionLocker()
if err != nil {
return nil, fmt.Errorf("migrate: postgres locker: %w", err)
}
opts = append(opts, goose.WithSessionLocker(locker))
}
provider, err := goose.NewProvider(dialect, db, root, opts...)
if err != nil {
return nil, fmt.Errorf("migrate: new provider: %w", err)
}
return provider, nil
}
+368
View File
@@ -0,0 +1,368 @@
package migrate_test
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"embed"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
"go.auvem.com/corestack/migrate"
)
// invariant: embedded fixtures are read-only; no test mutates shared migration state.
//
//go:embed testdata
var fixtures embed.FS
func TestSQLite(t *testing.T) {
for _, test := range []struct {
name string
run func(*testing.T, *sql.DB, goose.Dialect)
}{
{"flow", testFlow}, {"failure", testFailure},
} {
t.Run(test.name, func(t *testing.T) {
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "migrate.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
test.run(t, db, goose.DialectSQLite3)
})
}
}
func TestPostgres(t *testing.T) {
for _, test := range []struct {
name string
run func(*testing.T, *sql.DB, goose.Dialect)
}{
{"flow", testFlow}, {"failure", testFailure},
} {
t.Run(test.name, func(t *testing.T) { test.run(t, postgresDatabase(t)(), goose.DialectPostgres) })
}
}
func testFlow(t *testing.T, db *sql.DB, dialect goose.Dialect) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
var logs bytes.Buffer
goMigration := goose.NewGoMigration(20261027120000,
&goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "INSERT INTO migration_events (version) VALUES (20261027120000)")
return err
}},
&goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "DELETE FROM migration_events WHERE version = 20261027120000")
return err
}})
p, err := migrate.New(db, dialect, fixtures, "testdata/migrations", slog.New(slog.NewJSONHandler(&logs, nil)), goMigration)
if err != nil {
t.Fatal(err)
}
versions := []int64{7, 20261026093000, 20261027114500, 20261027120000}
// Registering on one provider must not leak into another provider in the same process.
other := newProvider(t, db, dialect, "testdata/migrations")
if len(other.ListSources()) != 3 {
t.Fatal("Go migration leaked to another provider")
}
assertState(t, ctx, p, 0, versions, 0)
results, err := p.UpTo(ctx, versions[1])
if err != nil {
t.Fatal(err)
}
assertResults(t, results, versions[:2])
assertState(t, ctx, p, versions[1], versions, 2)
results, err = p.Up(ctx)
if err != nil {
t.Fatal(err)
}
assertResults(t, results, versions[2:])
assertState(t, ctx, p, versions[3], versions, 4)
assertEvents(t, ctx, db, versions)
results, err = p.Up(ctx)
if err != nil || len(results) != 0 {
t.Fatalf("idempotent Up = %v, %v", results, err)
}
results, err = p.DownTo(ctx, versions[1])
if err != nil {
t.Fatal(err)
}
assertResults(t, results, []int64{versions[3], versions[2]})
assertState(t, ctx, p, versions[1], versions, 2)
assertEvents(t, ctx, db, versions[:2])
results, err = p.DownTo(ctx, versions[0])
if err != nil {
t.Fatal(err)
}
assertResults(t, results, []int64{versions[1]})
assertState(t, ctx, p, versions[0], versions, 1)
assertEvents(t, ctx, db, versions[:1])
if _, err := p.DownTo(ctx, 0); err != nil {
t.Fatal(err)
}
assertState(t, ctx, p, 0, versions, 0)
results, err = p.Up(ctx)
if err != nil {
t.Fatal(err)
}
assertResults(t, results, versions)
assertEvents(t, ctx, db, versions)
if !strings.Contains(logs.String(), `"logger":"goose"`) || !strings.Contains(logs.String(), `"version":20261027114500`) {
t.Fatalf("missing structured migration logs: %s", logs.String())
}
}
func testFailure(t *testing.T, db *sql.DB, dialect goose.Dialect) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
p := newProvider(t, db, dialect, "testdata/failure")
_, err := p.Up(ctx)
var partial *goose.PartialError
if !errors.As(err, &partial) || partial.Failed.Source.Version != 20261027114500 ||
!strings.Contains(err.Error(), "missing_migration_table") || !strings.Contains(err.Error(), "20261027114500") {
t.Fatalf("expected clear failed timestamp migration error, got %v", err)
}
assertResults(t, partial.Applied, []int64{7, 20261026093000})
assertState(t, ctx, p, 20261026093000, []int64{7, 20261026093000, 20261027114500}, 2)
assertEvents(t, ctx, db, []int64{7, 20261026093000})
// A second attempt must fail at the same version without repeating committed work.
if _, err := p.Up(ctx); err == nil {
t.Fatal("retry unexpectedly succeeded")
}
assertEvents(t, ctx, db, []int64{7, 20261026093000})
}
func newProvider(t *testing.T, db *sql.DB, dialect goose.Dialect, dir string, migrations ...*goose.Migration) *goose.Provider {
t.Helper()
p, err := migrate.New(db, dialect, fixtures, dir, slog.New(slog.NewTextHandler(io.Discard, nil)), migrations...)
if err != nil {
t.Fatal(err)
}
return p
}
func assertState(t *testing.T, ctx context.Context, p *goose.Provider, current int64, versions []int64, applied int) {
t.Helper()
got, err := p.GetDBVersion(ctx)
if err != nil || got != current {
t.Fatalf("GetDBVersion = %d, %v; want %d", got, err, current)
}
got, latest, err := p.GetVersions(ctx)
if err != nil || got != current || latest != versions[len(versions)-1] {
t.Fatalf("GetVersions = %d, %d, %v", got, latest, err)
}
pending, err := p.HasPending(ctx)
if err != nil || pending != (applied < len(versions)) {
t.Fatalf("HasPending = %v, %v", pending, err)
}
status, err := p.Status(ctx)
if err != nil {
t.Fatal(err)
}
if len(status) != len(versions) {
t.Fatalf("Status length = %d; want %d", len(status), len(versions))
}
for i, s := range status {
want := goose.StatePending
if i < applied {
want = goose.StateApplied
}
if s.Source.Version != versions[i] || s.State != want {
t.Fatalf("Status[%d] = %+v, version %d; want %s, %d", i, s, s.Source.Version, want, versions[i])
}
}
}
func assertResults(t *testing.T, results []*goose.MigrationResult, want []int64) {
t.Helper()
got := make([]int64, 0, len(results))
for _, r := range results {
got = append(got, r.Source.Version)
}
if !slices.Equal(got, want) {
t.Fatalf("applied versions = %v; want %v", got, want)
}
}
func assertEvents(t *testing.T, ctx context.Context, db *sql.DB, want []int64) {
t.Helper()
rows, err := db.QueryContext(ctx, "SELECT version FROM migration_events ORDER BY version")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var got []int64
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
t.Fatal(err)
}
got = append(got, v)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if !slices.Equal(got, want) {
t.Fatalf("events = %v; want %v", got, want)
}
}
// postgresDatabase owns only its random schema, never the supplied database.
func postgresDatabase(t *testing.T) func() *sql.DB {
t.Helper()
dsn := os.Getenv("MIGRATE_POSTGRES_DSN")
if dsn == "" {
if os.Getenv("CI") != "" {
t.Fatal("MIGRATE_POSTGRES_DSN is required in CI")
}
t.Skip("set MIGRATE_POSTGRES_DSN to run Postgres integration tests")
}
config, err := pgx.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
admin := stdlib.OpenDB(*config)
t.Cleanup(func() {
if err := admin.Close(); err != nil {
t.Error(err)
}
})
schema := "migrate_" + strings.ToLower(rand.Text())
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
if _, err := admin.ExecContext(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if _, err := admin.ExecContext(ctx, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
t.Error(err)
}
})
config.RuntimeParams["search_path"] = schema
return func() *sql.DB {
db := stdlib.OpenDB(*config)
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
return db
}
}
func TestPostgresConcurrentUp(t *testing.T) {
open := postgresDatabase(t)
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
const workers = 4
providers := make([]*goose.Provider, workers)
for i := range providers {
// Each provider owns a distinct pool and Go migration instance, like separate daemons.
pause := goose.NewGoMigration(3, &goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "SELECT pg_sleep(0.2)")
return err
}}, nil)
providers[i] = newProvider(t, open(), goose.DialectPostgres, "testdata/migrations", pause)
}
type outcome struct {
results []*goose.MigrationResult
err error
}
start := make(chan struct{})
done := make(chan outcome, workers)
for _, p := range providers {
go func() {
select {
case <-start:
case <-ctx.Done():
done <- outcome{err: ctx.Err()}
return
}
results, err := p.Up(ctx)
done <- outcome{results, err}
}()
}
close(start)
var applied []int64
for range workers {
result := <-done
if result.err != nil {
t.Error(result.err)
}
for _, r := range result.results {
applied = append(applied, r.Source.Version)
}
}
slices.Sort(applied)
if want := []int64{3, 7, 20261026093000, 20261027114500}; !slices.Equal(applied, want) {
t.Fatalf("concurrent Up applied = %v; want exactly once each: %v", applied, want)
}
assertEvents(t, ctx, open(), []int64{7, 20261026093000, 20261027114500})
for _, p := range providers {
assertState(t, ctx, p, 20261027114500, applied, len(applied))
}
}
func TestConstructor(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
for _, test := range []struct {
name string
db *sql.DB
dialect goose.Dialect
dir string
logger *slog.Logger
migrations []*goose.Migration
contains string
}{
{name: "nil database", dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, contains: "db must not be nil"},
{name: "nil logger", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", contains: "slog logger must not be nil"},
{name: "invalid directory", db: db, dialect: goose.DialectSQLite3, dir: "../migrations", logger: logger, contains: "directory"},
{name: "missing directory", db: db, dialect: goose.DialectSQLite3, dir: "missing", logger: logger, contains: "migrate:"},
{name: "invalid dialect", db: db, dialect: "unknown", dir: "testdata/migrations", logger: logger, contains: "dialect"},
{name: "nil migration", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, migrations: []*goose.Migration{nil}, contains: "must not be nil"},
{name: "duplicate version", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, migrations: []*goose.Migration{goose.NewGoMigration(7, nil, nil)}, contains: "7"},
} {
t.Run(test.name, func(t *testing.T) {
p, err := migrate.New(test.db, test.dialect, fixtures, test.dir, test.logger, test.migrations...)
if err == nil || p != nil || !strings.Contains(err.Error(), test.contains) {
t.Fatalf("New = %v, %v; want error containing %q", p, err, test.contains)
}
})
}
// Construction and validation are offline, even when the database is closed.
if err := db.Close(); err != nil {
t.Fatal(err)
}
if _, err := migrate.New(db, goose.DialectSQLite3, fixtures, "testdata/migrations", logger); err != nil {
t.Fatal(err)
}
}
+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
INSERT INTO migration_events (version) VALUES (7);
-- +goose Down
DROP TABLE migration_events;
+5
View File
@@ -0,0 +1,5 @@
-- +goose Up
INSERT INTO migration_events (version) VALUES (20261026093000);
-- +goose Down
DELETE FROM migration_events WHERE version = 20261026093000;
+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
INSERT INTO migration_events (version) VALUES (20261027114500);
INSERT INTO missing_migration_table (version) VALUES (20261027114500);
-- +goose Down
DELETE FROM migration_events WHERE version = 20261027114500;
+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
INSERT INTO migration_events (version) VALUES (7);
-- +goose Down
DROP TABLE migration_events;
+5
View File
@@ -0,0 +1,5 @@
-- +goose Up
INSERT INTO migration_events (version) VALUES (20261026093000);
-- +goose Down
DELETE FROM migration_events WHERE version = 20261026093000;
+5
View File
@@ -0,0 +1,5 @@
-- +goose Up
INSERT INTO migration_events (version) VALUES (20261027114500);
-- +goose Down
DELETE FROM migration_events WHERE version = 20261027114500;