diff --git a/internal/db/db.go b/internal/db/db.go index b63fcc096..157523e1f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -3,6 +3,7 @@ package db import ( + "context" "database/sql" "embed" "errors" @@ -13,6 +14,7 @@ import ( "sort" "strconv" "strings" + "sync" "syscall" "time" @@ -66,8 +68,63 @@ func Open(dbPath string) (*sql.DB, error) { return db, nil } -// OpenMemory creates an in-memory database for testing. +// OpenMemory creates an in-memory database for testing. Each call returns an +// independent database holding the fully migrated schema. +// +// Running all migrations through modernc's transpiled SQLite costs ~2.5s per +// call under -race, which is paid by hundreds of tests. So the first call +// migrates a template once per process and every call after deserializes a +// copy of it, which is byte-for-byte the state migrate would have produced. func OpenMemory() (*sql.DB, error) { + tmplOnce.Do(func() { tmpl, tmplErr = memoryTemplate() }) + if tmplErr != nil { + return nil, tmplErr + } + db, err := openMemoryConn() + if err != nil { + return nil, err + } + if err := withRawConn(db, func(c memConn) error { return c.Deserialize(tmpl) }); err != nil { + db.Close() + return nil, fmt.Errorf("load memory template: %w", err) + } + return db, nil +} + +var ( + tmplOnce sync.Once + tmpl []byte + tmplErr error +) + +// memConn is the part of modernc.org/sqlite's driver connection OpenMemory +// uses to copy the template. +type memConn interface { + Serialize() ([]byte, error) + Deserialize([]byte) error +} + +func memoryTemplate() ([]byte, error) { + db, err := openMemoryConn() + if err != nil { + return nil, err + } + defer db.Close() + if err := migrate(db); err != nil { + return nil, fmt.Errorf("run migrations: %w", err) + } + var buf []byte + err = withRawConn(db, func(c memConn) (err error) { + buf, err = c.Serialize() + return err + }) + if err != nil { + return nil, fmt.Errorf("serialize memory template: %w", err) + } + return buf, nil +} + +func openMemoryConn() (*sql.DB, error) { db, err := sql.Open("sqlite", ":memory:"+connectionPragmaDSN) if err != nil { return nil, fmt.Errorf("open memory database: %w", err) @@ -83,13 +140,22 @@ func OpenMemory() (*sql.DB, error) { db.Close() return nil, err } + return db, nil +} - if err := migrate(db); err != nil { - db.Close() - return nil, fmt.Errorf("run migrations: %w", err) +func withRawConn(db *sql.DB, fn func(memConn) error) error { + conn, err := db.Conn(context.Background()) + if err != nil { + return err } - - return db, nil + defer conn.Close() + return conn.Raw(func(dc any) error { + c, ok := dc.(memConn) + if !ok { + return fmt.Errorf("sqlite driver conn %T cannot serialize", dc) + } + return fn(c) + }) } // preflight validates that the directory containing dbPath exists, is a diff --git a/internal/db/openmemory_test.go b/internal/db/openmemory_test.go new file mode 100644 index 000000000..4c3760c27 --- /dev/null +++ b/internal/db/openmemory_test.go @@ -0,0 +1,113 @@ +package db + +import ( + "database/sql" + "database/sql/driver" + "errors" + "strings" + "testing" +) + +// OpenMemory hands out copies of one migrated template; a write to one copy +// must not show up in the next, and per-connection pragmas must survive the +// copy. +func TestOpenMemory_CopiesAreIndependent(t *testing.T) { + a, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer a.Close() + if _, err := a.Exec(`INSERT INTO settings (key, value) VALUES ('openmemory_probe', 'x')`); err != nil { + t.Fatal(err) + } + + b, err := OpenMemory() + if err != nil { + t.Fatal(err) + } + defer b.Close() + var n int + if err := b.QueryRow(`SELECT COUNT(*) FROM settings WHERE key = 'openmemory_probe'`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("row written to one OpenMemory database leaked into another") + } + var fk int + if err := b.QueryRow(`PRAGMA foreign_keys`).Scan(&fk); err != nil || fk != 1 { + t.Fatalf("foreign_keys = %d (err %v), want 1", fk, err) + } + if got, want := len(versionSetForTest(t, b)), countMigrationFiles(t); got != want { + t.Fatalf("schema_migrations has %d versions, want %d", got, want) + } +} + +func countMigrationFiles(t *testing.T) int { + t.Helper() + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + t.Fatal(err) + } + return len(entries) +} + +// A failed template build is reported by every OpenMemory call, not retried +// into a half-migrated database. +// +// It swaps the package's template for the duration, so it must not call +// t.Parallel: Go holds parallel tests back until every sequential one has +// finished, which is what keeps the swap invisible to them. +func TestOpenMemory_TemplateErrorIsReturned(t *testing.T) { + db, err := OpenMemory() // the template is built by now + if err != nil { + t.Fatal(err) + } + db.Close() + saved, savedErr := tmpl, tmplErr + t.Cleanup(func() { tmpl, tmplErr = saved, savedErr }) + tmplErr = errors.New("migration failed") + if db, err := OpenMemory(); err == nil || db != nil { + t.Fatalf("OpenMemory = %v, %v; want the template error", db, err) + } + // A template that cannot be loaded fails the call instead of handing out + // an empty database. + tmpl, tmplErr = nil, nil + if db, err := OpenMemory(); err == nil || db != nil { + t.Fatalf("OpenMemory with an unloadable template = %v, %v; want an error", db, err) + } +} + +func TestWithRawConn_ClosedDB(t *testing.T) { + db, err := openMemoryConn() + if err != nil { + t.Fatal(err) + } + db.Close() + if err := withRawConn(db, func(memConn) error { return nil }); err == nil { + t.Fatal("withRawConn on a closed database succeeded") + } +} + +// A driver connection that cannot serialize is an error, not a panic. +func TestWithRawConn_DriverWithoutSerialize(t *testing.T) { + sql.Register("openmemory-plain", plainDriver{}) + db, err := sql.Open("openmemory-plain", "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + err = withRawConn(db, func(memConn) error { t.Fatal("fn called for a driver without Serialize"); return nil }) + if err == nil || !strings.Contains(err.Error(), "cannot serialize") { + t.Fatalf("err = %v, want a cannot-serialize error", err) + } +} + +type plainDriver struct{} + +func (plainDriver) Open(string) (driver.Conn, error) { return plainConn{}, nil } + +type plainConn struct{} + +func (plainConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("not supported") } +func (plainConn) Close() error { return nil } +func (plainConn) Begin() (driver.Tx, error) { return nil, errors.New("not supported") }