Files
jschoubben 9f5d7a1a83 Name ADR 0013 in the test that defends it
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.
2026-08-31 17:33:34 +02:00

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)
}
}