The fix for the dropped catalog pins one table in one wrong shape, which is the failure that happened. What cost the rebuild was more general: a destructive repair added at `database.NewDB` -- the chokepoint every binary in this project shares -- without asking which binary it runs in. The next one will have a different name and a different reason. So `TestNoCacheTableIsRetiredHere` asserts the outcome instead: put every `datamap` Cache table into a shape the schema has moved past, open the database the way cmd/indexbuild does, and require all of them to still be there. Driving it from `datamap.ByKind` is what makes it cover tables nobody remembered -- flipping the policy back fails on five, including the two artist-credit tables added the same day, where the existing test fails on one. It asserts the rows survive too, because SQLite does an implicit DELETE before a DROP and a repair that recreated the table would look identical. And it accepts an error from `NewDB`, because that is the documented trade: loud is recoverable, gone is not. `scripts/index-cache-snapshot.sh` covers the half no test can reach. The volume holds the only copy of a catalog that costs hours of someone else's bandwidth to re-derive. `VACUUM INTO` rather than `cp`, since a byte copy of a live SQLite file is a corrupt file of plausible size; the resumable staging directory is skipped; and each snapshot is reopened and asked for its catalog row count before anything is rotated out. A corrupt source and an empty catalog were both exercised: each exits non-zero, removes its own output, and leaves the previous snapshots alone. docs/index-cache.md is the restore, and the reason to bother: a restored snapshot resolves to `refresh` and folds in the listens since, which is minutes against the 3-23h this rebuild has been estimating.
312 lines
10 KiB
Go
312 lines
10 KiB
Go
//go:build indexbuild
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"yellowjacket/backend/database"
|
|
"yellowjacket/backend/datamap"
|
|
"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);
|
|
`)
|
|
|
|
// This used to assert the symptom -- that the schema cannot be
|
|
// applied over a table whose shape has moved on -- because at the
|
|
// time nothing repaired it and only this job did. The app-side
|
|
// repair (backend/database/staleshape.go) now retires a stale
|
|
// non-authored table before applySchema meets it, so opening
|
|
// succeeds and the symptom no longer reproduces from here.
|
|
//
|
|
// That does not make retireLibraryTables redundant, and the rest of
|
|
// this test is why: the app-side repair only removes what is *stale*,
|
|
// while this database wants its library half gone entirely, healthy
|
|
// or not, because nothing here scans, plays or authors.
|
|
if _, err := database.NewDB(logger); err != nil {
|
|
t.Fatalf("the app-side repair should have opened this: %v", err)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// TestTheCatalogSurvivesAStaleShape is the accident written down.
|
|
//
|
|
// The app repairs a stale Cache table by dropping it: its catalog is
|
|
// downloaded, so a wrong shape costs about a minute of re-fetching and
|
|
// keeping it costs every Explore read. Applied here that rule is
|
|
// catastrophic — this database is what the artifact is *cut from*, so
|
|
// there is nothing to re-fetch and the only way back is the ~205 GB
|
|
// dump stream the /cache volume exists to avoid.
|
|
//
|
|
// It shipped without that distinction and dropped the real CI catalog
|
|
// on the first run:
|
|
//
|
|
// retiring a table ... table=explore_index
|
|
// reason="column entity_type is TEXT, schema declares INTEGER"
|
|
// index maintenance mode=build reason="no completed import yet"
|
|
//
|
|
// The mismatch was real: that database is deliberately kept in the
|
|
// older encoding, which `fix(indexexport): read an index older than the
|
|
// binary` exists to tolerate. So the shape will not match, every run,
|
|
// by design — and the catalog must survive it anyway.
|
|
func TestTheCatalogSurvivesAStaleShape(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")
|
|
|
|
if _, err := database.NewDB(logger); err != nil {
|
|
t.Fatalf("first open: %v", err)
|
|
}
|
|
|
|
// The shape the real index database is in: every current column,
|
|
// but the ids and the entity type still text. That is what the
|
|
// exporter's backward-compatibility fix tolerates, and it is what
|
|
// the repair saw and called stale.
|
|
exec(t, dbPath, `
|
|
DROP TABLE explore_index;
|
|
CREATE TABLE explore_index (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
entity_type TEXT NOT NULL,
|
|
mbid TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
artist_name TEXT NOT NULL,
|
|
artist_mbid TEXT NOT NULL,
|
|
aliases TEXT NOT NULL DEFAULT '',
|
|
popularity INTEGER NOT NULL DEFAULT 0,
|
|
listener_count INTEGER NOT NULL DEFAULT 0,
|
|
duration INTEGER NOT NULL DEFAULT 0,
|
|
caa_release_mbid TEXT NOT NULL DEFAULT '',
|
|
release_name TEXT NOT NULL DEFAULT '',
|
|
primary_type TEXT NOT NULL DEFAULT '',
|
|
secondary_types TEXT NOT NULL DEFAULT '',
|
|
release_date TEXT NOT NULL DEFAULT '',
|
|
total_tracks INTEGER NOT NULL DEFAULT 0,
|
|
artist_type TEXT NOT NULL DEFAULT '',
|
|
country TEXT NOT NULL DEFAULT '',
|
|
disambiguation TEXT NOT NULL DEFAULT '',
|
|
sort_name TEXT NOT NULL DEFAULT '',
|
|
in_library INTEGER NOT NULL DEFAULT 0,
|
|
is_similar INTEGER NOT NULL DEFAULT 0,
|
|
local_artist_id INTEGER,
|
|
local_release_group_id INTEGER,
|
|
local_recording_id INTEGER,
|
|
discog_fetched INTEGER NOT NULL DEFAULT 0,
|
|
UNIQUE(mbid)
|
|
);
|
|
INSERT INTO explore_index
|
|
(entity_type, mbid, title, artist_name, artist_mbid)
|
|
VALUES ('artist', 'a-b-c', 'A Catalog Row', 'An Artist', 'd-e-f');
|
|
`)
|
|
|
|
if _, err := database.NewDB(logger); err != nil {
|
|
t.Fatalf("open with a stale catalog shape: %v", err)
|
|
}
|
|
|
|
if got := count(t, dbPath, "SELECT COUNT(*) FROM explore_index"); got != 1 {
|
|
t.Fatalf(
|
|
"explore_index rows = %d, want 1 — the catalog was retired, "+
|
|
"which costs this database a ~205GB rebuild",
|
|
got,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestNoCacheTableIsRetiredHere is the general form of the accident
|
|
// above, and it exists because the specific one is not the risk.
|
|
//
|
|
// `TestTheCatalogSurvivesAStaleShape` pins one table in one wrong shape,
|
|
// which is the failure that happened. What cost the ~205 GB was not that
|
|
// shape: it was a destructive repair added to `database.NewDB` -- the
|
|
// one chokepoint every binary in this project shares -- without asking
|
|
// which binary it was running in. The next such repair will have a
|
|
// different name and a different reason, and this database still cannot
|
|
// afford it.
|
|
//
|
|
// So the assertion is about the *outcome* rather than the mechanism: put
|
|
// every Cache table in a shape the schema has certainly moved past, open
|
|
// the database the way cmd/indexbuild does, and require that all of them
|
|
// are still there afterwards. Any future repair that drops one fails
|
|
// here regardless of how it decides to.
|
|
//
|
|
// Two things about it are deliberate.
|
|
//
|
|
// The table list comes from `datamap.ByKind(Cache)` rather than being
|
|
// written out, so a Cache table added next year is covered by this test
|
|
// on the day it is added -- the same reason `TestCatalogCoversSchema`
|
|
// reads the schema instead of a list.
|
|
//
|
|
// And `NewDB` returning an error is *accepted*, because that is the
|
|
// trade the fix documents: with Cache tables no longer rebuilt here, a
|
|
// shape the schema moved past now fails this job loudly instead of
|
|
// silently costing it a day of downloading. Loud is fine. Gone is not.
|
|
func TestNoCacheTableIsRetiredHere(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")
|
|
|
|
if _, err := database.NewDB(logger); err != nil {
|
|
t.Fatalf("first open: %v", err)
|
|
}
|
|
|
|
// An FTS table is four shadow tables and cannot be given a "wrong
|
|
// shape" meaningfully; the repair skips them for the same reason and
|
|
// retires them with their parent, which the parents below cover.
|
|
var cache []string
|
|
|
|
for _, table := range datamap.ByKind(datamap.Cache) {
|
|
if table.FTS {
|
|
continue
|
|
}
|
|
|
|
cache = append(cache, table.Name)
|
|
}
|
|
|
|
if len(cache) == 0 {
|
|
t.Fatal("no Cache tables to check: the datamap or this test is wrong")
|
|
}
|
|
|
|
for _, name := range cache {
|
|
// A shape nothing in the current schema describes. What matters
|
|
// is only that it disagrees; the real mismatch was one column's
|
|
// type.
|
|
exec(t, dbPath, `
|
|
DROP TABLE IF EXISTS `+name+`;
|
|
CREATE TABLE `+name+` (id INTEGER PRIMARY KEY, moved_past TEXT);
|
|
INSERT INTO `+name+` (moved_past) VALUES ('irreplaceable');
|
|
`)
|
|
}
|
|
|
|
// The error is not the assertion: see the note above.
|
|
_, _ = database.NewDB(logger)
|
|
|
|
for _, name := range cache {
|
|
rows := count(t, dbPath,
|
|
`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = '`+name+`'`)
|
|
if rows == 0 {
|
|
t.Errorf("%s was retired: in this database a Cache table is derived, "+
|
|
"not downloaded, and dropping one costs the ~205 GB dump stream", name)
|
|
|
|
continue
|
|
}
|
|
|
|
// Present but emptied is the same loss wearing a different
|
|
// shape: SQLite does an implicit DELETE before a DROP, and a
|
|
// repair that recreated the table would look identical here.
|
|
if n := count(t, dbPath, `SELECT count(*) FROM `+name); n == 0 {
|
|
t.Errorf("%s survived but was emptied", name)
|
|
}
|
|
}
|
|
}
|