navidrome/db/repair.go
Deluan Quintão d0d5403708
feat(cli): add 'doctor' and 'search rebuild' commands to recover from FTS5 corruption (#6069)
* feat(db): add repair command to rebuild a corrupted FTS5 search index

A corrupted media_file_fts index made every scan fail with 'database disk
image is malformed', and sqlite3's built-in 'rebuild' command cannot repair
contentless FTS5 tables, leaving users to hand-drop tables and triggers.

Add 'navidrome db repair': it runs PRAGMA integrity_check, and when the
reported corruption is confined to the FTS5 search tables, drops and
recreates the three tables and their nine triggers and repopulates them
from the base tables (which hold all the data, so nothing is lost). The
result is verified with the FTS5-native 'integrity-check' command, which
reads only the rebuilt indexes instead of re-scanning the whole database
(on a 761MB production copy: ~9s full check, ~1s rebuild, sub-second
verify). A --rebuild flag forces the rebuild even when the check passes,
for silently desynced indexes. The rebuild refuses to run while migrations
are pending, and a schema-comparison test guards the duplicated DDL against
drifting from the migration.

The DbPath existence check and the YES confirmation prompt, previously
copy-pasted across the backup commands, are extracted into shared cmd
helpers used by both backup and repair.

Part of #6067

* fix(db): type the FTS migration version as int64 for 32-bit builds

The untyped constant defaults to int, which overflows on arm/v7 and 386.

* feat(db): split repair into 'db doctor' and 'search rebuild' commands

A single 'db repair' command promised more than it delivered: the only thing
it could actually repair was the search index, and its diagnosis and its fix
were welded together, so a forced rebuild paid the full integrity check twice.

Split it: 'navidrome db doctor' is strictly read-only, runs both PRAGMA
integrity_check and PRAGMA foreign_key_check, and routes the user (to
'search rebuild' when corruption is FTS-only, to backup/.recover otherwise).
'navidrome search rebuild' just rebuilds and verifies the FTS index, which
takes ~2s on a prod-size library instead of ~19s.

* refactor(cmd): extract a testable doctor function and bound foreign key output

Extract the doctor routing (check, classify, advise) into a function that
takes an io.Writer, so the advice paths are unit-tested and the process exit
happens in the cobra wrapper after the DB is closed (os.Exit was skipping the
deferred close, leaving WAL/SHM files behind on the unhealthy paths).

Aggregate foreign_key_check by (table, parent): the raw pragma emits one row
per orphan, which is unbounded output on a large corrupted library. Also
make confirmYES take an io.Reader, drop the unused return from the renamed
requireExistingDB, share the FTS table list with the tests, and stop the
schema-guard specs from paying for a seeded database they never use.

* docs(cmd): promise 'never alters your data' instead of 'never modifies the database'

Closing the doctor's connection can checkpoint a stale WAL into the main
file (as any SQLite tool does), so the byte-level claim was too strong. The
checks themselves are read-only and no logical content ever changes.

* fix(cmd): make 'db doctor' advice honest when checks are inconclusive

PRAGMA integrity_check stops at 100 errors and emits no marker row, so a
saturated result was being read as the whole picture. IntegrityCheck now sets
the limit itself and reports saturation as a truncated list, and doctor no
longer claims corruption is limited to the search index in that case.

Foreign key violations now print a next step instead of only flipping the
exit code: migrations run with foreign_keys off, so orphan rows are a
realistic leftover on a database that is not corrupt.

Also corrects the 'search rebuild' help, which promised that 'db doctor'
detects when a rebuild is needed -- integrity_check cannot see an index that
is merely out of sync; gives the never-migrated case its intended message
instead of a raw 'no such table: goose_db_version'; and extracts
rebuildSearchIndex so the database is closed before log.Fatal exits.

* refactor(cmd): promote 'db doctor' to a top-level 'doctor' command

The 'db' group held a single subcommand, and the checks planned for it reach
past the database: config, music folder permissions, external tools. None of
those belong under 'db'.

Promoting it also evens out the shape of the pair. The command that finds the
problem is now top-level alongside 'search rebuild', the command that fixes
it, matching the 'brew doctor' convention users already expect.

'db doctor' has never been released, so no alias or deprecation is needed.

* refactor(db): tighten the doctor and search rebuild internals

Follow-up cleanup with no behaviour change except where noted.

integrity_check now asks the pragma for one row beyond the reported limit and
treats that extra row as the proof it truncated, instead of inferring truncation
from a saturated count. That distinguishes a list of exactly 100 issues from one
that was cut short -- the old test could not, and 100 was SQLite's own default,
so passing it was a no-op.

ForeignKeyCheck returns []FKViolation instead of pre-formatted English, moving
the prose to the layer that already owns the CLI vocabulary. The goose table
probe shared with isSchemaEmpty becomes hasGooseTable, so 'has this database
ever been migrated' has one spelling. Also folds ftsMigrationApplied into
requireFTSMigration, lifts printFindings out of a closure that captured nothing,
names the FTS trigger suffixes once, and corrects the ftsSchemaDDL comment: the
drift test compares against the full migration chain, not the single frozen
migration it claimed.

* fix(db): verify the rebuilt search index before committing it

RebuildFTS committed its transaction and only then ran the FTS5 integrity
check, from the caller. A rebuild that produced a bad index was therefore
already persisted by the time anyone noticed, leaving the user worse off than
before they ran the command.

The check now runs inside the transaction, so a rebuild that does not verify
rolls back and leaves the original index in place. VerifyFTS keeps its *sql.DB
signature for callers outside a transaction; the shared body takes the small
execer interface that both *sql.DB and *sql.Tx satisfy.

Adds a spec for the rollback: it removes a column the repopulating SELECT
reads, so the transaction fails after the drops, and asserts the old index
still answers queries.

* refactor(cmd): drop the unused io.Reader parameter from confirmYES

The reader was added as a test seam that no test ever used: all three callers
pass os.Stdin. Back to fmt.Scanln, which drops the parameter and the now-unused
os import from backup.go and search.go.

* fix(cmd): stop promising a scan clears every foreign key violation

doctor told the user to run 'navidrome scan -f' for any foreign key
violation. SQLStore.GC only purges albums, artists, folders, annotations,
bookmarks, tags and playlist tracks, so orphans elsewhere survive it and the
next doctor run still reports them. player.user_id references user(id) and no
scan phase touches that table at all.

The advice now says a scan clears some of them and the rest have to be removed
by hand, which keeps the next step the earlier round asked for without claiming
a cleanup that does not happen.

* docs(db): trim over-long comments on the doctor and rebuild paths

Six comments ran past two lines or repeated something already stated nearby.
The RebuildFTS doc claimed the rebuild rolls back on a column mismatch, which
the new 'verifies before committing' sentence already implies, and a spec
comment restated that same rationale a second time.

* docs: drop em dashes from the comments added in this branch
2026-09-11 22:26:28 -04:00

367 lines
14 KiB
Go

package db
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"strings"
)
var ftsTables = []string{"media_file_fts", "album_fts", "artist_fts"}
var ftsTriggerSuffixes = []string{"_ai", "_ad", "_au"}
// integrityCheckMaxIssues bounds the problems reported; IntegrityCheck asks the
// pragma for one extra row, because it truncates without emitting any marker.
const integrityCheckMaxIssues = 100
// IntegrityCheck runs PRAGMA integrity_check and returns the problems it reports, or
// an empty slice when healthy. The second value marks a list that was cut short.
func IntegrityCheck(ctx context.Context, database *sql.DB) ([]string, bool, error) {
rows, err := database.QueryContext(ctx,
fmt.Sprintf("PRAGMA integrity_check(%d)", integrityCheckMaxIssues+1))
if err != nil {
return nil, false, fmt.Errorf("running integrity_check: %w", err)
}
defer rows.Close()
var issues []string
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
return nil, false, fmt.Errorf("reading integrity_check results: %w", err)
}
issues = append(issues, line)
}
if err := rows.Err(); err != nil {
return nil, false, fmt.Errorf("reading integrity_check results: %w", err)
}
if len(issues) == 1 && issues[0] == "ok" {
return nil, false, nil
}
if len(issues) > integrityCheckMaxIssues {
return issues[:integrityCheckMaxIssues], true, nil
}
return issues, false, nil
}
// FKViolation counts the rows in Table that reference missing rows in Parent.
type FKViolation struct {
Table string
Parent string
Count int64
}
// ForeignKeyCheck runs PRAGMA foreign_key_check, aggregated per (table, parent) pair
// because the raw pragma emits one row per orphan, unbounded on a large library.
func ForeignKeyCheck(ctx context.Context, database *sql.DB) ([]FKViolation, error) {
rows, err := database.QueryContext(ctx,
`SELECT "table", "parent", count(*) FROM pragma_foreign_key_check GROUP BY "table", "parent"`)
if err != nil {
return nil, fmt.Errorf("running foreign_key_check: %w", err)
}
defer rows.Close()
var violations []FKViolation
for rows.Next() {
var v FKViolation
if err := rows.Scan(&v.Table, &v.Parent, &v.Count); err != nil {
return nil, fmt.Errorf("reading foreign_key_check results: %w", err)
}
violations = append(violations, v)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("reading foreign_key_check results: %w", err)
}
return violations, nil
}
// IsFTSCorruptionOnly reports whether every integrity issue refers to one of the
// FTS5 search tables, meaning RebuildFTS can fully repair the database.
func IsFTSCorruptionOnly(issues []string) bool {
if len(issues) == 0 {
return false
}
for _, line := range issues {
if !slices.ContainsFunc(ftsTables, func(table string) bool { return strings.Contains(line, table) }) {
return false
}
}
return true
}
// execer is the subset of *sql.DB and *sql.Tx that verifyFTS needs.
type execer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// VerifyFTS runs the FTS5 'integrity-check' command on each search table. Unlike a
// full PRAGMA integrity_check, it reads only the FTS indexes, not the whole database.
func VerifyFTS(ctx context.Context, database *sql.DB) error {
return verifyFTS(ctx, database)
}
func verifyFTS(ctx context.Context, database execer) error {
for _, table := range ftsTables {
stmt := fmt.Sprintf("INSERT INTO %[1]s(%[1]s) VALUES('integrity-check')", table) //nolint:gosec // fixed table list
if _, err := database.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("verifying %s: %w", table, err)
}
}
return nil
}
const ftsSearchMigration int64 = 20260220173400
var errNotMigrated = errors.New("the FTS search migration has not been applied yet; start Navidrome once to migrate the database first")
// requireFTSMigration fails unless the FTS search migration has run. The goose table
// is probed separately because a query against a missing table fails at prepare time.
func requireFTSMigration(ctx context.Context, database *sql.DB) error {
migrated, err := hasGooseTable(ctx, database)
if err != nil {
return fmt.Errorf("checking FTS migration status: %w", err)
}
if !migrated {
return errNotMigrated
}
var applied int
if err := database.QueryRowContext(ctx,
"SELECT count(*) FROM goose_db_version WHERE version_id = ?", ftsSearchMigration).Scan(&applied); err != nil {
return fmt.Errorf("checking FTS migration status: %w", err)
}
if applied == 0 {
return errNotMigrated
}
return nil
}
// RebuildFTS drops the FTS5 search tables and their triggers, recreates them from the
// base tables, and verifies the result before committing. The tables are contentless,
// so no user data is lost. It needs only the FTS migration, not a fully migrated
// schema, because a corrupted DB often cannot run pending migrations.
func RebuildFTS(ctx context.Context, database *sql.DB) error {
if err := requireFTSMigration(ctx, database); err != nil {
return err
}
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("starting FTS rebuild transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
var stmts []string
for _, table := range ftsTables {
for _, suffix := range ftsTriggerSuffixes {
stmts = append(stmts, "DROP TRIGGER IF EXISTS "+table+suffix)
}
stmts = append(stmts, "DROP TABLE IF EXISTS "+table)
}
stmts = append(stmts, ftsSchemaDDL...)
for _, stmt := range stmts {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("rebuilding FTS schema: %w", err)
}
}
if err := verifyFTS(ctx, tx); err != nil {
return fmt.Errorf("the rebuilt search index did not verify: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing FTS rebuild: %w", err)
}
return nil
}
// ftsSchemaDDL must reproduce what the full migration chain produces, not what any
// single migration does; the schema comparison in repair_test.go guards the drift.
var ftsSchemaDDL = []string{
`
CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5(
title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`,
`
CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5(
name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`,
`
CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5(
name, sort_artist_name, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`,
`
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
SELECT rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''),
COALESCE(search_normalized, '')
FROM media_file
`,
`
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''),
COALESCE(search_participants, ''), COALESCE(discs, ''),
COALESCE(catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(album.tags, '$.albumversion') AS je), ''),
COALESCE(search_normalized, '')
FROM album
`,
`
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '')
FROM artist
`,
`
CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
COALESCE(OLD.search_normalized, ''));
END
`,
`
CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file
WHEN
OLD.title IS NOT NEW.title OR
OLD.album IS NOT NEW.album OR
OLD.artist IS NOT NEW.artist OR
OLD.album_artist IS NOT NEW.album_artist OR
OLD.sort_title IS NOT NEW.sort_title OR
OLD.sort_album_name IS NOT NEW.sort_album_name OR
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR
OLD.disc_subtitle IS NOT NEW.disc_subtitle OR
OLD.search_participants IS NOT NEW.search_participants OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
COALESCE(NEW.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
COALESCE(OLD.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
COALESCE(OLD.search_normalized, ''));
END
`,
`
CREATE TRIGGER album_fts_au AFTER UPDATE ON album
WHEN
OLD.name IS NOT NEW.name OR
OLD.sort_album_name IS NOT NEW.sort_album_name OR
OLD.album_artist IS NOT NEW.album_artist OR
OLD.search_participants IS NOT NEW.search_participants OR
OLD.discs IS NOT NEW.discs OR
OLD.catalog_num IS NOT NEW.catalog_num OR
OLD.tags IS NOT NEW.tags OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
COALESCE(OLD.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
COALESCE(NEW.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
COALESCE(OLD.search_normalized, ''));
END
`,
`
CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist
WHEN
OLD.name IS NOT NEW.name OR
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
}