Files
yellowjacket/backend/datamap/datamap.go
T
yonluandClaude Opus 5 20fbf28f2a perf(explore): make the owned-artist backfill yield, mark, and stop
The post-scan backfills share MusicBrainz's rate limiters with every
page the user can open, and both were FIFO — so a thousand-artist
enrichment put an album page behind an hour of queued work.
WithBackgroundLane/WithBackgroundPriority add a slower second lane: a
marked wait takes no token while any interactive wait is outstanding.
It is a context marker rather than a parameter because a backfill calls
the same client methods a detail page does. A long backfill also has to
be visible and stoppable, so jobs.KindCatalogEnrich registers both with
progress and cancel — after the work is counted, since these passes are
a no-op on every launch once the library is covered.

What it does not fetch is the point. It ran for hours against a
900-artist library and marked nothing, because three of the four things
it did per artist were work nobody asked for: similar artists, which
the artist page already resolves on view, and a full GetArtistImage
(fanart.tv, TheAudioDB, Wikidata, Wikipedia, ten portraits) reached
only to warm the MB artist lookup EnsureArtistRels does alone. It was
also serial across artists while every limiter is per-host and idle.

The marks are a table rather than more explore_index columns, because
artifactimport merges by column list and a flag added there is a second
place to remember. BrowseReleaseGroupsAll pages to exhaustion, where
the old call silently cut a prolific artist at 100 release groups.

One portrait is downloaded now; the rest are remembered as URLs.
resolveAllSources downloaded every candidate, up to ten, full size,
while nothing reads anything but primary.jpg — 5.3 GB measured on a
real cache, 4.1 GB of it unreachable. OrphanedArtistImagesJob is why
that survived: it joined the bare MBID onto the images directory, but
artist directories are sharded under a two-character prefix, so it
named a path that never existed and deleted the rows that were the only
record of the files it left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 13:33:54 -04:00

436 lines
15 KiB
Go

