The post-scan backfills share MusicBrainz's rate limiters with every page the user can open, and both were FIFO — so a thousand-artist enrichment put an album page behind an hour of queued work. WithBackgroundLane/WithBackgroundPriority add a slower second lane: a marked wait takes no token while any interactive wait is outstanding. It is a context marker rather than a parameter because a backfill calls the same client methods a detail page does. A long backfill also has to be visible and stoppable, so jobs.KindCatalogEnrich registers both with progress and cancel — after the work is counted, since these passes are a no-op on every launch once the library is covered. What it does not fetch is the point. It ran for hours against a 900-artist library and marked nothing, because three of the four things it did per artist were work nobody asked for: similar artists, which the artist page already resolves on view, and a full GetArtistImage (fanart.tv, TheAudioDB, Wikidata, Wikipedia, ten portraits) reached only to warm the MB artist lookup EnsureArtistRels does alone. It was also serial across artists while every limiter is per-host and idle. The marks are a table rather than more explore_index columns, because artifactimport merges by column list and a flag added there is a second place to remember. BrowseReleaseGroupsAll pages to exhaustion, where the old call silently cut a prolific artist at 100 release groups. One portrait is downloaded now; the rest are remembered as URLs. resolveAllSources downloaded every candidate, up to ten, full size, while nothing reads anything but primary.jpg — 5.3 GB measured on a real cache, 4.1 GB of it unreachable. OrphanedArtistImagesJob is why that survived: it joined the bare MBID onto the images directory, but artist directories are sharded under a two-character prefix, so it named a path that never existed and deleted the rows that were the only record of the files it left behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
230 lines
7.0 KiB
Go
230 lines
7.0 KiB
Go
// Package explore provides MusicBrainz and ListenBrainz API clients
|
|
// with rate-limited HTTP access and a SQLite response cache.
|
|
package explore
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// RateLimiter enforces a maximum request rate using a token bucket.
|
|
// MusicBrainz requires ≤1 request per second and rejects ALL
|
|
// requests (not just excess) when the rate is exceeded, so callers
|
|
// block proactively via Wait rather than retrying reactively.
|
|
//
|
|
// A limiter may carry a second, slower **background lane** (see
|
|
// WithBackgroundLane). A caller marked by WithBackgroundPriority is
|
|
// paced by that lane *and* yields to interactive callers: while any
|
|
// interactive Wait is outstanding, background waits do not take a
|
|
// token at all. This is what keeps a multi-thousand-request backfill
|
|
// from putting the album page the user is looking at right now behind
|
|
// hours of queued work.
|
|
//
|
|
// RateLimiter is safe for concurrent use.
|
|
type RateLimiter struct {
|
|
limiter *rate.Limiter
|
|
|
|
// background paces callers marked with WithBackgroundPriority. Nil
|
|
// means background callers are paced by limiter alone and only the
|
|
// yield gate below applies.
|
|
background *rate.Limiter
|
|
|
|
mu sync.Mutex
|
|
// interactive counts Waits currently blocked (or about to block) on
|
|
// behalf of a user-facing request.
|
|
interactive int
|
|
// clear is closed when interactive falls to zero, and is nil while
|
|
// there are none. Background waiters select on it rather than
|
|
// polling, so a quiet limiter costs nothing.
|
|
clear chan struct{}
|
|
}
|
|
|
|
// NewRateLimiter returns a rate limiter that allows exactly one
|
|
// request per second with a burst size of 1. The first call to
|
|
// Wait returns immediately; subsequent calls block until the next
|
|
// token is available.
|
|
func NewRateLimiter() *RateLimiter {
|
|
return &RateLimiter{
|
|
limiter: rate.NewLimiter(rate.Every(time.Second), 1),
|
|
}
|
|
}
|
|
|
|
// NewRateLimiterN returns a rate limiter that allows n requests
|
|
// per second with a burst of n. Used for background tasks like
|
|
// index building where a higher rate is acceptable.
|
|
func NewRateLimiterN(n int) *RateLimiter {
|
|
return &RateLimiter{
|
|
limiter: rate.NewLimiter(rate.Limit(n), n),
|
|
}
|
|
}
|
|
|
|
// NewRateLimiterF returns a rate limiter that allows f requests
|
|
// per second with a burst of 1.
|
|
func NewRateLimiterF(f float64) *RateLimiter {
|
|
return &RateLimiter{
|
|
limiter: rate.NewLimiter(rate.Limit(f), 1),
|
|
}
|
|
}
|
|
|
|
// NewRateLimiterBurst returns a rate limiter that allows n requests
|
|
// per second with a burst size of b. The burst allows short spikes
|
|
// (e.g. 3 concurrent search calls) without queueing, while still
|
|
// limiting sustained throughput.
|
|
func NewRateLimiterBurst(n, b int) *RateLimiter {
|
|
return &RateLimiter{
|
|
limiter: rate.NewLimiter(rate.Limit(n), b),
|
|
}
|
|
}
|
|
|
|
// WithBackgroundLane gives the limiter a slower sustained rate for
|
|
// callers marked by WithBackgroundPriority, and returns it so the
|
|
// call can be chained onto a constructor.
|
|
//
|
|
// The interactive lane is deliberately allowed to be faster than the
|
|
// origin's documented limit (MB's search limiter is 3/s burst 1, which
|
|
// staggers three concurrent search goroutines rather than raising the
|
|
// sustained rate). Sustained background work has no such excuse: a
|
|
// backfill running for an hour at the interactive rate is exactly the
|
|
// traffic shape that earns an all-or-nothing 503 for every request the
|
|
// app makes, interactive ones included.
|
|
func (r *RateLimiter) WithBackgroundLane(perSecond float64) *RateLimiter {
|
|
r.background = rate.NewLimiter(rate.Limit(perSecond), 1)
|
|
|
|
return r
|
|
}
|
|
|
|
// Wait blocks until the rate limiter allows the caller to proceed
|
|
// or the context is cancelled. Returns ctx.Err() if the context
|
|
// expires before a token becomes available.
|
|
//
|
|
// A context marked by WithBackgroundPriority takes the background
|
|
// lane; every other caller is interactive.
|
|
func (r *RateLimiter) Wait(ctx context.Context) error {
|
|
if isBackgroundPriority(ctx) {
|
|
return r.waitBackground(ctx)
|
|
}
|
|
|
|
r.enterInteractive()
|
|
defer r.exitInteractive()
|
|
|
|
return r.limiter.Wait(ctx)
|
|
}
|
|
|
|
// waitBackground yields to interactive callers, paces on the background
|
|
// lane if there is one, and only then takes a token.
|
|
//
|
|
// One request of slippage is possible and is accepted rather than
|
|
// designed out: an interactive caller arriving *during* the final
|
|
// limiter.Wait below queues behind this one background request. Making
|
|
// that impossible would mean cancelling an already-granted reservation,
|
|
// which the token bucket cannot express, in exchange for at most one
|
|
// request-time of latency.
|
|
func (r *RateLimiter) waitBackground(ctx context.Context) error {
|
|
for {
|
|
if err := r.awaitClear(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
if r.background != nil {
|
|
if err := r.background.Wait(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// An interactive caller may have arrived while the background
|
|
// lane was pacing us. Go round again — a background task that
|
|
// never runs while the user is active is the intent, and the
|
|
// context is what ends the loop.
|
|
if r.pendingInteractive() {
|
|
continue
|
|
}
|
|
|
|
return r.limiter.Wait(ctx)
|
|
}
|
|
}
|
|
|
|
// enterInteractive registers an outstanding interactive wait.
|
|
func (r *RateLimiter) enterInteractive() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
if r.interactive == 0 {
|
|
r.clear = make(chan struct{})
|
|
}
|
|
|
|
r.interactive++
|
|
}
|
|
|
|
// exitInteractive retires one, releasing background waiters when the
|
|
// last interactive caller is done.
|
|
func (r *RateLimiter) exitInteractive() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
r.interactive--
|
|
|
|
if r.interactive <= 0 {
|
|
r.interactive = 0
|
|
|
|
if r.clear != nil {
|
|
close(r.clear)
|
|
r.clear = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// pendingInteractive reports whether any interactive wait is outstanding.
|
|
func (r *RateLimiter) pendingInteractive() bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
return r.interactive > 0
|
|
}
|
|
|
|
// awaitClear blocks until no interactive wait is outstanding.
|
|
func (r *RateLimiter) awaitClear(ctx context.Context) error {
|
|
for {
|
|
r.mu.Lock()
|
|
ch := r.clear
|
|
r.mu.Unlock()
|
|
|
|
if ch == nil {
|
|
return nil
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-ch:
|
|
}
|
|
}
|
|
}
|
|
|
|
// backgroundPriorityKey types the context marker below.
|
|
type backgroundPriorityKey struct{}
|
|
|
|
// WithBackgroundPriority marks a context as belonging to background
|
|
// work — a post-scan backfill rather than something a user is waiting
|
|
// on. Every RateLimiter.Wait made under it yields to interactive
|
|
// callers and is paced by the background lane.
|
|
//
|
|
// It is a context marker rather than a parameter because the marking
|
|
// has to survive the whole call chain — a backfill calls the same
|
|
// MusicBrainzClient methods the detail pages call, and threading a
|
|
// priority argument through all of them would put the decision at
|
|
// every call site instead of at the one place that knows.
|
|
func WithBackgroundPriority(ctx context.Context) context.Context {
|
|
return context.WithValue(ctx, backgroundPriorityKey{}, true)
|
|
}
|
|
|
|
// isBackgroundPriority reports whether ctx was marked by
|
|
// WithBackgroundPriority.
|
|
func isBackgroundPriority(ctx context.Context) bool {
|
|
v, _ := ctx.Value(backgroundPriorityKey{}).(bool)
|
|
|
|
return v
|
|
}
|