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:
@@ -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 {
|
||||
|
||||
@@ -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, ¬Null, &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);
|
||||
Reference in New Issue
Block a user