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,282 @@
package recommendation
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Slice 5 (#2376): the candidate-artist tag cache for out-of-library Discover
// candidates. These cover the SQL rather than the provider chain — the
// eligibility query is the piece with real risk in it (a GROUP BY over a
// many-rows-per-candidate table, a LEFT JOIN to bookkeeping, and two exclusion
// branches), and it's consumed by this package's slice-6 ranking.
//
// Fixtures live here because the harness and seedUnmatched do.
const (
tagVersionCurrent = 2
tagVersionOld = 1
)
// listEligible is the query under test, at the current provider version.
func listEligible(t *testing.T, pool *pgxpool.Pool, limit int32) []dbq.ListCandidateArtistsMissingTagsRow {
t.Helper()
rows, err := dbq.New(pool).ListCandidateArtistsMissingTags(context.Background(),
dbq.ListCandidateArtistsMissingTagsParams{
TagSourcesVersion: tagVersionCurrent,
Limit: limit,
})
if err != nil {
t.Fatalf("ListCandidateArtistsMissingTags: %v", err)
}
return rows
}
func setState(t *testing.T, pool *pgxpool.Pool, mbid, source string, version int32) {
t.Helper()
if err := dbq.New(pool).SetCandidateArtistTagState(context.Background(),
dbq.SetCandidateArtistTagStateParams{
CandidateMbid: mbid, TagSource: source, TagSourcesVersion: version,
}); err != nil {
t.Fatalf("SetCandidateArtistTagState: %v", err)
}
}
func mbidsOf(rows []dbq.ListCandidateArtistsMissingTagsRow) []string {
out := make([]string, 0, len(rows))
for _, r := range rows {
out = append(out, r.CandidateMbid)
}
return out
}
func TestCandidateTags_NeverProcessedIsEligible(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, user.ID, seed.ID)
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
rows := listEligible(t, pool, 10)
if len(rows) != 1 {
t.Fatalf("len = %d, want 1: %v", len(rows), mbidsOf(rows))
}
if rows[0].CandidateName != "Candidate One" {
t.Errorf("name = %q, want Candidate One", rows[0].CandidateName)
}
if rows[0].TotalScore != 0.9 {
t.Errorf("score = %v, want 0.9", rows[0].TotalScore)
}
}
// An in-library candidate's tags belong in track_tags, and the suggestion query
// filters it out anyway — enriching it would be wasted API budget.
func TestCandidateTags_InLibraryCandidateIsExcluded(t *testing.T) {
pool := newPool(t)
seed := seedArtist(t, pool, "Seed", "")
seedArtist(t, pool, "Already Here", "cand-in-lib")
seedUnmatched(t, pool, seed.ID, "cand-in-lib", "Already Here", 0.9)
if rows := listEligible(t, pool, 10); len(rows) != 0 {
t.Errorf("len = %d, want 0: %v", len(rows), mbidsOf(rows))
}
}
func TestCandidateTags_SettledWithTagsIsExcluded(t *testing.T) {
pool := newPool(t)
seed := seedArtist(t, pool, "Seed", "")
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
setState(t, pool, "cand-1", "musicbrainz", tagVersionCurrent)
if rows := listEligible(t, pool, 10); len(rows) != 0 {
t.Errorf("len = %d, want 0 (already enriched): %v", len(rows), mbidsOf(rows))
}
}
// A candidate that settled 'none' becomes eligible again when the provider set
// widens (version bump) — that's the whole point of the version column. It must
// NOT be eligible at the current version, or the worker re-fetches it forever.
func TestCandidateTags_SettledNoneReopensOnlyOnVersionBump(t *testing.T) {
pool := newPool(t)
seed := seedArtist(t, pool, "Seed", "")
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
setState(t, pool, "cand-1", "none", tagVersionCurrent)
if rows := listEligible(t, pool, 10); len(rows) != 0 {
t.Errorf("current version: len = %d, want 0 (settled)", len(rows))
}
setState(t, pool, "cand-1", "none", tagVersionOld)
if rows := listEligible(t, pool, 10); len(rows) != 1 {
t.Errorf("older version: len = %d, want 1 (eligible again)", len(rows))
}
}
// artist_similarity_unmatched holds one row per (seed, candidate, source).
// Without the GROUP BY, a candidate that five seeds point at would be fetched
// five times — five times the MusicBrainz budget for identical data.
func TestCandidateTags_ManySeedsCollapseToOneRowAndSumScores(t *testing.T) {
pool := newPool(t)
seedA := seedArtist(t, pool, "Seed A", "")
seedB := seedArtist(t, pool, "Seed B", "")
seedUnmatched(t, pool, seedA.ID, "cand-1", "Candidate One", 0.4)
seedUnmatched(t, pool, seedB.ID, "cand-1", "Candidate One", 0.3)
rows := listEligible(t, pool, 10)
if len(rows) != 1 {
t.Fatalf("len = %d, want 1 (grouped): %v", len(rows), mbidsOf(rows))
}
if got := rows[0].TotalScore; got < 0.69 || got > 0.71 {
t.Errorf("total_score = %v, want ~0.7 (summed across seeds)", got)
}
}
// The pool is far larger than one pass can drain at ~1 req/s, so the ordering
// IS the feature: the strongest candidates must be enriched first, or the ones
// that actually reach a user's deck starve behind the long tail.
func TestCandidateTags_StrongestCandidatesComeFirst(t *testing.T) {
pool := newPool(t)
seed := seedArtist(t, pool, "Seed", "")
seedUnmatched(t, pool, seed.ID, "weak", "Weak", 0.1)
seedUnmatched(t, pool, seed.ID, "strong", "Strong", 0.95)
seedUnmatched(t, pool, seed.ID, "middle", "Middle", 0.5)
rows := listEligible(t, pool, 10)
want := []string{"strong", "middle", "weak"}
got := mbidsOf(rows)
if len(got) != 3 {
t.Fatalf("len = %d, want 3: %v", len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
// And the limit takes the strongest, not an arbitrary slice.
if top := mbidsOf(listEligible(t, pool, 1)); len(top) != 1 || top[0] != "strong" {
t.Errorf("limit 1 returned %v, want [strong]", top)
}
}
func TestCandidateTags_InsertKeepsTheStrongerWeight(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
ins := func(w float64) {
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
CandidateMbid: "cand-1", Tag: "shoegaze", Weight: w,
}); err != nil {
t.Fatalf("InsertCandidateArtistTag: %v", err)
}
}
ins(0.8)
ins(0.3) // weaker second write must not clobber
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"cand-1"})
if err != nil {
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
}
if len(rows) != 1 {
t.Fatalf("len = %d, want 1", len(rows))
}
if rows[0].Weight != 0.8 {
t.Errorf("weight = %v, want 0.8 (GREATEST)", rows[0].Weight)
}
}
func TestCandidateTags_ListForMbidsIgnoresUnaskedCandidates(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
for _, mbid := range []string{"want-1", "want-2", "other"} {
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
CandidateMbid: mbid, Tag: "rock", Weight: 1,
}); err != nil {
t.Fatalf("insert %s: %v", mbid, err)
}
}
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"want-1", "want-2"})
if err != nil {
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
}
if len(rows) != 2 {
t.Errorf("len = %d, want 2", len(rows))
}
for _, r := range rows {
if r.CandidateMbid == "other" {
t.Error("returned a candidate that wasn't asked for")
}
}
}
// The similarity feed is refetched and churns, so without the sweep the cache
// only grows. Both halves must survive/die together for the right candidates.
func TestCandidateTags_GcDropsOrphansAndKeepsLiveOnes(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
seed := seedArtist(t, pool, "Seed", "")
seedUnmatched(t, pool, seed.ID, "live", "Live", 0.9)
// "gone" is cached but no longer in the feed; "adopted" got added to the
// library since, so its tags belong in track_tags now.
seedArtist(t, pool, "Adopted", "adopted")
seedUnmatched(t, pool, seed.ID, "adopted", "Adopted", 0.8)
for _, mbid := range []string{"live", "gone", "adopted"} {
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
CandidateMbid: mbid, Tag: "rock", Weight: 1,
}); err != nil {
t.Fatalf("insert %s: %v", mbid, err)
}
setState(t, pool, mbid, "musicbrainz", tagVersionCurrent)
}
deleted, err := q.GcDeleteOrphanedCandidateArtistTags(ctx)
if err != nil {
t.Fatalf("GcDeleteOrphanedCandidateArtistTags: %v", err)
}
if deleted != 2 {
t.Errorf("deleted %d tag rows, want 2 (gone + adopted)", deleted)
}
deletedState, err := q.GcDeleteOrphanedCandidateArtistTagState(ctx)
if err != nil {
t.Fatalf("GcDeleteOrphanedCandidateArtistTagState: %v", err)
}
if deletedState != 2 {
t.Errorf("deleted %d state rows, want 2", deletedState)
}
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"live", "gone", "adopted"})
if err != nil {
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
}
if len(rows) != 1 || rows[0].CandidateMbid != "live" {
t.Errorf("survivors = %v, want [live] only", rows)
}
}
// Coverage is the operator's window onto the honest ceiling: processed vs
// actually-tagged. A big gap means thin upstream data, not a broken worker.
func TestCandidateTags_CoverageCountsProcessedAndTagged(t *testing.T) {
pool := newPool(t)
setState(t, pool, "has-tags", "musicbrainz", tagVersionCurrent)
setState(t, pool, "mixed-tags", "mixed", tagVersionCurrent)
setState(t, pool, "no-tags", "none", tagVersionCurrent)
got, err := dbq.New(pool).CountCandidateArtistTagCoverage(context.Background())
if err != nil {
t.Fatalf("CountCandidateArtistTagCoverage: %v", err)
}
if got.Processed != 3 {
t.Errorf("processed = %d, want 3", got.Processed)
}
if got.WithTags != 2 {
t.Errorf("with_tags = %d, want 2 ('none' excluded)", got.WithTags)
}
}