Ships the fresh-start schema cleanup: rebuilt explore catalog index pipeline (dump import, artifact fetch/build, incremental listen-count refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/ slskd/yt-dlp providers, staging, reconciliation, wanted list), and the supporting schema/query/store changes across backend and frontend. Also includes two smaller follow-ups: bump the central index's rebuild-after cadence from 90 to 180 days, and remove the Explore "library only" online/offline toggle entirely (frontend-only, no backend counterpart) rather than carry unused UI/state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
563 lines
14 KiB
Go
563 lines
14 KiB
Go
//go:build indexbuild
|
|
|
|
package explore
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Parallel dump streaming. MetaBrainz shapes throughput per
|
|
// connection, so a single sequential stream of the listens dump tops
|
|
// out near 5 MB/s no matter how fast the link is — the 205GB stage-1
|
|
// download alone runs for eleven hours that way. Several concurrent
|
|
// Range requests lift that to tens of MB/s.
|
|
//
|
|
// parallelReader hides the concurrency behind the same sequential
|
|
// io.Reader the tar decoders already consume: lanes fetch fixed-size
|
|
// chunks ahead of the caller, and chunks are handed over strictly in
|
|
// order. Pos() therefore keeps meaning "absolute offset of the next
|
|
// byte to be delivered", which is what the stage-1 checkpoint records.
|
|
//
|
|
// For the listens dump this is now the fallback: dumpproject.go fetches
|
|
// only the columns stage 1 reads, which is well under half the bytes.
|
|
// This path still serves the canonical dump (a zstd stream that cannot
|
|
// be range-projected) and any origin that refuses Range requests.
|
|
//
|
|
// A caution on the rates quoted below: measured again on 2026-07-28,
|
|
// data.metabrainz.org served ~0.65 MB/s aggregate to one client and got
|
|
// *slower* past eight connections — the shaping is per-IP, not per
|
|
// connection, so raising dumpLanes buys throttling rather than speed.
|
|
|
|
const (
|
|
// dumpLanes is the number of concurrent Range requests. One lane
|
|
// gets ~5 MB/s because MetaBrainz shapes per connection; four reach
|
|
// tens of MB/s. Deliberately conservative: these are a nonprofit's
|
|
// servers, they answer sustained heavy use with 503s, and pushing
|
|
// past this trades politeness for throughput that backoff eats
|
|
// anyway.
|
|
dumpLanes = 4
|
|
|
|
// dumpChunkSize is how much a lane fetches per request. Each
|
|
// request pays a ramp-up cost, so small chunks squander the gain —
|
|
// 32MB chunks measured roughly 40% slower than 128MB ones.
|
|
dumpChunkSize = 128 << 20
|
|
|
|
// dumpWindowChunks bounds the chunks in flight or buffered awaiting
|
|
// in-order delivery. Kept just above the lane count so lanes never
|
|
// idle waiting for the consumer; costs dumpWindowChunks *
|
|
// dumpChunkSize of buffer.
|
|
dumpWindowChunks = dumpLanes + 2
|
|
|
|
// dumpProbeTimeout bounds the HEAD request that sizes a resource
|
|
// before a parallel stream starts.
|
|
dumpProbeTimeout = 30 * time.Second
|
|
|
|
// dumpMaxIdleConns keeps a pooled connection per lane so chunk
|
|
// requests reuse TCP+TLS instead of reconnecting each time.
|
|
dumpMaxIdleConns = dumpLanes * 2
|
|
|
|
// dumpRetryAfterCap bounds how long a server-supplied Retry-After is
|
|
// honoured, so a bad header can't park a lane indefinitely.
|
|
dumpRetryAfterCap = 60 * time.Second
|
|
)
|
|
|
|
// dumpStream is the streaming surface the dump importers consume,
|
|
// implemented by both parallelReader and resumableReader.
|
|
type dumpStream interface {
|
|
io.ReadCloser
|
|
|
|
// Pos is the absolute byte offset of the next byte to be delivered.
|
|
// This is what an interrupted import checkpoints and resumes from.
|
|
Pos() int64
|
|
|
|
// Fetched is the absolute byte offset the downloader has reached.
|
|
// With prefetching lanes this runs ahead of Pos, and it — not Pos —
|
|
// is what progress and stall reporting should watch: a reader
|
|
// buffering a 128MB chunk is downloading, not stalled.
|
|
Fetched() int64
|
|
|
|
// Total is the total resource size, or -1 while unknown.
|
|
Total() int64
|
|
}
|
|
|
|
// newDumpHTTPClient builds the client used for dump discovery and
|
|
// streaming. HTTP/2 is disabled deliberately: it multiplexes every
|
|
// lane onto a single TCP connection, which collapses parallel Range
|
|
// requests back to one shaped stream (measured at 1-3 MB/s).
|
|
func newDumpHTTPClient() *http.Client {
|
|
transport := &http.Transport{
|
|
// Explicitly HTTP/1.1: ALPN would otherwise negotiate h2 and
|
|
// silently undo the parallelism below.
|
|
TLSClientConfig: &tls.Config{
|
|
NextProtos: []string{"http/1.1"},
|
|
MinVersion: tls.VersionTLS12,
|
|
},
|
|
ForceAttemptHTTP2: false,
|
|
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
|
|
MaxIdleConns: dumpMaxIdleConns,
|
|
MaxIdleConnsPerHost: dumpMaxIdleConns,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
}
|
|
|
|
// No client-level timeout: dump streams run for hours. Per-request
|
|
// deadlines come from the caller's context, and chunk fetches retry
|
|
// on their own.
|
|
return &http.Client{Transport: transport}
|
|
}
|
|
|
|
// probeDumpSize returns the resource size when the server advertises
|
|
// one and supports Range requests, else (0, false).
|
|
func probeDumpSize(ctx context.Context, client *http.Client, url string) (int64, bool) {
|
|
reqCtx, cancel := context.WithTimeout(ctx, dumpProbeTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodHead, url, nil)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
req.Header.Set("User-Agent", lbUserAgent)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK || resp.ContentLength <= 0 {
|
|
return 0, false
|
|
}
|
|
|
|
if resp.Header.Get("Accept-Ranges") != "bytes" {
|
|
return 0, false
|
|
}
|
|
|
|
return resp.ContentLength, true
|
|
}
|
|
|
|
// chunkResult is one fetched chunk awaiting in-order delivery. bufp is
|
|
// the pooled backing array, returned to the pool once drained.
|
|
type chunkResult struct {
|
|
bufp *[]byte
|
|
n int
|
|
err error
|
|
}
|
|
|
|
// parallelReader streams [base, size) of an HTTP resource through
|
|
// several concurrent Range requests, delivering bytes in order.
|
|
type parallelReader struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
client *http.Client
|
|
url string
|
|
logger *slog.Logger
|
|
|
|
base int64
|
|
size int64
|
|
totalChunks int64
|
|
|
|
// lanes, chunkSize and window are fields rather than constants so
|
|
// tests can drive the reader with small chunks.
|
|
lanes int
|
|
chunkSize int64
|
|
window int64
|
|
|
|
mu sync.Mutex
|
|
cond *sync.Cond
|
|
ready map[int64]*chunkResult
|
|
nextDL int64 // next chunk index to hand to a lane
|
|
nextOut int64 // next chunk index to deliver
|
|
closed bool
|
|
|
|
delivered atomic.Int64
|
|
|
|
// fetched counts bytes pulled down by the lanes, including chunks
|
|
// still buffered ahead of the caller.
|
|
fetched atomic.Int64
|
|
|
|
// cur is the undelivered tail of the chunk being drained.
|
|
cur []byte
|
|
curBufp *[]byte
|
|
|
|
pool sync.Pool
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// newParallelReader starts the lanes and returns a reader positioned at
|
|
// offset. Returns nil when the resource can't be range-streamed, so
|
|
// callers fall back to a sequential reader.
|
|
func newParallelReader(
|
|
ctx context.Context, client *http.Client, logger *slog.Logger, url string, offset int64,
|
|
) *parallelReader {
|
|
return newParallelReaderLogged(
|
|
ctx, client, logger, url, offset, dumpLanes, dumpChunkSize, dumpWindowChunks,
|
|
)
|
|
}
|
|
|
|
// newParallelReaderWith is newParallelReader with the lane geometry
|
|
// spelled out, so tests can exercise ordering and resume with chunks
|
|
// small enough to be practical.
|
|
func newParallelReaderWith(
|
|
ctx context.Context, client *http.Client, url string, offset int64,
|
|
lanes int, chunkSize, window int64,
|
|
) *parallelReader {
|
|
return newParallelReaderLogged(
|
|
ctx, client, slog.New(slog.DiscardHandler), url, offset, lanes, chunkSize, window,
|
|
)
|
|
}
|
|
|
|
// newParallelReaderLogged is newParallelReaderWith with a logger, so a
|
|
// throttled or retrying stream is diagnosable from the app log.
|
|
func newParallelReaderLogged(
|
|
ctx context.Context, client *http.Client, logger *slog.Logger,
|
|
url string, offset int64, lanes int, chunkSize, window int64,
|
|
) *parallelReader {
|
|
size, ok := probeDumpSize(ctx, client, url)
|
|
if !ok || offset >= size {
|
|
return nil
|
|
}
|
|
|
|
// Below a couple of chunks there's nothing to parallelise.
|
|
if size-offset < 2*chunkSize {
|
|
return nil
|
|
}
|
|
|
|
if logger == nil {
|
|
logger = slog.New(slog.DiscardHandler)
|
|
}
|
|
|
|
streamCtx, cancel := context.WithCancel(ctx)
|
|
|
|
p := ¶llelReader{
|
|
ctx: streamCtx,
|
|
cancel: cancel,
|
|
client: client,
|
|
logger: logger,
|
|
url: url,
|
|
base: offset,
|
|
size: size,
|
|
lanes: lanes,
|
|
chunkSize: chunkSize,
|
|
window: window,
|
|
ready: make(map[int64]*chunkResult, window),
|
|
pool: sync.Pool{New: func() any {
|
|
b := make([]byte, chunkSize)
|
|
|
|
return &b
|
|
}},
|
|
}
|
|
|
|
remaining := size - offset
|
|
p.totalChunks = (remaining + chunkSize - 1) / chunkSize
|
|
p.cond = sync.NewCond(&p.mu)
|
|
|
|
for range lanes {
|
|
p.wg.Add(1)
|
|
|
|
go p.lane()
|
|
}
|
|
|
|
// A cancelled context must wake anyone blocked on the condition.
|
|
go func() {
|
|
<-streamCtx.Done()
|
|
|
|
p.mu.Lock()
|
|
p.cond.Broadcast()
|
|
p.mu.Unlock()
|
|
}()
|
|
|
|
return p
|
|
}
|
|
|
|
// Pos returns the absolute offset of the next byte to be delivered.
|
|
func (p *parallelReader) Pos() int64 {
|
|
return p.base + p.delivered.Load()
|
|
}
|
|
|
|
// Fetched returns the absolute offset the lanes have downloaded to.
|
|
func (p *parallelReader) Fetched() int64 {
|
|
return p.base + p.fetched.Load()
|
|
}
|
|
|
|
// Total returns the total resource size.
|
|
func (p *parallelReader) Total() int64 {
|
|
return p.size
|
|
}
|
|
|
|
// lane fetches chunks until the window is exhausted or the stream ends.
|
|
func (p *parallelReader) lane() {
|
|
defer p.wg.Done()
|
|
|
|
for {
|
|
p.mu.Lock()
|
|
|
|
for {
|
|
if p.closed || p.ctx.Err() != nil || p.nextDL >= p.totalChunks {
|
|
p.mu.Unlock()
|
|
|
|
return
|
|
}
|
|
|
|
// Stay inside the delivery window so buffered chunks can't
|
|
// outrun the consumer.
|
|
if p.nextDL < p.nextOut+p.window {
|
|
break
|
|
}
|
|
|
|
p.cond.Wait()
|
|
}
|
|
|
|
idx := p.nextDL
|
|
p.nextDL++
|
|
|
|
p.mu.Unlock()
|
|
|
|
bufp, n, err := p.fetchChunk(idx)
|
|
if err == nil {
|
|
p.fetched.Add(int64(n))
|
|
}
|
|
|
|
p.mu.Lock()
|
|
p.ready[idx] = &chunkResult{bufp: bufp, n: n, err: err}
|
|
p.cond.Broadcast()
|
|
p.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// fetchChunk retrieves one chunk, retrying transient failures. The
|
|
// range is fully specified, so a retry simply re-requests it.
|
|
func (p *parallelReader) fetchChunk(idx int64) (*[]byte, int, error) {
|
|
lo := p.base + idx*p.chunkSize
|
|
|
|
hi := lo + p.chunkSize - 1
|
|
if hi >= p.size {
|
|
hi = p.size - 1
|
|
}
|
|
|
|
want := int(hi - lo + 1)
|
|
|
|
bufp, _ := p.pool.Get().(*[]byte)
|
|
|
|
var (
|
|
lastErr error
|
|
wait time.Duration
|
|
)
|
|
|
|
for attempt := 0; attempt <= maxStreamRetries; attempt++ {
|
|
if attempt > 0 {
|
|
delay := min(streamRetryBaseDelay<<(attempt-1), streamRetryMaxDelay)
|
|
if wait > 0 {
|
|
delay = wait
|
|
}
|
|
|
|
select {
|
|
case <-p.ctx.Done():
|
|
p.pool.Put(bufp)
|
|
|
|
return nil, 0, p.ctx.Err()
|
|
case <-time.After(delay):
|
|
}
|
|
}
|
|
|
|
n, retryAfter, err := p.fetchOnce(lo, hi, (*bufp)[:want])
|
|
if err == nil && n == want {
|
|
return bufp, n, nil
|
|
}
|
|
|
|
if err == nil {
|
|
err = io.ErrUnexpectedEOF
|
|
}
|
|
|
|
lastErr = err
|
|
wait = retryAfter
|
|
|
|
p.logger.Warn("dump import: chunk fetch failed, retrying",
|
|
"chunk", idx,
|
|
"attempt", attempt+1,
|
|
"retryAfter", retryAfter,
|
|
"error", err,
|
|
)
|
|
|
|
if p.ctx.Err() != nil {
|
|
p.pool.Put(bufp)
|
|
|
|
return nil, 0, p.ctx.Err()
|
|
}
|
|
}
|
|
|
|
p.pool.Put(bufp)
|
|
|
|
return nil, 0, fmt.Errorf("%w: %s chunk %d after %d retries: %w",
|
|
ErrDumpStream, p.url, idx, maxStreamRetries, lastErr)
|
|
}
|
|
|
|
// fetchOnce performs a single Range request into buf. The second
|
|
// return value is the server's requested Retry-After delay, if any.
|
|
func (p *parallelReader) fetchOnce(lo, hi int64, buf []byte) (int, time.Duration, error) {
|
|
req, err := http.NewRequestWithContext(p.ctx, http.MethodGet, p.url, nil)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("dump chunk request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("User-Agent", lbUserAgent)
|
|
req.Header.Set("Range", "bytes="+strconv.FormatInt(lo, 10)+"-"+strconv.FormatInt(hi, 10))
|
|
|
|
resp, err := p.client.Do(req)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("dump chunk fetch: %w", err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusPartialContent {
|
|
// A loaded dump server answers with 503 (or 429) rather than
|
|
// queueing; that is a "come back shortly", not a failure.
|
|
return 0, parseRetryAfter(resp.Header.Get("Retry-After")),
|
|
fmt.Errorf("%w: HTTP %d from %s", ErrDumpStream, resp.StatusCode, p.url)
|
|
}
|
|
|
|
n, err := io.ReadFull(resp.Body, buf)
|
|
if err != nil {
|
|
return n, 0, fmt.Errorf("dump chunk read: %w", err)
|
|
}
|
|
|
|
return n, 0, nil
|
|
}
|
|
|
|
// parseRetryAfter reads a Retry-After header given in seconds, clamped
|
|
// to dumpRetryAfterCap. Returns 0 when absent or unparseable.
|
|
func parseRetryAfter(v string) time.Duration {
|
|
if v == "" {
|
|
return 0
|
|
}
|
|
|
|
secs, err := strconv.Atoi(v)
|
|
if err != nil || secs <= 0 {
|
|
return 0
|
|
}
|
|
|
|
return min(time.Duration(secs)*time.Second, dumpRetryAfterCap)
|
|
}
|
|
|
|
func (p *parallelReader) Read(b []byte) (int, error) {
|
|
for len(p.cur) == 0 {
|
|
if err := p.ctx.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
res, err := p.nextChunk()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
p.curBufp = res.bufp
|
|
p.cur = (*res.bufp)[:res.n]
|
|
}
|
|
|
|
n := copy(b, p.cur)
|
|
p.cur = p.cur[n:]
|
|
p.delivered.Add(int64(n))
|
|
|
|
if len(p.cur) == 0 && p.curBufp != nil {
|
|
p.pool.Put(p.curBufp)
|
|
p.curBufp = nil
|
|
}
|
|
|
|
return n, nil
|
|
}
|
|
|
|
// nextChunk blocks until the next in-order chunk is available.
|
|
func (p *parallelReader) nextChunk() (*chunkResult, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
for {
|
|
if p.closed {
|
|
return nil, io.ErrClosedPipe
|
|
}
|
|
|
|
if err := p.ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if p.nextOut >= p.totalChunks {
|
|
return nil, io.EOF
|
|
}
|
|
|
|
res, ok := p.ready[p.nextOut]
|
|
if ok {
|
|
delete(p.ready, p.nextOut)
|
|
|
|
p.nextOut++
|
|
|
|
// A freed window slot may unblock a waiting lane.
|
|
p.cond.Broadcast()
|
|
|
|
if res.err != nil {
|
|
return nil, res.err
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
p.cond.Wait()
|
|
}
|
|
}
|
|
|
|
// Close stops the lanes and releases buffered chunks.
|
|
func (p *parallelReader) Close() error {
|
|
p.mu.Lock()
|
|
|
|
if p.closed {
|
|
p.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
p.closed = true
|
|
p.cond.Broadcast()
|
|
p.mu.Unlock()
|
|
|
|
p.cancel()
|
|
p.wg.Wait()
|
|
|
|
p.mu.Lock()
|
|
clear(p.ready)
|
|
p.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// openDumpStream returns the best available stream for a dump URL,
|
|
// preferring parallel Range lanes and falling back to a single
|
|
// resumable connection when the server won't serve ranges.
|
|
func (imp *dumpImporter) openDumpStream(
|
|
ctx context.Context, url string, offset int64,
|
|
) dumpStream {
|
|
if p := newParallelReader(ctx, imp.httpClient, imp.logger, url, offset); p != nil {
|
|
imp.logger.Info("dump import: streaming in parallel",
|
|
"lanes", dumpLanes,
|
|
"chunkMB", dumpChunkSize>>20,
|
|
"url", url,
|
|
)
|
|
|
|
return p
|
|
}
|
|
|
|
imp.logger.Info("dump import: parallel streaming unavailable, using single stream",
|
|
"url", url,
|
|
)
|
|
|
|
return newResumableReader(ctx, imp.httpClient, url, offset)
|
|
}
|