perf(explore): make the owned-artist backfill yield, mark, and stop
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
This commit is contained in:
@@ -4,6 +4,7 @@ package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
@@ -14,9 +15,31 @@ import (
|
||||
// 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
|
||||
@@ -56,9 +79,151 @@ func NewRateLimiterBurst(n, b int) *RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user