A migration refused when the record and the files disagree is the property that makes a schema trustworthy months later. The test asserted it without naming the decision, so an audit of which decisions are defended could not see it. novox/hq ADR 0017.
297 lines
9.8 KiB
Go
297 lines
9.8 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// These run against a real PostgreSQL. Not a fake, and for the reason novox/hq ADR 0017 gives
|
|
// where the host tests the real filesystem: what is being tested here *is* the database's
|
|
// behaviour — that DDL is transactional, that an advisory lock serialises, that a checksum
|
|
// mismatch is caught against a record the database actually kept. A fake would assert that the
|
|
// fake behaves as expected.
|
|
//
|
|
// `make check` raises one. Without it these skip, and say so rather than passing.
|
|
|
|
func admin(t *testing.T) string {
|
|
t.Helper()
|
|
dsn := os.Getenv("MESH_TEST_POSTGRES")
|
|
if dsn == "" {
|
|
t.Skip("no MESH_TEST_POSTGRES; run `make check` to raise one")
|
|
}
|
|
return dsn
|
|
}
|
|
|
|
// freshStore gives a test its own empty database.
|
|
//
|
|
// Its own, rather than a shared one cleaned between tests: these tests are about what a migration
|
|
// runner does to a schema, and a leftover table from a previous test is indistinguishable from
|
|
// the bug this whole package exists to catch.
|
|
func freshStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
dsn := admin(t)
|
|
name := fmt.Sprintf("test_%s_%d", strings.ToLower(strings.NewReplacer(
|
|
"/", "_", "-", "_").Replace(t.Name())), time.Now().UnixNano()%1_000_000)
|
|
if len(name) > 60 {
|
|
name = name[:60]
|
|
}
|
|
|
|
conn, err := pgx.Connect(t.Context(), dsn)
|
|
if err != nil {
|
|
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
|
|
}
|
|
if _, err := conn.Exec(t.Context(), "create database "+name); err != nil {
|
|
t.Fatalf("cannot create %s: %v", name, err)
|
|
}
|
|
conn.Close(t.Context())
|
|
|
|
t.Setenv(Variable("testing"), replaceDatabase(dsn, name))
|
|
s, err := Open(t.Context(), "testing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
s.Close()
|
|
c, err := pgx.Connect(context.Background(), dsn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer c.Close(context.Background())
|
|
_, _ = c.Exec(context.Background(), "drop database if exists "+name+" with (force)")
|
|
})
|
|
if err := s.Ready(t.Context(), 20*time.Second); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func replaceDatabase(dsn, name string) string {
|
|
cut := strings.LastIndex(dsn, "/")
|
|
rest := ""
|
|
if q := strings.Index(dsn[cut:], "?"); q >= 0 {
|
|
rest = dsn[cut+q:]
|
|
}
|
|
return dsn[:cut] + "/" + name + rest
|
|
}
|
|
|
|
func TestTheSchemaIsAppliedAndRecorded(t *testing.T) {
|
|
s := freshStore(t)
|
|
migrations := []Migration{{Number: 1, Name: "people", SQL: "create table person (id int)", Checksum: "a"}}
|
|
|
|
done, err := s.Migrate(t.Context(), migrations)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(done) != 1 {
|
|
t.Fatalf("applied %d migrations, expected 1", len(done))
|
|
}
|
|
|
|
// Read back from the system rather than trusting the return value (novox/hq ADR 0018).
|
|
var exists bool
|
|
if err := s.Pool().QueryRow(t.Context(),
|
|
`select exists (select 1 from information_schema.tables where table_name = 'person')`,
|
|
).Scan(&exists); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !exists {
|
|
t.Error("Migrate reported success and the table is not there")
|
|
}
|
|
|
|
applied, err := s.AppliedMigrations(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(applied) != 1 || applied[0].Checksum != "a" {
|
|
t.Errorf("the record says %+v", applied)
|
|
}
|
|
}
|
|
|
|
func TestRunningTwiceChangesNothing(t *testing.T) {
|
|
// The bootstrap runs this, and so does every restart of the control plane. A second run that
|
|
// re-applied the schema would fail on the first `create table`, so a control plane would come
|
|
// up exactly once.
|
|
s := freshStore(t)
|
|
migrations := []Migration{{Number: 1, Name: "people", SQL: "create table person (id int)", Checksum: "a"}}
|
|
|
|
if _, err := s.Migrate(t.Context(), migrations); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
done, err := s.Migrate(t.Context(), migrations)
|
|
if err != nil {
|
|
t.Fatalf("the second run failed: %v", err)
|
|
}
|
|
if len(done) != 0 {
|
|
t.Errorf("the second run applied %d migrations", len(done))
|
|
}
|
|
}
|
|
|
|
func TestAFailedMigrationLeavesNothingBehind(t *testing.T) {
|
|
// Note what this does and does not defend. PostgreSQL wraps a multi-statement simple query in
|
|
// an implicit transaction of its own, so this passes with this package's transaction removed
|
|
// — it was checked, and it did. What it defends is the database and driver behaviour relied
|
|
// on: a driver sending each statement separately would break it, and nothing else would say
|
|
// so. The property that belongs to this code is the next test.
|
|
s := freshStore(t)
|
|
migrations := []Migration{{
|
|
Number: 1, Name: "half", Checksum: "a",
|
|
SQL: `create table kept (id int);
|
|
create table broken (id int) this is not sql;`,
|
|
}}
|
|
|
|
if _, err := s.Migrate(t.Context(), migrations); err == nil {
|
|
t.Fatal("a migration with a syntax error reported success")
|
|
}
|
|
|
|
var tables int
|
|
if err := s.Pool().QueryRow(t.Context(),
|
|
`select count(*) from information_schema.tables where table_name in ('kept','broken')`,
|
|
).Scan(&tables); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tables != 0 {
|
|
t.Errorf("%d table(s) survived a failed migration; it must be all or nothing", tables)
|
|
}
|
|
}
|
|
|
|
func TestASchemaChangeAndItsRecordCommitTogether(t *testing.T) {
|
|
// This is what the explicit transaction is for, and all it is for.
|
|
//
|
|
// Split the change from the row saying it happened, and a schema moves with nothing recording
|
|
// it — so the next run finds the migration outstanding and applies it to a database that
|
|
// already has it. The failure surfaces as a broken migration rather than as a lost record.
|
|
//
|
|
// The real case is the process dying between the two, which a test cannot arrange. Standing
|
|
// in for it: a migration that makes its own record impossible to write.
|
|
s := freshStore(t)
|
|
if _, err := s.AppliedMigrations(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
migrations := []Migration{{
|
|
Number: 1, Name: "hostile", Checksum: "a",
|
|
SQL: "create table kept (id int); drop table migration;",
|
|
}}
|
|
if _, err := s.Migrate(t.Context(), migrations); err == nil {
|
|
t.Fatal("a migration whose record could not be written reported success")
|
|
}
|
|
|
|
var kept, ledger bool
|
|
if err := s.Pool().QueryRow(t.Context(),
|
|
`select exists (select 1 from information_schema.tables where table_name = 'kept'),
|
|
exists (select 1 from information_schema.tables where table_name = 'migration')`,
|
|
).Scan(&kept, &ledger); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if kept {
|
|
t.Error("the schema change survived although nothing recorded it; the next run would " +
|
|
"apply it again, to a database that already has it")
|
|
}
|
|
if !ledger {
|
|
t.Error("the migration record did not come back with the rollback")
|
|
}
|
|
}
|
|
|
|
// Defends novox/hq ADR 0013: a schema change is a numbered migration.
|
|
//
|
|
// The property that makes a schema trustworthy months later is not that migrations ran, but that
|
|
// they refuse to run when the record and the files disagree — an edited migration is a schema
|
|
// nobody can reproduce.
|
|
func TestAChangedMigrationIsRefusedAgainstARealRecord(t *testing.T) {
|
|
// The same refusal as the unit test, against a record PostgreSQL actually kept — which is
|
|
// what the guard protects, and the unit test can only model.
|
|
s := freshStore(t)
|
|
first := []Migration{{Number: 1, Name: "people", SQL: "create table person (id int)", Checksum: "a"}}
|
|
if _, err := s.Migrate(t.Context(), first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
edited := []Migration{{Number: 1, Name: "people", SQL: "create table person (id bigint)", Checksum: "b"}}
|
|
if _, err := s.Migrate(t.Context(), edited); err == nil {
|
|
t.Fatal("a migration edited after it ran was accepted")
|
|
}
|
|
}
|
|
|
|
func TestTwoRunnersDoNotRaceEachOther(t *testing.T) {
|
|
// A restart during a slow migration produces exactly this: two copies of the control plane
|
|
// migrating one database. Without the advisory lock both read an empty record, both decide
|
|
// everything is outstanding, and the second fails on `create table` — which looks like a
|
|
// broken migration rather than a race.
|
|
s := freshStore(t)
|
|
migrations := []Migration{{
|
|
Number: 1, Name: "slow", Checksum: "a",
|
|
SQL: "create table slow (id int); select pg_sleep(0.4);",
|
|
}}
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]error, 2)
|
|
counts := make([]int, 2)
|
|
for i := range results {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
done, err := s.Migrate(context.Background(), migrations)
|
|
results[i], counts[i] = err, len(done)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
for i, err := range results {
|
|
if err != nil {
|
|
t.Errorf("runner %d failed: %v", i, err)
|
|
}
|
|
}
|
|
if counts[0]+counts[1] != 1 {
|
|
t.Errorf("the migration was applied %d times between two runners; exactly one should have "+
|
|
"done the work and the other should have found nothing to do", counts[0]+counts[1])
|
|
}
|
|
}
|
|
|
|
func TestLoadedMigrationsApplyToARealDatabase(t *testing.T) {
|
|
// A migration that parses and does not run is the failure this catches. The unit tests read
|
|
// files and check names; nothing there executes SQL.
|
|
s := freshStore(t)
|
|
migrations, err := LoadMigrations(fstest.MapFS{
|
|
"migrations/0001-first.sql": {Data: []byte("create table a (id int);")},
|
|
"migrations/0002-second.sql": {Data: []byte("alter table a add column b text;")},
|
|
}, "migrations")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
done, err := s.Migrate(t.Context(), migrations)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(done) != 2 || done[0].Number != 1 || done[1].Number != 2 {
|
|
t.Fatalf("applied %+v", done)
|
|
}
|
|
}
|
|
|
|
func TestReadyRefusesADatabaseThatWillNotAnswer(t *testing.T) {
|
|
// Ready is what stands between the bootstrap and a control plane that starts against a
|
|
// database still coming up. It has to give up rather than block for ever, and it has to fail
|
|
// when nothing is there.
|
|
admin(t)
|
|
t.Setenv(Variable("testing"), "postgres://nobody@127.0.0.1:1/nothing")
|
|
s, err := Open(t.Context(), "testing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
|
|
start := time.Now()
|
|
if err := s.Ready(t.Context(), 1*time.Second); err == nil {
|
|
t.Fatal("Ready returned success against a port with nothing on it")
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 10*time.Second {
|
|
t.Errorf("Ready took %s to give up on a 1s budget", elapsed)
|
|
}
|
|
}
|