feat(discover): artist-tag cache for out-of-library candidates — #2376
test-go / test (push) Failing after 32s
test-go / integration (push) Successful in 4m50s

Migration 0050 adds candidate_artist_tags + candidate_artist_tag_state:
folksonomy tags for artists NOT in the library, which track_tags cannot
hold because it's FK'd to tracks(id) and a Discover candidate has no local
row. Slice 6 ranks against these; this slice only fills the cache.

The reuse the task claimed is real and verified: MusicBrainz's
fetchEntityTags(ctx, "artist", mbid, scale) already existed for the #1519
recording→artist fallback, so FetchArtistTags is a thin wrapper. Two
subtleties it does NOT inherit:

  - Weight scale is 1.0, not artistTagWeightFactor (0.6). That discount
    exists because FetchTrackTags uses artist tags as a *proxy* for a
    track's; here the artist IS the subject. Applying it would make these
    weights incomparable with track_tags — exactly the comparison slice 6
    depends on. Pinned by a test.
  - fetchEntityTags reports existing-but-untagged as (empty, nil) so the
    track path can fall through. There's no next level here, so empty
    becomes the terminal ErrNotFound; otherwise the enricher would settle
    a candidate as "enriched" with zero tags.

ArtistTagProvider is the split TrackTagProvider's own doc comment
anticipated ("e.g. artist-level tags"). Last.fm gains artist.getTopTags,
which returns the same toptags envelope, so the response type and
normalizer are reused unchanged.

Rather than write the merge-and-classify loop twice, extracted it from
EnrichTrack into runChain(). The ErrNotFound-vs-transient split is the
load-bearing part — those lead to opposite persistence decisions — so it
now has direct unit tests it never had while inlined.

Bookkeeping is a separate table, not columns, because the "providers had
nothing" outcome must be recordable for a candidate with zero tag rows,
and there is no per-candidate row to hang columns off (
artist_similarity_unmatched holds many rows per candidate). Absence of a
state row means "never processed", so a transient failure writes nothing
and stays eligible.

Two capacity realities are designed for, not papered over:
  - The pool is O(library artists x neighbours) and MusicBrainz allows
    ~1 req/s, so it can never drain in one pass. The eligibility query
    returns candidates in descending summed-similarity order, so the ones
    that can actually reach a deck are enriched first.
  - candidateBatch (50) is smaller than the track batch (200): tracks are
    finite and drain to completion, candidates are effectively unbounded
    and would otherwise starve the track arm forever.

GC sweeps both tables — the similarity feed churns, and a candidate that
joins the library has its tags in track_tags now. Tags swept before state
so a mid-sweep crash leaves a valid state, not a re-fetch loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 20:04:15 -04:00
co-authored by Claude Opus 5
parent f17356560d
commit 4f9b083eec
16 changed files with 1387 additions and 41 deletions
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS candidate_artist_tag_state_source_idx;
DROP TABLE IF EXISTS candidate_artist_tag_state;
DROP TABLE IF EXISTS candidate_artist_tags;
@@ -0,0 +1,54 @@
-- 0050_candidate_artist_tags.up.sql — folksonomy tags for OUT-OF-LIBRARY
-- artists (#2376, milestone #268 slice 5).
--
-- track_tags (0042) cannot hold these: it is FK'd to tracks(id), and a
-- Discover candidate has no local row by definition. So this is a parallel
-- cache keyed by the candidate's MusicBrainz MBID — the only stable identity
-- available for an artist we don't have.
--
-- Purpose is slice 6: rank suggestions by overlap between these tags and the
-- user's taste_profile_tags, turning "neighbour of an artist you play" into
-- "matches the sound you like".
--
-- GLOBAL, not per-user: a candidate's tags are a property of the artist, not
-- of anyone's taste. Nothing here is user-scoped, so rule #47 has nothing to
-- scope — the per-user part lives entirely in slice 6's ranking.
--
-- weight is a normalized folksonomy strength in [0,1], same scale as
-- track_tags, so the two can be compared without a conversion step.
CREATE TABLE candidate_artist_tags (
candidate_mbid text NOT NULL,
tag text NOT NULL,
weight double precision NOT NULL DEFAULT 1,
PRIMARY KEY (candidate_mbid, tag)
);
-- Enrichment bookkeeping. This is a SEPARATE table rather than columns on the
-- tags table, because the "providers had nothing" outcome must be recordable
-- for a candidate with zero tag rows — otherwise every empty candidate stays
-- eligible forever and the worker re-fetches it on every tick.
--
-- tracks solved the same problem with columns on `tracks` (0042), but there is
-- no per-candidate row anywhere to hang them off: artist_similarity_unmatched
-- is keyed (seed_artist_id, candidate_mbid, source) and holds MANY rows per
-- candidate.
--
-- Absence of a row here means "never processed", so unlike tracks.tag_source
-- this column can be NOT NULL — there is no null-means-pending state to model.
-- 'musicbrainz' | 'lastfm' | 'mixed' → found, cached
-- 'none' → providers confirmed nothing
-- tag_sources_version → bump to re-process settled 'none'
--
-- A transient failure writes NO row at all (rather than a row it would then
-- have to distinguish), which leaves the candidate eligible for the next pass.
CREATE TABLE candidate_artist_tag_state (
candidate_mbid text PRIMARY KEY,
tag_source text NOT NULL,
tag_sources_version integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Serves the eligibility scan's "settled 'none' under an older version"
-- branch. The PK already covers the per-candidate lookups.
CREATE INDEX candidate_artist_tag_state_source_idx
ON candidate_artist_tag_state (tag_source, tag_sources_version);