feat(discover): artist-tag cache for out-of-library candidates — #2376
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user