Scoped library payloads: load the track list when a view needs it #287

Open
yonlu wants to merge 10 commits from perf/279-284-scoped-library-payloads into main
pull from: perf/279-284-scoped-library-payloads
3 changed files with 157 additions and 0 deletions
Showing only changes of commit bce8d27370 - Show all commits

No files matched your search

+49
View File
@@ -13,6 +13,7 @@ import (
"net/http" "net/http"
"slices" "slices"
"strings" "strings"
"sync/atomic"
"time" "time"
) )
@@ -25,6 +26,19 @@ const (
// responds with a non-2xx status code. // responds with a non-2xx status code.
var ErrListenBrainzHTTP = errors.New("listenbrainz HTTP error") var ErrListenBrainzHTTP = errors.New("listenbrainz HTTP error")
// ErrListenBrainzUnauthorized is a 401: the endpoint wants a token, and
// no retry will change that.
//
// It is separate from ErrListenBrainzHTTP because it is the one
// failure that is about *this client* rather than about the thing being
// asked for — which is what makes it the one worth latching. The
// popularity endpoints answered 401 to every request on 2026-10-05, so
// a discography backfill spent one rate-limited request per artist to
// be told the same thing: 399 of them in a minute of a real library's
// backfill, each one a log line and a wasted slot in the shared
// limiter.
var ErrListenBrainzUnauthorized = errors.New("listenbrainz requires a token")
// ListenBrainzClient is a thin HTTP client for the ListenBrainz // ListenBrainzClient is a thin HTTP client for the ListenBrainz
// popularity and labs APIs. All requests are rate-limited via the // popularity and labs APIs. All requests are rate-limited via the
// shared RateLimiter and cached via the shared Cache. // shared RateLimiter and cached via the shared Cache.
@@ -39,6 +53,13 @@ type ListenBrainzClient struct {
// SetBaseURL shape — so a test that points one client at an // SetBaseURL shape — so a test that points one client at an
// httptest server does not stop being parallel-safe. // httptest server does not stop being parallel-safe.
baseURL string baseURL string
// refused latches the first 401. A token is a property of the
// installation, not of the artist being asked about, so the answer
// is the same for every later request and asking again is pure
// cost. Per client rather than global so a test can have one that
// is refused and one that is not.
refused atomic.Bool
} }
// NewListenBrainzClient creates a ListenBrainz API client. // NewListenBrainzClient creates a ListenBrainz API client.
@@ -56,6 +77,14 @@ func NewListenBrainzClient(
} }
} }
// Unauthorized reports whether this client has been refused with a 401
// during its life. A caller that is about to do a long pass of
// requests should ask before starting it: the answer will not change
// mid-pass.
func (c *ListenBrainzClient) Unauthorized() bool {
return c.refused.Load()
}
// SetBaseURL redirects this client at another host. Tests only. // SetBaseURL redirects this client at another host. Tests only.
func (c *ListenBrainzClient) SetBaseURL(url string) { func (c *ListenBrainzClient) SetBaseURL(url string) {
c.baseURL = strings.TrimSuffix(url, "/") c.baseURL = strings.TrimSuffix(url, "/")
@@ -489,6 +518,11 @@ func (c *ListenBrainzClient) doPost(
func (c *ListenBrainzClient) doRequest( func (c *ListenBrainzClient) doRequest(
ctx context.Context, method string, url string, body []byte, ctx context.Context, method string, url string, body []byte,
) ([]byte, error) { ) ([]byte, error) {
// Asked and answered, for the rest of this client's life.
if c.refused.Load() {
return nil, fmt.Errorf("%w: %s", ErrListenBrainzUnauthorized, url)
}
c.logger.Debug("listenbrainz rate limiter wait", "url", url) c.logger.Debug("listenbrainz rate limiter wait", "url", url)
if err := c.limiter.Wait(ctx); err != nil { if err := c.limiter.Wait(ctx); err != nil {
@@ -533,6 +567,21 @@ func (c *ListenBrainzClient) doRequest(
"status", resp.StatusCode, "status", resp.StatusCode,
) )
if resp.StatusCode == http.StatusUnauthorized {
// Recorded once, at warning level, because the next thing this
// client does is stop asking: a log line per artist is the
// symptom this latch exists to remove.
if c.refused.CompareAndSwap(false, true) {
c.logger.Warn("listenbrainz refused this client: "+
"popularity data needs a token, so the rest of this "+
"run will not ask for it",
"url", url,
)
}
return nil, fmt.Errorf("%w: %s", ErrListenBrainzUnauthorized, url)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"%w: %d %s", ErrListenBrainzHTTP, resp.StatusCode, truncateBody(respBody), "%w: %d %s", ErrListenBrainzHTTP, resp.StatusCode, truncateBody(respBody),
@@ -0,0 +1,90 @@
package explore
import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"yellowjacket/backend/database"
)
// #284: the popularity endpoints answered 401 to every request, so a
// backfill spent one rate-limited request per artist to be told the same
// thing — 399 of them in a minute against a real library. A token is a
// property of the installation, not of the artist, so the first refusal
// is the answer for the whole client.
func TestListenBrainzLatchesARefusal(t *testing.T) {
t.Parallel()
var requests atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusUnauthorized)
},
))
t.Cleanup(srv.Close)
c := NewListenBrainzClient(
NewRateLimiter(), NewCache(database.NewTestDB(t), slog.Default()), slog.Default(),
)
c.SetBaseURL(srv.URL)
for i := range 5 {
_, err := c.TopRecordingsForArtist(context.Background(), "an-mbid")
if !errors.Is(err, ErrListenBrainzUnauthorized) {
t.Fatalf("call %d: err = %v, want ErrListenBrainzUnauthorized", i, err)
}
}
if got := requests.Load(); got != 1 {
t.Errorf("requests = %d, want 1: the rest of the calls are the same answer", got)
}
if !c.Unauthorized() {
t.Error("Unauthorized() = false after a 401")
}
}
// A failure that a retry could fix must not latch: the artist stays
// unmarked and the next run asks again.
func TestListenBrainzDoesNotLatchATransientFailure(t *testing.T) {
t.Parallel()
var requests atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusInternalServerError)
},
))
t.Cleanup(srv.Close)
c := NewListenBrainzClient(
NewRateLimiter(), NewCache(database.NewTestDB(t), slog.Default()), slog.Default(),
)
c.SetBaseURL(srv.URL)
for range 3 {
_, err := c.TopRecordingsForArtist(context.Background(), "an-mbid")
if !errors.Is(err, ErrListenBrainzHTTP) {
t.Fatalf("err = %v, want ErrListenBrainzHTTP", err)
}
}
if got := requests.Load(); got != 3 {
t.Errorf("requests = %d, want 3", got)
}
if c.Unauthorized() {
t.Error("Unauthorized() = true after a 500")
}
}
+18
View File
@@ -518,6 +518,13 @@ func (si *SearchIndex) BackfillLibraryDiscographies(ctx context.Context) {
break break
} }
// A refused client is refused for every artist: the rest of
// this pass would be the same 401, once per artist (#284). The
// artists stay unmarked, so a run with a token picks them up.
if indexLB.Unauthorized() {
break
}
work <- mbid work <- mbid
} }
@@ -534,6 +541,17 @@ func (si *SearchIndex) BackfillLibraryDiscographies(ctx context.Context) {
return return
} }
if indexLB.Unauthorized() {
si.logger.Warn("discography backfill stopped early: "+
"listenbrainz refused this client, and a token is what it wants",
"artists", total, "of", len(mbids),
)
job.logf(jobs.LevelWarn, "Stopped early: ListenBrainz needs a token")
return
}
job.logf(jobs.LevelInfo, "Filled in "+strconv.Itoa(total)+" artists") job.logf(jobs.LevelInfo, "Filled in "+strconv.Itoa(total)+" artists")
si.logger.Info("discography backfill complete", "artists", total) si.logger.Info("discography backfill complete", "artists", total)