fix(similarity): keep ListenBrainz's whole answer and resolve it locally (#5296)
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m51s
release / integration (push) Successful in 5m36s
release / android (push) Successful in 7m47s
release / Build signed APK (releases and dev) (push) Successful in 8m29s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m47s
release / Verify release artifacts (tag releases only) (push) Skipped

The worker kept only the similar recordings already in the library, at most
20 of ListenBrainz's 50, and judged freshness by the edges it had written.
Two failures followed, both measured on the operator's library (#3879):

- A seed whose answer matched nothing wrote nothing, so it was never fresh.
  With the queue ordered by id, 25 such seeds held its head and were
  re-asked every hour; 17 of 2,466 played seeds had any edges.
- A recording that reached the library after its seed was fetched (a
  Lidarr import, an MBID from the AcoustID lookup) was never linked until
  a refetch, which for the stuck seeds never came.

Now every answer is cached whole in listenbrainz_similar_recordings and
every answer, an empty one or a permanent 4xx included, is recorded in
track_similarity_fetches. The queue reads the fetch record: never-fetched
first, then the oldest, refreshed after 30 days. The listenbrainz edges are
derived in SQL from the cache, one present track per recording and no cap,
for the seed just fetched and for every seed once per tick, so new arrivals
link within the hour without asking ListenBrainz again.