// Package datamap is the catalog of everything YellowJacket persists.
//
// It exists because deletion logic used to be written per call site and
// lived far from the thing being deleted. Nobody adding a table could
// know the full set of places that needed updating, so tables leaked
// (rows nothing ever removed), files leaked (thumbnails whose database
// row was gone), and in one case a new table's foreign key silently made
// libraries unremovable.
//
// Every table, view, and on-disk asset directory is classified along two
// axes that between them determine every policy worth having:
//
// - Kind — what it costs to lose the data.
// - Lifetime — how rows or files are removed.
//
// The catalog is plain data with no dependency on any service, so tests
// can assert it against a live schema. The rule that gives it teeth:
// every table in the database must be claimed by exactly one entry here,
// enforced by TestCatalogCoversSchema. A new table fails the build until
// somebody states what it is and how it dies.
package datamap
import "strings"
// Kind classifies persisted data by what losing it costs.
type Kind string
const (
// Owned data is a projection of the user's audio files. The files
// on disk are the source of truth; a rescan rebuilds all of it.
Owned Kind = "owned"
// Authored data was created by the user and exists nowhere else.
// Losing it is unrecoverable data loss — it must never be deleted
// as a side effect of anything.
Authored Kind = "authored"
// Derived data is computed from owned data and is cheap to rebuild.
// It may be deleted freely and must never block deletion of the
// data it was derived from.
Derived Kind = "derived"
// Cache data came from the network or a MusicBrainz dump. It is
// rebuildable but expensive — rate limits, or a multi-hour index
// build — so it is evicted on its own schedule rather than being
// tied to the lifetime of anything else.
Cache Kind = "cache"
)
// Lifetime says how rows leave a table. It is declared here and checked
// against the live schema by TestLifetimesMatchSchema, so a foreign key
// that disagrees with the stated policy fails the tests.
type Lifetime string
const (
// Cascade means a foreign key with ON DELETE CASCADE removes rows
// when the parent goes. No application code required.
Cascade Lifetime = "cascade"
// SetNull means a foreign key with ON DELETE SET NULL orphans the
// row deliberately, keeping it alive without its parent.
SetNull Lifetime = "set-null"
// Swept means application code must delete these rows explicitly —
// either in a removal path or in the maintenance janitor. Any table
// with a NO ACTION foreign key must declare this, because such a key
// blocks its parent's deletion until someone clears the child rows.
Swept Lifetime = "swept"
// Retained means rows are never removed automatically. Only an
// explicit user action deletes them.
Retained Lifetime = "retained"
)
// Table is one catalog entry.
type Table struct {
// Name is the SQL table or view name.
Name string
// Kind is what the data costs to lose.
Kind Kind
// Lifetime is how rows are removed.
Lifetime Lifetime
// FTS marks an FTS5 virtual table, which SQLite backs with four
// shadow tables (_config, _data, _docsize, _idx). Those are
// implementation detail and are resolved to this entry.
FTS bool
// Note records why the classification is what it is, particularly
// where a table is not purely one kind.
Note string
}
// ftsShadowSuffixes are the tables SQLite creates behind an FTS5 virtual
// table. They belong to their parent and are never catalogued directly.
var ftsShadowSuffixes = []string{
"_config", "_data", "_docsize", "_idx",
}
// internalTables are SQLite's own bookkeeping, outside our control.
var internalTables = map[string]bool{
"sqlite_sequence": true,
"sqlite_stat1": true,
"sqlite_stat4": true,
}
// tables is the catalog. Keep it alphabetical.
var tables = []Table{
{
Name: "artist_credit", Kind: Owned, Lifetime: Swept,
Note: "Credit strings parsed from file tags. Orphan-swept when " +
"no recording references them.",
},
{
Name: "artist_credit_artist", Kind: Owned, Lifetime: Swept,
Note: "Join table between credits and artists.",
},
{
Name: "artist_enrichment", Kind: Derived, Lifetime: Retained,
Note: "Which catalog enrichment passes have run for an artist. " +
"Derived: losing it re-runs the fetches, which cost time and " +
"someone else's rate limit but no data. Retained because a " +
"stale mark for an artist no longer owned is harmless — the " +
"backfill only ever asks about artists the library has.",
},
{
Name: "artist_images", Kind: Cache, Lifetime: Swept,
Note: "Artist photos from fanart.tv/MusicBrainz. Rows point at " +
"files under the artist-images directory; the janitor sweeps " +
"both together.",
},
{
Name: "artist_metadata", Kind: Cache, Lifetime: Swept,
Note: "Fetched artist bios and metadata. No TTL — swept when the " +
"artist is no longer referenced.",
},
{
Name: "artists", Kind: Owned, Lifetime: Swept,
Note: "Artists parsed from file tags. Orphan-swept.",
},
{
Name: "audio_files", Kind: Owned, Lifetime: Swept,
Note: "MIXED KIND. Mostly an owned projection of files on disk, " +
"but play_count, last_played and tag_status are authored and " +
"exist nowhere else. Deleting a row to rebuild it destroys " +
"that authored state — which is why a file rename currently " +
"loses play counts. See the data architecture plan.",
},
{
Name: "cover_art", Kind: Owned, Lifetime: Swept,
Note: "Extracted embedded artwork. file_path names the original " +
"only; the sized variants beside it are derived filenames and " +
"must be expanded when deleting (see library.coverArtFileSet).",
},
{
Name: "download_downloads", Kind: Authored, Lifetime: Cascade,
Note: "One row per 'go find me this', i.e. one search-and-grab " +
"attempt. Cascades from libraries, and cascades onward to " +
"download_items. Terminal rows are history the user clears " +
"explicitly.",
},
{
Name: "download_items", Kind: Authored, Lifetime: Cascade,
Note: "One grab attempt per row, with the ranked candidate stored " +
"as JSON. Cascades from download_downloads. The candidate blob " +
"is kept rather than re-derived because a provider's result " +
"set is ephemeral — the peer that had the files may be gone, " +
"and the row still has to explain why it was chosen.",
},
{
Name: "download_providers", Kind: Authored, Lifetime: Retained,
Note: "Download clients the user connected. Removed only by the " +
"user. Holds no secrets: API keys live in a 0600 file keyed " +
"by this row's id, so the table can be dumped into a bug " +
"report without redaction.",
},
{
Name: "download_requests", Kind: Authored, Lifetime: Cascade,
Note: "The durable request list: one MBID per row, plus retry " +
"bookkeeping. Cascades from libraries, and from a parent " +
"artist request to the album requests it derived. Unlike " +
"download_downloads these are not history — a request " +
"outlives every download attempt made on it and is only " +
"removed by the user or by the library coming to own what it " +
"names.",
},
{
Name: "explore_champion_fts", Kind: Cache, Lifetime: Retained, FTS: true,
Note: "Full-text index over the champion entities of the " +
"MusicBrainz dump. Rebuilt only by a full index build.",
},
{
Name: "explore_index", Kind: Cache, Lifetime: Retained,
Note: "The offline MusicBrainz search index. Rebuilding costs a " +
"~205GB dump stream, so it is never swept automatically.",
},
{
Name: "explore_index_fts", Kind: Cache, Lifetime: Retained, FTS: true,
Note: "Full-text index over explore_index.",
},
{
Name: "explore_index_meta", Kind: Cache, Lifetime: Retained,
Note: "Build metadata for explore_index: dump version, coverage " +
"tiers, last refresh.",
},
{
Name: "excluded_paths", Kind: Authored, Lifetime: Cascade,
Note: "Paths the user removed from the library, which the scanner " +
"must not import again. Authored: it is a decision, not " +
"derivable from disk. Cascades with its library, and a full " +
"rescan clears it \u2014 the only way back for a path removed by " +
"mistake.",
},
{
Name: "file_types", Kind: Derived, Lifetime: Retained,
Note: "Static lookup rows seeded from code, not user data.",
},
{
Name: "genres", Kind: Owned, Lifetime: Swept,
Note: "Genres parsed from file tags. Orphan-swept.",
},
{
Name: "http_cache", Kind: Cache, Lifetime: Swept,
Note: "Cached HTTP responses with a TTL. Reads filter on " +
"expires_at; the janitor deletes expired rows.",
},
{
Name: "job_state", Kind: Authored, Lifetime: Retained,
Note: "Persisted background-job state, including scans the user " +
"paused. Represents user intent, so it survives restarts.",
},
{
Name: "libraries", Kind: Authored, Lifetime: Retained,
Note: "The directories the user chose. Removed only by explicit " +
"user action via RemoveLibrary.",
},
{
Name: "lyrics_index", Kind: Derived, Lifetime: Retained, FTS: true,
Note: "Full-text index over embedded and fetched lyrics. Rebuilt " +
"from owned files plus the LRCLIB backfill.",
},
{
Name: "play_history", Kind: Authored, Lifetime: Cascade,
Note: "Listening history. Authored, but intentionally cascades " +
"with its track — history for a file no longer in the library " +
"has nothing to point at.",
},
{
Name: "player_state", Kind: Authored, Lifetime: Retained,
Note: "Volume, repeat and shuffle modes, last position.",
},
{
Name: "playlist_tracks", Kind: Authored, Lifetime: SetNull,
Note: "Playlist membership. Deliberately survives its track: " +
"audio_file_id is nulled and phantom_* columns preserve the " +
"entry so a rescan can re-link it.",
},
{
Name: "playlists", Kind: Authored, Lifetime: Retained,
Note: "User-created playlists, including smart playlist rules.",
},
{
Name: "queue", Kind: Authored, Lifetime: Retained,
Note: "The play queue's own state (current position, source).",
},
{
Name: "queue_tracks", Kind: Authored, Lifetime: Cascade,
Note: "Queue entries. Cascade with their track; the queue is " +
"compacted afterwards.",
},
{
Name: "recording_genres", Kind: Owned, Lifetime: Swept,
Note: "Join table between recordings and genres.",
},
{
Name: "recordings", Kind: Owned, Lifetime: Swept,
Note: "Tracks as parsed from file tags. Orphan-swept.",
},
{
Name: "release_group_recordings", Kind: Owned, Lifetime: Swept,
Note: "Join table between release groups and recordings.",
},
{
Name: "release_groups", Kind: Owned, Lifetime: Swept,
Note: "Albums as parsed from file tags. Orphan-swept.",
},
{
Name: "release_to_rg", Kind: Cache, Lifetime: Retained,
Note: "Release to release-group mapping from the dump.",
},
{
Name: "schema_migrations", Kind: Derived, Lifetime: Retained,
Note: "Bookkeeping for sql/migrations: which numbered files have " +
"run. Safe to lose — replaying an already-applied migration " +
"tolerates its ALTER TABLE ADD COLUMN as a no-op and just " +
"re-records it.",
},
{
Name: "search_clicks", Kind: Authored, Lifetime: Retained,
Note: "Which results the user picked, used to rank future " +
"searches. Behavioural but unrecoverable if dropped.",
},
{
Name: "search_index", Kind: Derived, Lifetime: Retained, FTS: true,
Note: "Contentless FTS5 over the library. Individual rows cannot " +
"be deleted, so stale entries are tolerated and filtered by " +
"joining track_metadata; a full rescan rebuilds it.",
},
{
Name: "similar_artist_map", Kind: Cache, Lifetime: Retained,
Note: "Artist similarity edges derived from the dump.",
},
{
Name: "tagging_candidates", Kind: Derived, Lifetime: Cascade,
Note: "Scored MusicBrainz candidates for a tagging group. " +
"Cascades with its tagging_items row.",
},
{
Name: "tagging_items", Kind: Derived, Lifetime: Swept,
Note: "MIXED KIND. The grouping is derived from folder layout, " +
"but status and cleared_at record the user's review " +
"decisions. Its library_id foreign key is NO ACTION, so " +
"RemoveLibrary must delete these rows explicitly or the " +
"library cannot be removed at all.",
},
{
Name: "track_metadata", Kind: Derived, Lifetime: Retained,
Note: "A view joining audio_files to its entity chain. Holds no " +
"rows of its own.",
},
}
// directories are the on-disk asset trees under the user data directory.
// Files there have no foreign keys, so nothing removes them implicitly —
// each needs a sweeper in the janitor.
var directories = []Directory{
{
Name: "covers", Kind: Derived,
Note: "Extracted cover art plus generated _sm/_md/_lg variants. " +
"Live set is cover_art.file_path expanded to its variants.",
},
{
Name: "artist-images", Kind: Cache,
Note: "Per-artist-MBID directories of fetched photos, a " +
"primary.jpg with thumbnails, and a .miss marker for artists " +
"known to have no art. Live set is artist_images.file_path.",
},
{
Name: "cover-art-cache", Kind: Cache,
Note: "Cover Art Archive images fetched for Explore browsing, " +
"keyed by release-group MBID. Not tied to owned data at all.",
},
}
// Directory is an on-disk asset tree owned by the application.
type Directory struct {
// Name is the directory name under the user data directory.
Name string
// Kind is what the files cost to lose.
Kind Kind
// Note records what lives there and how the live set is determined.
Note string
}
// Tables returns the full catalog.
func Tables() []Table {
out := make([]Table, len(tables))
copy(out, tables)
return out
}
// Directories returns the catalogued asset directories.
func Directories() []Directory {
out := make([]Directory, len(directories))
copy(out, directories)
return out
}
// Lookup returns the catalog entry for a table name, resolving FTS
// shadow tables to their parent. Reports false for SQLite's internal
// tables and for anything not catalogued.
func Lookup(name string) (Table, bool) {
if internalTables[name] {
return Table{}, false
}
for _, t := range tables {
if t.Name == name {
return t, true
}
}
if parent, ok := ftsParent(name); ok {
return Lookup(parent)
}
return Table{}, false
}
// IsInternal reports whether a table is SQLite's own bookkeeping.
func IsInternal(name string) bool {
return internalTables[name] || strings.HasPrefix(name, "sqlite_")
}
// ftsParent maps an FTS5 shadow table to the virtual table that owns it.
func ftsParent(name string) (string, bool) {
for _, suffix := range ftsShadowSuffixes {
if !strings.HasSuffix(name, suffix) {
continue
}
parent := strings.TrimSuffix(name, suffix)
for _, t := range tables {
if t.Name == parent && t.FTS {
return parent, true
}
}
}
return "", false
}
// ByKind returns every catalogued table of a given kind.
func ByKind(k Kind) []Table {
var out []Table
for _, t := range tables {
if t.Kind == k {
out = append(out, t)
}
}
return out
}