A read-through cache shared by the web app and the worker so reopening an artist/album (and repeat lookups during scans/discovery) stop re-hitting MusicBrainz. Postgres-only (Redis disregarded — PG is already shared across web + worker); keys on the logical operation so both processes share entries; serve-stale-on-outage; TTL-only invalidation. Four tasks: MbCache table → web read-through → worker CachingMbBrowser → client Map + pruning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.8 KiB
MusicBrainz Response Cache (Postgres) — Implementation Plan
Sketched 2026-07-14. A Postgres-backed read-through cache shared by the web app and the worker, so reopening an artist (discography) or album (tracklist) — and repeat lookups during scans/discovery — stop re-hitting MusicBrainz. Postgres only; Redis explicitly disregarded (Postgres is already running and shared across web(TS) + worker(Python); Next's Data Cache is per-instance and the Python worker can't see it, so a shared store is required and we already have one — no new container/ops for a single-user app). Redis revisited only if profiling later shows PG cache lookups are a bottleneck (won't be at this scale) or pub/sub is wanted.
Context
Both callers hit MB live with no cache and no throttle: web mbGet
(web/src/lib/musicbrainz.ts — bare fetch) and worker _mbbrowser.py
(musicbrainzngs). Reopen an artist → full discography re-fetched; reopen an album →
tracklist re-fetched; search-as-you-type fires a request per keystroke. MB data changes on
the order of days/weeks, so hours-to-days TTLs are safe. Caching also helps stay under MB's
~1 req/s anonymous rate limit (today only the worker scan paces ~1/s; the web side is
unthrottled).
Confirmed decisions
- Postgres, not Redis (rationale above).
- Key on the LOGICAL operation, not the request URL. Web uses raw
ws/2REST paths; the worker usesmusicbrainzngs, which builds its own requests — the two never produce the same URL, so URL-keyed caches would never share. Logical keys let a discography the worker fetched during a scan warm the web modal (and vice versa). Cost: wrap the high-level functions, not the low-level fetch. - TTL applied at read time from
fetchedAt(noexpiresAtcolumn) so TTLs are tunable in code with no migration. - Serve stale on MB outage — if a live fetch throws and a (stale) row exists, return it.
- TTL-only invalidation is sufficient for a home app; no explicit invalidation required for v1.
Cross-cutting facts
- Prisma schema
web/prisma/schema.prisma; migrations reach livelyravia the web entrypoint'smigrate deployondocker compose up -d --build(same as every prior migration). PostgresJsonmaps to JSONB. - The worker already threads a
conn(psycopg) throughmaybe_run_scan/scan_chunk/_run_discovery(main.py), andMusicBrainzBrowseris built inregistry.build_browser()/ used byLastfmSource. A conn is available at the monitor/scan call sites. - Key namespaces (shared verbatim between TS and Python):
rg-tracks:{rgMbid},artist-rgs:{artistMbid},artist-name:{artistMbid},rg-search:{artist}|{album},artist-search:{query}. - Suggested TTLs (all tunable, no migration): tracklists / discography / artist-name 30d
(near-immutable),
searchReleaseGroup7d,searchArtiststype-ahead 1d (or leave uncached). Normalizerg-search/artist-searchkeys (trim + lowercase) so casing doesn't fragment the cache. - Tests run against
lyra_test(guard blocks the live DB); both suites are DB + pure logic, no MB network in tests.
Task 1 — schema + migration
web/prisma/schema.prisma+= model:model MbCache { key String @id json Json fetchedAt DateTime @default(now()) }npx prisma migrate dev --name add_mb_cache(dev/lyra_test). Ships to live viamigrate deployon rebuild. NoexpiresAt.- Verify:
prisma migrate statusclean; table present.
Task 2 — web read-through (biggest UX win, smallest surface)
- New
web/src/lib/mbcache.ts:import { prisma } from "@/lib/db"; const DAY = 86_400; export async function cachedMb<T>(key: string, ttlSec: number, fetchFresh: () => Promise<T>): Promise<T> { const hit = await prisma.mbCache.findUnique({ where: { key } }); if (hit && (Date.now() - hit.fetchedAt.getTime()) / 1000 < ttlSec) return hit.json as T; try { const fresh = await fetchFresh(); await prisma.mbCache.upsert({ where: { key }, create: { key, json: fresh as object }, update: { json: fresh as object, fetchedAt: new Date() }, }); return fresh; } catch (e) { if (hit) return hit.json as T; // serve stale on MB outage throw e; } } - Wrap the high-level fns in
web/src/lib/musicbrainz.tswith logical keys + TTLs:browseReleaseGroupTracks(rg-tracks:{rgMbid}, 30d),browse discography(artist-rgs:{artistMbid}, 30d),getArtistName(artist-name:{artistMbid}, 30d),searchReleaseGroup(rg-search:{artist}|{album}normalized, 7d),searchArtists(artist-search:{query}normalized, 1d — or skip). Keep each fn's existing MB-mapping logic inside thefetchFreshclosure so cached values are the already-shaped objects. - Negative results:
searchReleaseGroupcan returnnull(no match). Decide: cachenullbriefly (e.g. 1d) to avoid re-hammering on edition-qualified misses (ties into the Last.fm "No MusicBrainz match" todo), OR don't cache nulls. Recommend cachingnullwith a short TTL — but only AFTER that todo's title-normalization lands, so we don't cache a miss that normalization would have resolved. web/src/lib/mbcache.test.ts(againstlyra_test): first call invokes a spyfetchFresh, second returns cached without calling it; a stale row + a throwingfetchFreshserves the old value.- Deploy after this task — cached reopens on artist/album/discover/preview pages land here.
Task 3 — worker read-through + CachingMbBrowser (makes the cache shared)
- New
worker/lyra_worker/mbcache.py:import json def cached_mb(conn, key, ttl_sec, fetch_fresh): with conn.cursor() as cur: cur.execute('SELECT json, extract(epoch from (now() - "fetchedAt")) ' 'FROM "MbCache" WHERE key = %s', (key,)) row = cur.fetchone() if row and row[1] < ttl_sec: return row[0] fresh = fetch_fresh() with conn.cursor() as cur: cur.execute('INSERT INTO "MbCache" (key, json, "fetchedAt") VALUES (%s, %s, now()) ' 'ON CONFLICT (key) DO UPDATE SET json = EXCLUDED.json, "fetchedAt" = now()', (key, json.dumps(fresh))) conn.commit() return fresh CachingMbBrowser(inner: MbBrowser, conn)decorator implementing the sameMbBrowserprotocol, keying on the same logical strings as the web side (artist-rgs:{mbid},artist-search:{q}).browse_release_groupsreturnsReleaseGroupInfodataclasses → store plain field dicts and reconstruct on read (small mapping fn mirroring_mbbrowser.py).registry.build_browser()wraps inCachingMbBrowserwhen a conn is available (monitor/scan path);LastfmSource's browser can take the same decorator or stay uncached (its own try/except already tolerates source failure).worker/tests/test_mbcache.py: read-through + stale semantics with a fakefetch_fresh(no MB network). Reconstruction round-trips aReleaseGroupInfo.
Task 4 — client-side modal cache + pruning (independent cheap wins)
- Module-level
Map<rgMbid, Track[]>in the album-modal client so reopening the same modal within a session is instant with zero network (complements the DB cache; no backend). - Pruning: one line on an existing worker periodic tick —
DELETE FROM "MbCache" WHERE "fetchedAt" < now() - interval '90 days'— keeps the table bounded (single-user scale = a few thousand rows regardless). - Optional force-refresh: on an artist page "search now" / scan re-resolve,
DELETE FROM "MbCache" WHERE key = 'artist-rgs:…'first. Nice-to-have, not v1.
Rollout order
- Task 1 (schema + migration).
- Task 2 (web read-through) → deploy — most of the "reopen re-fetches MB" pain gone with the smallest surface.
- Task 3 (worker) → scans/discovery warm the same cache and stop re-fetching discographies.
- Task 4 (client Map + prune line + optional force-refresh).
Also-worth-doing (adjacent, not strictly this cache)
- A real ~1 req/s throttle on the web MB client — caching reduces requests but doesn't guarantee pacing under a burst; the worker scan already paces, the web side doesn't.
- Overlaps with TODO #6 (DB-cached on-disk tracklist): the Library modal should ultimately prefer real on-disk tracks; this MB cache covers the non-owned / fallback tracklist path.
Notes / deferred
- Redis deferred (see header). Reconsider only on measured PG-cache contention or a need for pub/sub / multi-instance.
null/negative caching gated on the Last.fm title-normalization todo landing first.- Key-normalization (trim+lowercase) for search keys MUST match byte-for-byte between the TS and Python sides or the two processes fragment the search cache (browse/tracks/name keys are MBID-based so they align naturally).