Artists get the same queue fix via artist_similarity_fetches; their answer
was already kept in artist_similarity and artist_similarity_unmatched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 19:56:22 -04:00
co-authored by Claude Opus 5.5
parent aa390c711a
commit e5dac9ddf0
9 changed files with 548 additions and 177 deletions
+127 -63
View File
@@ -11,6 +11,16 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const deleteSimilarRecordingsForSeed = `-- name: DeleteSimilarRecordingsForSeed :exec
DELETE FROM listenbrainz_similar_recordings WHERE seed_track_id = $1
`
// A fetch replaces the seed's cached answer whole.
func (q *Queries) DeleteSimilarRecordingsForSeed(ctx context.Context, seedTrackID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteSimilarRecordingsForSeed, seedTrackID)
return err
}
const getArtistsByMBIDs = `-- name: GetArtistsByMBIDs :many
SELECT id, mbid FROM artists WHERE mbid = ANY($1::text[])
`
@@ -40,49 +50,40 @@ func (q *Queries) GetArtistsByMBIDs(ctx context.Context, dollar_1 []string) ([]G
return items, nil
}
const getTracksByMBIDs = `-- name: GetTracksByMBIDs :many
SELECT id, mbid FROM tracks WHERE mbid = ANY($1::text[])
const insertSimilarRecordings = `-- name: InsertSimilarRecordings :exec
INSERT INTO listenbrainz_similar_recordings (seed_track_id, recording_mbid, score)
SELECT $1::uuid, r.mbid, max(r.score)
FROM (SELECT unnest($2::text[]) AS mbid,
unnest($3::float8[]) AS score) r
WHERE r.mbid <> ''
GROUP BY r.mbid
`
type GetTracksByMBIDsRow struct {
ID pgtype.UUID
Mbid *string
type InsertSimilarRecordingsParams struct {
SeedTrackID pgtype.UUID
Mbids []string
Scores []float64
}
// Bulk in-library lookup: maps a slice of MBIDs back to local track IDs.
func (q *Queries) GetTracksByMBIDs(ctx context.Context, dollar_1 []string) ([]GetTracksByMBIDsRow, error) {
rows, err := q.db.Query(ctx, getTracksByMBIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTracksByMBIDsRow
for rows.Next() {
var i GetTracksByMBIDsRow
if err := rows.Scan(&i.ID, &i.Mbid); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
// The seed's whole answer, in or out of the library. An MBID LB lists twice
// keeps its best score.
func (q *Queries) InsertSimilarRecordings(ctx context.Context, arg InsertSimilarRecordingsParams) error {
_, err := q.db.Exec(ctx, insertSimilarRecordings, arg.SeedTrackID, arg.Mbids, arg.Scores)
return err
}
const listPlayedArtistsNeedingSimilarity = `-- name: ListPlayedArtistsNeedingSimilarity :many
SELECT DISTINCT ar.id, ar.mbid
SELECT ar.id, ar.mbid
FROM artists ar
JOIN tracks t ON t.artist_id = ar.id
JOIN play_events pe ON pe.track_id = t.id
LEFT JOIN artist_similarity_fetches f ON f.artist_id = ar.id
WHERE ar.mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM artist_similarity asim
WHERE asim.artist_a_id = ar.id
AND asim.source = 'listenbrainz'
AND asim.fetched_at > now() - interval '7 days'
AND EXISTS (
SELECT 1 FROM tracks t
JOIN play_events pe ON pe.track_id = t.id
WHERE t.artist_id = ar.id
)
ORDER BY ar.id
AND (f.artist_id IS NULL OR f.fetched_at < now() - interval '30 days')
ORDER BY f.fetched_at NULLS FIRST, ar.id
LIMIT $1
`
@@ -91,6 +92,7 @@ type ListPlayedArtistsNeedingSimilarityRow struct {
Mbid *string
}
// The artist queue, with the same shape and the same reason.
func (q *Queries) ListPlayedArtistsNeedingSimilarity(ctx context.Context, limit int32) ([]ListPlayedArtistsNeedingSimilarityRow, error) {
rows, err := q.db.Query(ctx, listPlayedArtistsNeedingSimilarity, limit)
if err != nil {
@@ -112,17 +114,14 @@ func (q *Queries) ListPlayedArtistsNeedingSimilarity(ctx context.Context, limit
}
const listPlayedTracksNeedingSimilarity = `-- name: ListPlayedTracksNeedingSimilarity :many
SELECT DISTINCT t.id, t.mbid
SELECT t.id, t.mbid
FROM tracks t
JOIN play_events pe ON pe.track_id = t.id
LEFT JOIN track_similarity_fetches f ON f.track_id = t.id
WHERE t.mbid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM track_similarity ts
WHERE ts.track_a_id = t.id
AND ts.source = 'listenbrainz'
AND ts.fetched_at > now() - interval '7 days'
)
ORDER BY t.id
AND t.missing_since IS NULL
AND EXISTS (SELECT 1 FROM play_events pe WHERE pe.track_id = t.id)
AND (f.track_id IS NULL OR f.fetched_at < now() - interval '30 days')
ORDER BY f.fetched_at NULLS FIRST, t.id
LIMIT $1
`
@@ -131,9 +130,12 @@ type ListPlayedTracksNeedingSimilarityRow struct {
Mbid *string
}
// Tracks with at least one play, an MBID, and no fresh listenbrainz row
// (no row at all, OR fetched_at older than 7 days). Used by the worker
// to find work each tick. Bounded by $1.
// The worker's track queue: played, present tracks with a recording MBID that
// ListenBrainz has never answered for, or not in 30 days. Never-fetched first,
// then the oldest answer. Freshness is read from track_similarity_fetches, not
// from the edges, so a seed whose answer matched nothing in the library still
// counts as fetched (#5296); reading the edges is what let 25 such seeds hold
// the head of the queue forever.
func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit int32) ([]ListPlayedTracksNeedingSimilarityRow, error) {
rows, err := q.db.Query(ctx, listPlayedTracksNeedingSimilarity, limit)
if err != nil {
@@ -225,6 +227,86 @@ func (q *Queries) ListSimilarArtistsForArtist(ctx context.Context, arg ListSimil
return items, nil
}
const recordArtistSimilarityFetch = `-- name: RecordArtistSimilarityFetch :exec
INSERT INTO artist_similarity_fetches (artist_id, fetched_at, returned)
VALUES ($1, now(), $2)
ON CONFLICT (artist_id) DO UPDATE SET fetched_at = now(), returned = EXCLUDED.returned
`
type RecordArtistSimilarityFetchParams struct {
ArtistID pgtype.UUID
Returned int32
}
func (q *Queries) RecordArtistSimilarityFetch(ctx context.Context, arg RecordArtistSimilarityFetchParams) error {
_, err := q.db.Exec(ctx, recordArtistSimilarityFetch, arg.ArtistID, arg.Returned)
return err
}
const recordTrackSimilarityFetch = `-- name: RecordTrackSimilarityFetch :exec
INSERT INTO track_similarity_fetches (track_id, fetched_at, returned)
VALUES ($1, now(), $2)
ON CONFLICT (track_id) DO UPDATE SET fetched_at = now(), returned = EXCLUDED.returned
`
type RecordTrackSimilarityFetchParams struct {
TrackID pgtype.UUID
Returned int32
}
func (q *Queries) RecordTrackSimilarityFetch(ctx context.Context, arg RecordTrackSimilarityFetchParams) error {
_, err := q.db.Exec(ctx, recordTrackSimilarityFetch, arg.TrackID, arg.Returned)
return err
}
const resolveListenBrainzTrackEdges = `-- name: ResolveListenBrainzTrackEdges :exec
WITH want AS (
SELECT DISTINCT ON (c.seed_track_id, c.recording_mbid)
c.seed_track_id AS track_a_id, t.id AS track_b_id, c.score
FROM listenbrainz_similar_recordings c
JOIN tracks t ON t.mbid = c.recording_mbid
AND t.missing_since IS NULL
AND t.id <> c.seed_track_id
WHERE $1::uuid[] IS NULL
OR c.seed_track_id = ANY($1::uuid[])
ORDER BY c.seed_track_id, c.recording_mbid, t.id
),
upserted AS (
INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at)
SELECT track_a_id, track_b_id, score, 'listenbrainz', now() FROM want
ON CONFLICT (track_a_id, track_b_id, source) DO UPDATE
SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at
WHERE track_similarity.score IS DISTINCT FROM EXCLUDED.score
RETURNING 1
)
DELETE FROM track_similarity ts
WHERE ts.source = 'listenbrainz'
AND ($1::uuid[] IS NULL OR ts.track_a_id = ANY($1::uuid[]))
AND EXISTS (SELECT 1 FROM track_similarity_fetches f WHERE f.track_id = ts.track_a_id)
AND NOT EXISTS (
SELECT 1 FROM want w
WHERE w.track_a_id = ts.track_a_id AND w.track_b_id = ts.track_b_id
)
`
// Derives the listenbrainz edges in track_similarity from the cached answers:
// every cached recording that is a present track in the library becomes an
// edge, whenever it got there. Scoped to the given seeds, or every seed when
// seed_ids is NULL.
//
// One track per recording MBID: two copies of a song (an mp3 beside its flac,
// or one recording on two releases) would otherwise be two candidates, and the
// pool would offer the same song twice. No cap per seed: LB's answer is already
// at most 50.
//
// Only edges of seeds the cache owns are removed, those with a fetch row.
// Edges written before the cache existed, or copied onto a survivor by a fold
// (merge.sql), stay until their seed is fetched.
func (q *Queries) ResolveListenBrainzTrackEdges(ctx context.Context, seedIds []pgtype.UUID) error {
_, err := q.db.Exec(ctx, resolveListenBrainzTrackEdges, seedIds)
return err
}
const upsertArtistSimilarity = `-- name: UpsertArtistSimilarity :exec
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
VALUES ($1, $2, $3, 'listenbrainz', now())
@@ -274,21 +356,3 @@ func (q *Queries) UpsertArtistSimilarityUnmatched(ctx context.Context, arg Upser
)
return err
}
const upsertTrackSimilarity = `-- name: UpsertTrackSimilarity :exec
INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at)
VALUES ($1, $2, $3, 'listenbrainz', now())
ON CONFLICT (track_a_id, track_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at
`
type UpsertTrackSimilarityParams struct {
TrackAID pgtype.UUID
TrackBID pgtype.UUID
Score float64
}
func (q *Queries) UpsertTrackSimilarity(ctx context.Context, arg UpsertTrackSimilarityParams) error {
_, err := q.db.Exec(ctx, upsertTrackSimilarity, arg.TrackAID, arg.TrackBID, arg.Score)
return err
}