package explore import ( "archive/tar" "bufio" "context" "database/sql" "errors" "fmt" "io" "net/http" "regexp" "sort" "strconv" "strings" "time" ) // Incremental listen-count refresh. ListenBrainz publishes a small // (~250MB) incremental spark dump every day containing only the listens // submitted since the previous dump, in the same parquet-in-tar format // as the full dump. This folds those daily deltas into the index's // popularity numbers additively, so recordings, artists, and albums stay // fresh without re-streaming the ~170GB full dump and without a single // ListenBrainz API call. // // Album (release-group) popularity is derived locally: the incremental // gives per-release listen counts, which are rolled up to their release // group via the release_to_rg map captured during the full import. // // Correctness: each dump's deltas plus the high-water-mark advance are // applied in one transaction, so a crash can't half-apply a dump, and // the high-water-mark guarantees each dump is applied at most once. The // sum of the full dump plus every incremental is therefore the exact // cumulative listen count — the additive model does not drift. const ( // defaultIncrementalBaseURL is where ListenBrainz publishes daily // incremental listen dumps. defaultIncrementalBaseURL = "https://data.metabrainz.org/pub/musicbrainz/listenbrainz/incremental/" // listensCatchupTsKey records when the incremental refresh last ran // (RFC3339), gating how often it re-checks for new dumps. listensCatchupTsKey = "listens_last_catchup" // listensCatchupInterval is the default minimum time between refresh // checks. Album/track popularity is slow-moving and each dump is a // ~250MB download, so a weekly cadence keeps the index current while // bounding background network use. listensCatchupInterval = 7 * 24 * time.Hour // deltaInsertBatch bounds rows per multi-row INSERT into the temp // delta table during a single incremental apply. deltaInsertBatch = 500 // releaseLookupBatch bounds release MBIDs per release_to_rg lookup. releaseLookupBatch = 500 ) var ( incrementalDirRe = regexp.MustCompile(`^listenbrainz-dump-\d+-\d{8}-\d+-incremental$`) incrementalFileRe = regexp.MustCompile(`^listenbrainz-spark-dump-.*-incremental\.tar$`) ) // incrementalDump identifies one daily incremental dump. type incrementalDump struct { series int url string } // RefreshListenCounts folds any incremental dumps newer than the current // high-water-mark into the index's popularity numbers. It is a no-op // when there is no completed baseline import, when a full build is // running, when the last refresh was within minInterval (pass 0 to // force), or when offline. Runs synchronously — callers wanting the // background behaviour should invoke it in a goroutine. func (si *SearchIndex) RefreshListenCounts(ctx context.Context, minInterval time.Duration) { if !si.hasMeta(dumpImportDoneKey) { si.logger.Info("incremental refresh: no baseline import yet, skipping") return } si.mu.RLock() building := si.cancel != nil si.mu.RUnlock() if building { si.logger.Info("incremental refresh: full build running, skipping") return } if minInterval > 0 && si.refreshedWithin(minInterval) { si.logger.Info("incremental refresh: checked recently, skipping") return } hwm, ok := si.metaInt(listensAppliedSeriesKey) if !ok { si.logger.Warn("incremental refresh: no baseline series recorded, skipping") return } client := &http.Client{} dumps, err := discoverIncrementalDumps(ctx, client, defaultIncrementalBaseURL, hwm) if err != nil { si.logger.Warn("incremental refresh: discovery failed", "error", err) return } // Record the check even when there is nothing to apply, so the // cadence gate holds regardless of outcome. defer si.setMeta(listensCatchupTsKey, time.Now().UTC().Format(time.RFC3339)) if len(dumps) == 0 { si.logger.Info("incremental refresh: up to date", "throughSeries", hwm) return } si.logger.Info("incremental refresh: applying dumps", "count", len(dumps), "fromSeries", hwm+1, "toSeries", dumps[len(dumps)-1].series, ) applied := 0 through := hwm for _, d := range dumps { if ctx.Err() != nil { break } if err := si.applyIncremental(ctx, client, d); err != nil { // Stop at the first failure; the high-water-mark is only // advanced on success, so the next run resumes from here. si.logger.Warn("incremental refresh: apply failed, stopping", "series", d.series, "error", err) break } applied++ through = d.series } if applied > 0 { // Popularity changed, so refresh the champion tier that backs // generic short-prefix searches. si.scheduleChampionRebuild() } si.logger.Info("incremental refresh: complete", "applied", applied, "throughSeries", through) } // applyIncremental downloads one incremental dump, aggregates its listen // counts, rolls release counts up to release groups, and applies the // deltas atomically together with the high-water-mark advance. func (si *SearchIndex) applyIncremental( ctx context.Context, client *http.Client, dump incrementalDump, ) error { start := time.Now() stream := newResumableReader(ctx, client, dump.url, 0) defer func() { _ = stream.Close() }() counts, err := aggregateTarListens(ctx, stream) if err != nil { return fmt.Errorf("aggregate incremental %d: %w", dump.series, err) } rec, art, rel := splitCountsByKind(counts) rg := si.rollupReleaseDeltas(rel) if err := si.commitListenDeltas(dump.series, rec, art, rg); err != nil { return err } si.logger.Info("incremental refresh: applied dump", "series", dump.series, "recordings", len(rec), "artists", len(art), "releaseGroups", len(rg), "elapsed", time.Since(start).Round(time.Millisecond), ) return nil } // splitCountsByKind separates an aggregated counts map into per-kind // maps of canonical MBID string → delta. func splitCountsByKind(counts map[mbidKey]uint32) (rec, art, rel map[string]uint32) { rec = make(map[string]uint32) art = make(map[string]uint32) rel = make(map[string]uint32) for k, v := range counts { mbid := formatUUID(k[1:]) switch k[0] { case countKindRecording: rec[mbid] += v case countKindArtist: art[mbid] += v case countKindRelease: rel[mbid] += v } } return rec, art, rel } // rollupReleaseDeltas maps per-release listen deltas to their release // group via the release_to_rg table and sums per group. Releases not in // the table (below the index floor, or unknown) contribute nothing. func (si *SearchIndex) rollupReleaseDeltas(rel map[string]uint32) map[string]uint32 { rg := make(map[string]uint32) if len(rel) == 0 { return rg } mbids := make([]string, 0, len(rel)) for m := range rel { mbids = append(mbids, m) } for i := 0; i < len(mbids); i += releaseLookupBatch { end := min(i+releaseLookupBatch, len(mbids)) batch := mbids[i:end] placeholders := strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",") args := make([]any, len(batch)) for j, m := range batch { args[j] = m } rows, err := si.db.QueryContext( "SELECT release_mbid, rg_mbid FROM release_to_rg WHERE release_mbid IN ("+placeholders+")", args..., ) if err != nil { si.logger.Warn("incremental refresh: release_to_rg lookup failed", "error", err) continue } for rows.Next() { var relMBID, rgMBID string if err := rows.Scan(&relMBID, &rgMBID); err == nil { rg[rgMBID] += rel[relMBID] } } _ = rows.Close() } return rg } // commitListenDeltas applies recording, artist, and release-group deltas // to explore_index and advances the high-water-mark to series, all in a // single transaction so the apply is crash-atomic and exactly-once. func (si *SearchIndex) commitListenDeltas( series int, rec, art, rg map[string]uint32, ) error { tx, err := si.db.BeginTx() if err != nil { return fmt.Errorf("incremental tx: %w", err) } defer func() { _ = tx.Rollback() }() if _, err := tx.Exec( // mbid and kind are stored the way explore_index stores them, // so the join below is a plain equality rather than a // conversion per row. "CREATE TEMP TABLE IF NOT EXISTS incr_delta (mbid BLOB, kind INTEGER, delta INTEGER)", ); err != nil { return fmt.Errorf("incremental temp table: %w", err) } if _, err := tx.Exec("DELETE FROM incr_delta"); err != nil { return fmt.Errorf("incremental temp reset: %w", err) } for _, kd := range []struct { kind string deltas map[string]uint32 }{ {"recording", rec}, {"artist", art}, {"release_group", rg}, } { if err := insertDeltas(tx, kd.kind, kd.deltas); err != nil { return err } } // Additive apply: bump popularity for every index row that has a // matching delta. Rows with no delta are untouched; deltas with no // matching row (entity not indexed) are ignored. if _, err := tx.Exec(` UPDATE explore_index SET popularity = popularity + d.delta FROM incr_delta d WHERE d.mbid = explore_index.mbid AND d.kind = explore_index.entity_type `); err != nil { return fmt.Errorf("incremental apply: %w", err) } if _, err := tx.Exec("DELETE FROM incr_delta"); err != nil { return fmt.Errorf("incremental temp cleanup: %w", err) } if _, err := tx.Exec( "INSERT OR REPLACE INTO explore_index_meta (key, value) VALUES (?, ?)", listensAppliedSeriesKey, strconv.Itoa(series), ); err != nil { return fmt.Errorf("incremental advance high-water-mark: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("incremental commit: %w", err) } return nil } // insertDeltas bulk-inserts a kind's deltas into the temp table. func insertDeltas(tx *sql.Tx, kind string, deltas map[string]uint32) error { if len(deltas) == 0 { return nil } rowArgs := make([]any, 0, deltaInsertBatch*3) pending := 0 flush := func() error { if pending == 0 { return nil } values := strings.TrimSuffix(strings.Repeat("(?,?,?),", pending), ",") query := "INSERT INTO incr_delta (mbid, kind, delta) VALUES " + values if _, err := tx.Exec(query, rowArgs...); err != nil { return fmt.Errorf("incremental insert deltas: %w", err) } rowArgs = rowArgs[:0] pending = 0 return nil } code := entityCode(kind) for mbid, d := range deltas { rowArgs = append(rowArgs, mbidBytes(mbid), code, int64(d)) pending++ if pending >= deltaInsertBatch { if err := flush(); err != nil { return err } } } return flush() } // aggregateTarListens streams a tar of parquet listen members and sums // the per-entity listen counts. Unlike the full-dump path, the whole // (small) incremental is aggregated in RAM with no checkpointing. func aggregateTarListens(ctx context.Context, r io.Reader) (map[mbidKey]uint32, error) { buffered := bufio.NewReaderSize(r, 1<<20) tr := tar.NewReader(buffered) counts := make(map[mbidKey]uint32, 1<<18) for { if err := ctx.Err(); err != nil { return nil, err } hdr, err := tr.Next() if errors.Is(err, io.EOF) { break } if err != nil { return nil, fmt.Errorf("incremental tar: %w", err) } if hdr.Typeflag != tar.TypeReg || !strings.HasSuffix(hdr.Name, ".parquet") { continue } if hdr.Size > maxParquetMemberSize { return nil, fmt.Errorf( "%w: parquet member %s is %d bytes", ErrDumpFormat, hdr.Name, hdr.Size, ) } buf := make([]byte, hdr.Size) if _, err := io.ReadFull(tr, buf); err != nil { return nil, fmt.Errorf("incremental member read: %w", err) } deltas, err := parseListenParquet(buf) if err != nil { return nil, fmt.Errorf("incremental parse: %w", err) } for k, v := range deltas { counts[k] += v } } return counts, nil } // discoverIncrementalDumps lists the incremental directory and returns // the spark-dump URLs for every dump with a series greater than // sinceSeries, sorted ascending so they apply in chronological order. func discoverIncrementalDumps( ctx context.Context, client *http.Client, baseURL string, sinceSeries int, ) ([]incrementalDump, error) { hrefs, err := listHrefs(ctx, client, baseURL) if err != nil { return nil, err } var dumps []incrementalDump for _, h := range hrefs { dir := trimTrailingSlash(h) if !incrementalDirRe.MatchString(dir) { continue } series, ok := parseDumpSeries(dir) if !ok || series <= sinceSeries { continue } dirURL := baseURL + dir + "/" files, err := listHrefs(ctx, client, dirURL) if err != nil { continue } for _, f := range files { if incrementalFileRe.MatchString(f) { dumps = append(dumps, incrementalDump{series: series, url: dirURL + f}) break } } } sort.Slice(dumps, func(i, j int) bool { return dumps[i].series < dumps[j].series }) return dumps, nil } // metaInt reads an integer-valued explore_index_meta key. func (si *SearchIndex) metaInt(key string) (int, bool) { rows, err := si.db.QueryContext("SELECT value FROM explore_index_meta WHERE key = ?", key) if err != nil { return 0, false } defer func() { _ = rows.Close() }() if !rows.Next() { return 0, false } var v string if err := rows.Scan(&v); err != nil { return 0, false } n, err := strconv.Atoi(v) if err != nil { return 0, false } return n, true } // refreshedWithin reports whether the incremental refresh last ran less // than d ago. func (si *SearchIndex) refreshedWithin(d time.Duration) bool { rows, err := si.db.QueryContext( "SELECT value FROM explore_index_meta WHERE key = ?", listensCatchupTsKey, ) if err != nil { return false } defer func() { _ = rows.Close() }() if !rows.Next() { return false } var v string if err := rows.Scan(&v); err != nil { return false } t, err := time.Parse(time.RFC3339, v) if err != nil { return false } return time.Since(t) < d }