Files
minstrel/internal/similarity/worker_integration_test.go
T
bvandeusenandClaude Opus 5.5 e44025435e
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m4s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: ListenBrainz similarity is stored relative to the seed's best, and the candidate arms keep tier order (#6177)
LB's similarity scores are counts (16 to 1445 for recordings, to 10,875
for artists), and they reached a scorer that reads similarity as 0 to 1.
One edge added 64 to 5,800 points under Songs-like, so likes, skips and
recency stopped mattering for any candidate with an edge.

- ResolveListenBrainzTrackEdges divides each edge by the seed's best
  score in its whole cached answer. The cache keeps the raw answer.
- The worker stores matched and unmatched artist rows over the seed's
  top artist.
- Migration 0096 rescales the rows already stored.
- The candidate arms hold sim_score bands so tier 1 stays above tier 2:
  lb_similar 0.5 + 0.5 x edge, tag_overlap halved, likes_overlap 0.3.
  Otherwise a seed's weaker LB edges fell below a shared genre tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:56:26 -04:00

656 lines
22 KiB
Go

package similarity
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
)
func testPool(t *testing.T) (*pgxpool.Pool, *dbq.Queries) {
t.Helper()
if testing.Short() {
t.Skip("skipping in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool, dbq.New(pool)
}
type fixture struct {
pool *pgxpool.Pool
q *dbq.Queries
user pgtype.UUID
artist dbq.Artist
album dbq.Album
}
func newFixture(t *testing.T) fixture {
t.Helper()
pool, q := testPool(t)
ctx := context.Background()
u, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiTokenHash: "x", IsAdmin: false,
})
if err != nil {
t.Fatalf("user: %v", err)
}
artistMbid := "aaaaaaaa-1111-1111-1111-111111111111"
a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{
Name: "X", SortName: "X", Mbid: &artistMbid,
})
if err != nil {
t.Fatalf("artist: %v", err)
}
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{
Title: "X", SortTitle: "X", ArtistID: a.ID,
})
if err != nil {
t.Fatalf("album: %v", err)
}
return fixture{pool: pool, q: q, user: u.ID, artist: a, album: al}
}
func seedTrack(t *testing.T, f fixture, title string, mbid *string) dbq.Track {
t.Helper()
tr, err := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title,
AlbumID: f.album.ID,
ArtistID: f.artist.ID,
FilePath: "/tmp/" + title + ".flac",
DurationMs: 200_000,
Mbid: mbid,
})
if err != nil {
t.Fatalf("track: %v", err)
}
return tr
}
func markPlayed(t *testing.T, f fixture, trackID pgtype.UUID) {
t.Helper()
var sessionID pgtype.UUID
if err := f.pool.QueryRow(context.Background(),
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`,
f.user).Scan(&sessionID); err != nil {
t.Fatalf("session: %v", err)
}
if _, err := f.pool.Exec(context.Background(),
`INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now() - interval '1 minute', now(), 250000, 0.83, false)`,
f.user, trackID, sessionID); err != nil {
t.Fatalf("play_event: %v", err)
}
}
func newTestWorker(f fixture, lbBaseURL string) *Worker {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
return &Worker{
pool: f.pool,
// LabsBaseURL must point at the stub too: similarity calls hit
// the Labs API (commit 4fca0e6), so an unset LabsBaseURL would
// fall through to the real labs.api and the stub never runs.
client: &listenbrainz.Client{BaseURL: lbBaseURL, LabsBaseURL: lbBaseURL, HTTP: http.DefaultClient},
logger: logger,
tick: 1 * time.Hour,
batch: 5,
topK: 20,
}
}
// stubLB returns an httptest server that responds to similar-recordings and
// similar-artists with the given JSON payloads.
func stubLB(recordingsBody, artistsBody string, status int) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if status != 0 && status != http.StatusOK {
w.WriteHeader(status)
return
}
if strings.Contains(r.URL.Path, "/similar-recordings/") {
_, _ = w.Write([]byte(recordingsBody))
return
}
if strings.Contains(r.URL.Path, "/similar-artists/") {
_, _ = w.Write([]byte(artistsBody))
return
}
w.WriteHeader(http.StatusNotFound)
}))
}
func countTrackSim(t *testing.T, f fixture, a pgtype.UUID) int {
t.Helper()
var n int
if err := f.pool.QueryRow(context.Background(),
`SELECT count(*) FROM track_similarity WHERE track_a_id = $1`, a).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
return n
}
func countArtistSim(t *testing.T, f fixture, a pgtype.UUID) int {
t.Helper()
var n int
if err := f.pool.QueryRow(context.Background(),
`SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1`, a).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
return n
}
func TestTickOnce_NoPlayedTracks_NoOp(t *testing.T) {
f := newFixture(t)
srv := stubLB(`[]`, `[]`, http.StatusOK)
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tickOnce: %v", err)
}
var n int
_ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM track_similarity`).Scan(&n)
if n != 0 {
t.Errorf("track_similarity rows = %d, want 0", n)
}
_ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM artist_similarity`).Scan(&n)
if n != 0 {
t.Errorf("artist_similarity rows = %d, want 0", n)
}
}
func TestTickOnce_MapsLBResponseToLocalLibrary(t *testing.T) {
f := newFixture(t)
mbidA := "11111111-1111-1111-1111-111111111111"
mbidB := "22222222-2222-2222-2222-222222222222"
mbidC := "99999999-9999-9999-9999-999999999999" // NOT in library
trackA := seedTrack(t, f, "A", &mbidA)
_ = seedTrack(t, f, "B", &mbidB)
markPlayed(t, f, trackA.ID)
body := `[
{"recording_mbid": "` + mbidB + `", "score": 0.9},
{"recording_mbid": "` + mbidC + `", "score": 0.7}
]`
srv := stubLB(body, `[]`, http.StatusOK)
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tickOnce: %v", err)
}
if got := countTrackSim(t, f, trackA.ID); got != 1 {
t.Errorf("track_similarity rows = %d, want 1 (only mbidB is in-library)", got)
}
}
// LB's scores are counts, not 0 to 1 (16 to 1445 on the operator's library).
// An edge is stored over the seed's best score in the whole answer, in the
// library or not, so the scorer reads it as 0 to 1 (#6177).
func TestTickOnce_EdgeIsRelativeToTheSeedsBest(t *testing.T) {
f := newFixture(t)
mbidA := "11111111-1111-1111-1111-111111111111"
mbidB := "22222222-2222-2222-2222-222222222222"
mbidC := "99999999-9999-9999-9999-999999999999" // NOT in library
trackA := seedTrack(t, f, "A", &mbidA)
trackB := seedTrack(t, f, "B", &mbidB)
markPlayed(t, f, trackA.ID)
body := `[
{"recording_mbid": "` + mbidC + `", "score": 1445},
{"recording_mbid": "` + mbidB + `", "score": 289}
]`
srv := stubLB(body, `[]`, http.StatusOK)
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tickOnce: %v", err)
}
var score float64
if err := f.pool.QueryRow(context.Background(),
`SELECT score FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`,
trackA.ID, trackB.ID).Scan(&score); err != nil {
t.Fatalf("edge: %v", err)
}
if score != 0.2 {
t.Errorf("score = %v, want 0.2 (289 over the seed's best, 1445)", score)
}
}
// The 20-per-seed cap is gone with the cache (#5296): LB's answer is at most
// 50, and every recording of it that is in the library becomes an edge.
func TestTickOnce_KeepsEveryInLibraryMatch(t *testing.T) {
f := newFixture(t)
mbidSeed := "11111111-1111-1111-1111-111111111111"
seed := seedTrack(t, f, "Seed", &mbidSeed)
markPlayed(t, f, seed.ID)
type lbRow struct {
MBID string `json:"recording_mbid"`
Score float64 `json:"score"`
}
rows := make([]lbRow, 0, 25)
for i := 0; i < 25; i++ {
mbid := fmt.Sprintf("20000000-0000-0000-0000-%012d", i+1)
_ = seedTrack(t, f, fmt.Sprintf("T%02d", i+1), &mbid)
rows = append(rows, lbRow{MBID: mbid, Score: 1.0 - 0.01*float64(i)})
}
body, _ := json.Marshal(rows)
srv := stubLB(string(body), `[]`, http.StatusOK)
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tickOnce: %v", err)
}
if got := countTrackSim(t, f, seed.ID); got != 25 {
t.Errorf("got %d rows, want all 25 in-library matches", got)
}
}
func TestTickOnce_RecentFetchIsNotRepeated(t *testing.T) {
f := newFixture(t)
mbid := "11111111-1111-1111-1111-111111111111"
trackA := seedTrack(t, f, "A", &mbid)
markPlayed(t, f, trackA.ID)
recordFetch(t, f, trackA.ID, "now() - interval '29 days'")
srv, asked := recordingLB(func(string) string { return `[]` })
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tickOnce: %v", err)
}
if n := len(asked()); n != 0 {
t.Errorf("a seed fetched 29 days ago was asked again (%d requests)", n)
}
}
func TestTickOnce_RefreshesStaleRow(t *testing.T) {
f := newFixture(t)
mbid := "11111111-1111-1111-1111-111111111111"
trackA := seedTrack(t, f, "A", &mbid)
markPlayed(t, f, trackA.ID)
otherMbid := "55555555-5555-5555-5555-555555555555"
other := seedTrack(t, f, "Other", &otherMbid)
if _, err := f.pool.Exec(context.Background(),
`INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at)
VALUES ($1, $2, 0.5, 'listenbrainz', now() - interval '31 days')`, trackA.ID, other.ID); err != nil {
t.Fatalf("seed sim: %v", err)
}
recordFetch(t, f, trackA.ID, "now() - interval '31 days'")
srv := stubLB(`[{"recording_mbid":"`+otherMbid+`","score":0.95}]`, `[]`, http.StatusOK)
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tickOnce: %v", err)
}
var score float64
var fetchedAt time.Time
_ = f.pool.QueryRow(context.Background(),
`SELECT score, fetched_at FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`,
trackA.ID, other.ID).Scan(&score, &fetchedAt)
// The seed's only answer is its best, so the refreshed edge is 1.0 (#6177).
if score != 1.0 {
t.Errorf("score = %v, want 1.0 (refreshed, relative to the seed's best)", score)
}
if time.Since(fetchedAt) > time.Minute {
t.Errorf("fetched_at not bumped: %v ago", time.Since(fetchedAt))
}
}
func TestTickOnce_429AbortsTick(t *testing.T) {
f := newFixture(t)
mbid := "11111111-1111-1111-1111-111111111111"
trackA := seedTrack(t, f, "A", &mbid)
markPlayed(t, f, trackA.ID)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Retry-After", "60")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
w := newTestWorker(f, srv.URL)
_ = w.tickOnce(context.Background())
if got := countTrackSim(t, f, trackA.ID); got != 0 {
t.Errorf("rows on 429 = %d, want 0 (tick aborted before any inserts)", got)
}
}
func TestTickOnce_TransientErrorSkipsTrack(t *testing.T) {
f := newFixture(t)
mbidA := "11111111-1111-1111-1111-111111111111"
mbidB := "22222222-2222-2222-2222-222222222222"
otherMbid := "55555555-5555-5555-5555-555555555555"
trackA := seedTrack(t, f, "A", &mbidA)
trackB := seedTrack(t, f, "B", &mbidB)
_ = seedTrack(t, f, "Other", &otherMbid)
markPlayed(t, f, trackA.ID)
markPlayed(t, f, trackB.ID)
var seen atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/similar-recordings/") {
_, _ = w.Write([]byte(`[]`))
return
}
n := seen.Add(1)
if n == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte(`[{"recording_mbid":"` + otherMbid + `","score":0.7}]`))
}))
defer srv.Close()
w := newTestWorker(f, srv.URL)
_ = w.tickOnce(context.Background())
a := countTrackSim(t, f, trackA.ID)
b := countTrackSim(t, f, trackB.ID)
if a+b != 1 {
t.Errorf("expected exactly one track to succeed: a=%d b=%d", a, b)
}
}
func TestTickOnce_FiltersInLibrary(t *testing.T) {
f := newFixture(t)
mbid := "11111111-1111-1111-1111-111111111111"
trackA := seedTrack(t, f, "A", &mbid)
markPlayed(t, f, trackA.ID)
body := `[
{"recording_mbid":"99999999-9999-9999-9999-999999999991","score":0.9},
{"recording_mbid":"99999999-9999-9999-9999-999999999992","score":0.8},
{"recording_mbid":"99999999-9999-9999-9999-999999999993","score":0.7}
]`
srv := stubLB(body, `[]`, http.StatusOK)
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tickOnce: %v", err)
}
if got := countTrackSim(t, f, trackA.ID); got != 0 {
t.Errorf("filtered out-of-library: got %d, want 0", got)
}
}
func TestTickOnce_ArtistPassMirrors(t *testing.T) {
f := newFixture(t)
mbid := "11111111-1111-1111-1111-111111111111"
trackA := seedTrack(t, f, "A", &mbid)
markPlayed(t, f, trackA.ID)
bMbid := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
_, err := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: "Other", SortName: "Other", Mbid: &bMbid,
})
if err != nil {
t.Fatalf("artist: %v", err)
}
body := `[{"artist_mbid":"` + bMbid + `","score":0.85}]`
srv := stubLB(`[]`, body, http.StatusOK)
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tickOnce: %v", err)
}
if got := countArtistSim(t, f, f.artist.ID); got != 1 {
t.Errorf("artist_similarity rows = %d, want 1", got)
}
}
func TestTickOnce_NoMBIDOnTrack_Skipped(t *testing.T) {
f := newFixture(t)
trackNoMbid := seedTrack(t, f, "NoMbid", nil)
markPlayed(t, f, trackNoMbid.ID)
srv := stubLB(`[{"recording_mbid":"11111111-1111-1111-1111-111111111111","score":0.9}]`, `[]`, http.StatusOK)
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tickOnce: %v", err)
}
if got := countTrackSim(t, f, trackNoMbid.ID); got != 0 {
t.Errorf("no-MBID track produced rows: %d", got)
}
}
// TestUpsertArtistSimilar_PersistsUnmatchedToTable: M5c. Calls
// upsertArtistSimilar directly with a mix of in-library and out-of-library
// similar-artist payloads; verifies the matched path lands in
// artist_similarity (1 row), the unmatched path lands in
// artist_similarity_unmatched (3 rows), and rows with empty Name are
// skipped (we can't render a suggestion without a name).
func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
// Seed one additional in-library artist that will be the in-library match.
inLibMBID := "in-lib-mbid-123"
inLibArtist, err := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{
Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID,
})
if err != nil {
t.Fatalf("seed in-lib artist: %v", err)
}
w := newTestWorker(f, "")
similars := []listenbrainz.SimilarArtist{
// LB's scores are counts; rows are stored over the seed's best (#6177).
{MBID: inLibMBID, Name: "InLib Artist", Score: 1000},
{MBID: "out-mbid-1", Name: "Outsider One", Score: 850},
{MBID: "out-mbid-2", Name: "Outsider Two", Score: 800},
{MBID: "out-mbid-3", Name: "Outsider Three", Score: 700},
{MBID: "out-mbid-4", Name: "", Score: 600}, // empty name — skipped
}
w.upsertArtistSimilar(ctx, f.q, f.artist.ID, similars)
// Matched path: 1 row in artist_similarity for the in-library candidate.
var matchedCount int
if err := f.pool.QueryRow(ctx,
"SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1",
f.artist.ID,
).Scan(&matchedCount); err != nil {
t.Fatalf("count matched: %v", err)
}
if matchedCount != 1 {
t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount)
}
var matchedScore float64
if err := f.pool.QueryRow(ctx,
"SELECT score FROM artist_similarity WHERE artist_a_id = $1 AND artist_b_id = $2",
f.artist.ID, inLibArtist.ID,
).Scan(&matchedScore); err != nil {
t.Fatalf("fetch matched score: %v", err)
}
if matchedScore != 1.0 {
t.Errorf("matched score = %v, want 1.0 (the seed's best)", matchedScore)
}
// Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped.
var unmatchedCount int
if err := f.pool.QueryRow(ctx,
"SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1",
f.artist.ID,
).Scan(&unmatchedCount); err != nil {
t.Fatalf("count unmatched: %v", err)
}
if unmatchedCount != 3 {
t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount)
}
// Verify a specific row's name + score round-tripped correctly.
var name string
var score float64
if err := f.pool.QueryRow(ctx,
"SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2",
f.artist.ID, "out-mbid-1",
).Scan(&name, &score); err != nil {
t.Fatalf("fetch out-mbid-1: %v", err)
}
if name != "Outsider One" || score != 0.85 {
t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score)
}
}
// recordFetch stamps a seed as answered by ListenBrainz at the given SQL time.
func recordFetch(t *testing.T, f fixture, trackID pgtype.UUID, at string) {
t.Helper()
if _, err := f.pool.Exec(context.Background(),
`INSERT INTO track_similarity_fetches (track_id, fetched_at, returned) VALUES ($1, `+at+`, 0)`,
trackID); err != nil {
t.Fatalf("record fetch: %v", err)
}
}
// recordingLB answers similar-recordings with answer(mbid) and similar-artists
// with an empty list, and reports which recording MBIDs it was asked about.
func recordingLB(answer func(mbid string) string) (*httptest.Server, func() []string) {
var mu sync.Mutex
var asked []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/similar-recordings/") {
_, _ = w.Write([]byte(`[]`))
return
}
mbid := r.URL.Query().Get("recording_mbids")
mu.Lock()
asked = append(asked, mbid)
mu.Unlock()
_, _ = w.Write([]byte(answer(mbid)))
}))
return srv, func() []string {
mu.Lock()
defer mu.Unlock()
return append([]string(nil), asked...)
}
}
// The starvation #3879 measured: a seed whose answer matched nothing in the
// library wrote nothing, stayed due, and was asked again every tick ahead of
// everything behind it. Recording the fetch moves the queue on.
func TestTickOnce_UnmatchedAnswerDoesNotHoldTheQueue(t *testing.T) {
f := newFixture(t)
mbids := []string{
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
"33333333-3333-3333-3333-333333333333",
}
for i := range mbids {
tr := seedTrack(t, f, fmt.Sprintf("S%d", i), &mbids[i])
markPlayed(t, f, tr.ID)
}
srv, asked := recordingLB(func(string) string {
return `[{"recording_mbid":"99999999-9999-9999-9999-999999999999","score":0.9}]`
})
defer srv.Close()
w := newTestWorker(f, srv.URL)
w.batch = 1
for i := 0; i < len(mbids); i++ {
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tick %d: %v", i, err)
}
}
seen := map[string]bool{}
for _, m := range asked() {
seen[m] = true
}
if len(seen) != len(mbids) {
t.Errorf("three ticks of one asked about %d distinct seeds (%v), want all %d",
len(seen), asked(), len(mbids))
}
var fetched int
_ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM track_similarity_fetches`).Scan(&fetched)
if fetched != len(mbids) {
t.Errorf("track_similarity_fetches = %d, want %d", fetched, len(mbids))
}
}
// The point of the cache (#5296): a recording LB named while it was not in the
// library becomes an edge once it arrives, on the next tick's resolve, without
// asking LB again. One edge per recording when the library holds two copies,
// and none to a copy that is missing.
func TestTickOnce_CachedAnswerResolvesWhenTheRecordingArrives(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
seedMbid := "11111111-1111-1111-1111-111111111111"
laterMbid := "77777777-7777-7777-7777-777777777777"
goneMbid := "88888888-8888-8888-8888-888888888888"
seed := seedTrack(t, f, "Seed", &seedMbid)
markPlayed(t, f, seed.ID)
srv, asked := recordingLB(func(string) string {
return `[{"recording_mbid":"` + laterMbid + `","score":0.9},
{"recording_mbid":"` + goneMbid + `","score":0.8}]`
})
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(ctx); err != nil {
t.Fatalf("tick 1: %v", err)
}
if got := countTrackSim(t, f, seed.ID); got != 0 {
t.Fatalf("edges before the recording is in the library = %d, want 0", got)
}
var cached int
_ = f.pool.QueryRow(ctx, `SELECT count(*) FROM listenbrainz_similar_recordings WHERE seed_track_id = $1`,
seed.ID).Scan(&cached)
if cached != 2 {
t.Fatalf("cached answer rows = %d, want 2 (both out of library)", cached)
}
// The recording arrives twice (an mp3 beside its flac); the other one
// arrives only as a missing row.
_ = seedTrack(t, f, "Later mp3", &laterMbid)
_ = seedTrack(t, f, "Later flac", &laterMbid)
gone := seedTrack(t, f, "Gone", &goneMbid)
if _, err := f.pool.Exec(ctx, `UPDATE tracks SET missing_since = now() WHERE id = $1`, gone.ID); err != nil {
t.Fatal(err)
}
if err := w.tickOnce(ctx); err != nil {
t.Fatalf("tick 2: %v", err)
}
if got := countTrackSim(t, f, seed.ID); got != 1 {
t.Errorf("edges after the recording arrived = %d, want 1 (one per recording, none to a missing copy)", got)
}
if n := len(asked()); n != 1 {
t.Errorf("LB asked %d times, want 1: the resolve needs no new fetch", n)
}
}
// A 4xx answer is permanent for that MBID; it is recorded like an empty one
// so the seed waits its turn instead of being re-asked every hour.
func TestTickOnce_PermanentErrorCountsAsFetched(t *testing.T) {
f := newFixture(t)
mbid := "11111111-1111-1111-1111-111111111111"
trackA := seedTrack(t, f, "A", &mbid)
markPlayed(t, f, trackA.ID)
srv := stubLB(``, ``, http.StatusBadRequest)
defer srv.Close()
w := newTestWorker(f, srv.URL)
if err := w.tickOnce(context.Background()); err != nil {
t.Fatalf("tickOnce: %v", err)
}
var tracks, artists int
_ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM track_similarity_fetches`).Scan(&tracks)
_ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM artist_similarity_fetches`).Scan(&artists)
if tracks != 1 || artists != 1 {
t.Errorf("fetches recorded: tracks=%d artists=%d, want 1 and 1", tracks, artists)
}
}