docs: add MusicBrainz Postgres cache implementation plan
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>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
# 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/2` REST paths;
|
||||
the worker uses `musicbrainzngs`, 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`** (no `expiresAt` column) 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 live `lyra` via the web
|
||||
entrypoint's `migrate deploy` on `docker compose up -d --build` (same as every prior
|
||||
migration). Postgres `Json` maps to JSONB.
|
||||
- The worker already threads a `conn` (psycopg) through `maybe_run_scan` / `scan_chunk` /
|
||||
`_run_discovery` (`main.py`), and `MusicBrainzBrowser` is built in `registry.build_browser()`
|
||||
/ used by `LastfmSource`. 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), `searchReleaseGroup` **7d**, `searchArtists` type-ahead **1d** (or leave
|
||||
uncached). Normalize `rg-search`/`artist-search` keys (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:
|
||||
```prisma
|
||||
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 via
|
||||
`migrate deploy` on rebuild. No `expiresAt`.
|
||||
- Verify: `prisma migrate status` clean; table present.
|
||||
|
||||
## Task 2 — web read-through (biggest UX win, smallest surface)
|
||||
- New `web/src/lib/mbcache.ts`:
|
||||
```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.ts` with 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 the `fetchFresh` closure so cached values are the already-shaped objects.
|
||||
- **Negative results:** `searchReleaseGroup` can return `null` (no match). Decide: cache
|
||||
`null` briefly (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 caching `null` with 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` (against `lyra_test`): first call invokes a spy `fetchFresh`,
|
||||
second returns cached without calling it; a stale row + a throwing `fetchFresh` serves 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`:
|
||||
```python
|
||||
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 same `MbBrowser`
|
||||
protocol, keying on the **same** logical strings as the web side (`artist-rgs:{mbid}`,
|
||||
`artist-search:{q}`). `browse_release_groups` returns `ReleaseGroupInfo` dataclasses →
|
||||
store plain field dicts and reconstruct on read (small mapping fn mirroring `_mbbrowser.py`).
|
||||
- `registry.build_browser()` wraps in `CachingMbBrowser` when 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 fake `fetch_fresh`
|
||||
(no MB network). Reconstruction round-trips a `ReleaseGroupInfo`.
|
||||
|
||||
## 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
|
||||
1. Task 1 (schema + migration).
|
||||
2. Task 2 (web read-through) → **deploy** — most of the "reopen re-fetches MB" pain gone with
|
||||
the smallest surface.
|
||||
3. Task 3 (worker) → scans/discovery warm the same cache and stop re-fetching discographies.
|
||||
4. 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).
|
||||
Reference in New Issue
Block a user