Files
yellowjacket/cmd/indexbuild/staleschema_test.go
T
logan 66182f82cd
Build & publish Arch package / arch-package (push) Successful in 2m34s
CI / check (push) Successful in 2m36s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m32s
fix(indexbuild): repair the one database a squash cannot reach
The index job's /cache volume is a real YJ_HOME that outlives every
run, so plan 013's reshaped audio_files met a database still in the
old shape: `CREATE INDEX ... album_id` against a table without that
column, on every launch. "Delete and rescan" is the squash's answer
and is free everywhere except here, where half the file is the catalog
and deleting it costs ~205GB of downloading.

indexbuild now drops every table datamap does not classify as Cache
before the schema is applied. Nothing scans, plays or authors in that
database, so its non-catalog half is empty by construction and a shape
the schema stopped describing is pure liability; the catalog is never
touched.

TestRetireLibraryTables reproduces the failure symptom-first: build
the real schema, put audio_files back the way the volume had it,
assert the open fails, then assert the repair makes it open with the
catalog row still there.
2026-08-16 15:09:09 -04:00

119 lines
3.0 KiB
Go

//go:build indexbuild
package main
import (
"context"
"database/sql"
"log/slog"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
"yellowjacket/backend/database"
"yellowjacket/backend/system"
)
// TestRetireLibraryTables is the CI failure written down.
//
// The index job's persistent volume held a database from before plan
// 013 reshaped audio_files, so every run died applying an index to a
// column the old table does not have. The catalog in the same file is
// a day of downloading to rebuild and has to survive the repair.
func TestRetireLibraryTables(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
t.Setenv("YJ_HOME", t.TempDir())
dataDir, err := system.GetUserDataDirPath()
if err != nil {
t.Fatalf("resolve data dir: %v", err)
}
dbPath := filepath.Join(dataDir, "yj.db")
// Build the real thing, then put it back into the shape the volume
// was actually in: a pre-013 audio_files, and a table the schema
// stopped describing at all.
if _, err := database.NewDB(logger); err != nil {
t.Fatalf("first open: %v", err)
}
exec(t, dbPath, `
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
VALUES (1, randomblob(16), 'A Catalog Row', 'An Artist', randomblob(16));
DROP TABLE audio_files;
CREATE TABLE audio_files (
id INTEGER PRIMARY KEY,
file_path TEXT NOT NULL UNIQUE,
recording_id INTEGER
);
CREATE TABLE recordings (id INTEGER PRIMARY KEY, title TEXT);
`)
// The symptom, before the repair: the schema cannot be applied over
// a table whose shape has moved on.
if _, err := database.NewDB(logger); err == nil {
t.Fatal("expected the stale shape to fail to open; it did not")
}
if err := retireLibraryTables(context.Background(), logger); err != nil {
t.Fatalf("retireLibraryTables: %v", err)
}
for _, table := range []string{"audio_files", "recordings"} {
if count(t, dbPath, sqliteMasterQuery(table)) != 0 {
t.Errorf("%s survived; the schema cannot be applied over it", table)
}
}
if _, err := database.NewDB(logger); err != nil {
t.Fatalf("open after retiring stale tables: %v", err)
}
// The catalog is why the volume exists.
if got := count(t, dbPath, "SELECT COUNT(*) FROM explore_index"); got != 1 {
t.Errorf("explore_index rows = %d, want 1 (the catalog must survive)", got)
}
}
func sqliteMasterQuery(name string) string {
return `SELECT COUNT(*) FROM sqlite_master WHERE name = '` + name + `'`
}
func exec(t *testing.T, dbPath, statements string) {
t.Helper()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = db.Close() }()
if _, err := db.Exec(statements); err != nil {
t.Fatalf("exec: %v", err)
}
}
func count(t *testing.T, dbPath, query string) int {
t.Helper()
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = db.Close() }()
var n int
if err := db.QueryRow(query).Scan(&n); err != nil {
t.Fatalf("%s: %v", query, err)
}
return n
}