perf(S01/T01): Add token-bucket rate limiter (1 req/sec), SQLite respon…

- backend/explore/ratelimiter.go
- backend/explore/cache.go
- backend/database/sql/schemas/explore_cache.sql
- backend/database/database.go
This commit is contained in:
2026-03-23 07:55:25 -04:00
parent 398fd5aaae
commit 8fc075c24a
9 changed files with 652 additions and 1 deletions
+73 -1
View File
@@ -349,6 +349,16 @@ func runMigrations(
}
}
// Migration 11: explore_cache table for MusicBrainz/ListenBrainz
// API response caching with TTL expiry and MBID lookups.
if version < 11 {
if err := migration11ExploreCache(
ctx, db, logger,
); err != nil {
return err
}
}
return nil
}
@@ -1222,7 +1232,7 @@ func migration9SmartPlaylists(
// migration10PlayHistory adds play history tracking:
// - play_history table for timestamped play log
// - play_count and last_played columns on audio_files
// - Recreates track_metadata VIEW to expose the new columns
// - Recreates track_metadata VIEW to expose the new columns.
func migration10PlayHistory(
ctx context.Context,
db *sql.DB,
@@ -1352,6 +1362,68 @@ func migration10PlayHistory(
return nil
}
// migration11ExploreCache creates the explore_cache table for
// MusicBrainz and ListenBrainz API response caching. The table
// stores raw JSON keyed by URL with TTL-based expiry and optional
// MBID columns for future autotagging lookups.
func migration11ExploreCache(
ctx context.Context,
db *sql.DB,
logger *slog.Logger,
) error {
logger.Info(
"applying migration 11: explore_cache table",
)
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS explore_cache (
url_key TEXT PRIMARY KEY,
response TEXT NOT NULL,
mbid TEXT,
entity_type TEXT,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
return fmt.Errorf(
"migration 11: could not create explore_cache table: %w",
err,
)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_explore_cache_expires
ON explore_cache(expires_at)
`); err != nil {
return fmt.Errorf(
"migration 11: could not create expires index: %w",
err,
)
}
if _, err := db.ExecContext(ctx, `
CREATE INDEX IF NOT EXISTS idx_explore_cache_mbid
ON explore_cache(mbid)
`); err != nil {
return fmt.Errorf(
"migration 11: could not create mbid index: %w",
err,
)
}
if _, err := db.ExecContext(
ctx, "PRAGMA user_version = 11",
); err != nil {
return fmt.Errorf(
"could not set user_version to 11: %w", err,
)
}
logger.Info("migration 11 complete")
return nil
}
// readLibraryDirFromTOML reads the TOML config file and returns
// the Library.DirectoryPath value, or "" if not configured.
func readLibraryDirFromTOML(logger *slog.Logger) string {
+191
View File
@@ -1041,3 +1041,194 @@ func TestMigration10PlayHistory(t *testing.T) {
t.Errorf("track_metadata play_count = %d, want 1", viewPlayCount)
}
}
// ---------------------------------------------------------------------------
// Migration 11 — explore_cache table
// ---------------------------------------------------------------------------
func TestMigration11ExploreCache(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
// Verify user_version >= 11.
var version int
verRows, err := db.QueryContext("PRAGMA user_version")
if err != nil {
t.Fatalf("PRAGMA user_version: %v", err)
}
if !verRows.Next() {
_ = verRows.Close()
t.Fatal("PRAGMA user_version: no row returned")
}
if err := verRows.Scan(&version); err != nil {
_ = verRows.Close()
t.Fatalf("scan user_version: %v", err)
}
_ = verRows.Close()
if version < 11 {
t.Errorf("user_version = %d, want >= 11", version)
}
// Verify explore_cache table exists.
var tableCount int64
tblRows, err := db.QueryContext(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='explore_cache'",
)
if err != nil {
t.Fatalf("query sqlite_master: %v", err)
}
if !tblRows.Next() {
_ = tblRows.Close()
t.Fatal("no row from sqlite_master query")
}
if err := tblRows.Scan(&tableCount); err != nil {
_ = tblRows.Close()
t.Fatalf("scan table count: %v", err)
}
_ = tblRows.Close()
if tableCount != 1 {
t.Errorf("explore_cache table count = %d, want 1", tableCount)
}
// Verify all expected columns exist.
expectedCols := map[string]bool{
"url_key": false,
"response": false,
"mbid": false,
"entity_type": false,
"expires_at": false,
"created_at": false,
}
colRows, err := db.QueryContext(
"PRAGMA table_info(explore_cache)",
)
if err != nil {
t.Fatalf("PRAGMA table_info(explore_cache): %v", err)
}
for colRows.Next() {
var (
cid int64
name string
colType string
notNull int64
dfltValue sql.NullString
pk int64
)
if err := colRows.Scan(
&cid, &name, &colType, &notNull, &dfltValue, &pk,
); err != nil {
_ = colRows.Close()
t.Fatalf("scan table_info row: %v", err)
}
if _, ok := expectedCols[name]; ok {
expectedCols[name] = true
}
}
_ = colRows.Close()
for col, found := range expectedCols {
if !found {
t.Errorf("explore_cache missing column: %s", col)
}
}
// Verify indexes exist.
idxRows, err := db.QueryContext(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='explore_cache'",
)
if err != nil {
t.Fatalf("query indexes: %v", err)
}
indexes := map[string]bool{}
for idxRows.Next() {
var name string
if err := idxRows.Scan(&name); err != nil {
_ = idxRows.Close()
t.Fatalf("scan index name: %v", err)
}
indexes[name] = true
}
_ = idxRows.Close()
if !indexes["idx_explore_cache_expires"] {
t.Error("missing index: idx_explore_cache_expires")
}
if !indexes["idx_explore_cache_mbid"] {
t.Error("missing index: idx_explore_cache_mbid")
}
// Round-trip: insert and read back.
_, err = db.ExecContext(
`INSERT INTO explore_cache (url_key, response, mbid, entity_type, expires_at)
VALUES ('test-key', '{"data":"value"}', 'abc-123', 'artist', datetime('now', '+1 hour'))`,
)
if err != nil {
t.Fatalf("insert explore_cache: %v", err)
}
rows, err := db.QueryContext(
"SELECT url_key, response, mbid, entity_type FROM explore_cache WHERE url_key = 'test-key'",
)
if err != nil {
t.Fatalf("query explore_cache: %v", err)
}
if !rows.Next() {
_ = rows.Close()
t.Fatal("explore_cache row not found")
}
var (
urlKey string
response string
mbid sql.NullString
entityType sql.NullString
)
if err := rows.Scan(&urlKey, &response, &mbid, &entityType); err != nil {
_ = rows.Close()
t.Fatalf("scan explore_cache row: %v", err)
}
_ = rows.Close()
if urlKey != "test-key" {
t.Errorf("url_key = %q, want %q", urlKey, "test-key")
}
if response != `{"data":"value"}` {
t.Errorf("response = %q, want %q", response, `{"data":"value"}`)
}
if !mbid.Valid || mbid.String != "abc-123" {
t.Errorf("mbid = %v, want abc-123", mbid)
}
if !entityType.Valid || entityType.String != "artist" {
t.Errorf("entity_type = %v, want artist", entityType)
}
}
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS explore_cache (
url_key TEXT PRIMARY KEY,
response TEXT NOT NULL,
mbid TEXT,
entity_type TEXT,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_explore_cache_expires ON explore_cache(expires_at);
CREATE INDEX IF NOT EXISTS idx_explore_cache_mbid ON explore_cache(mbid);