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
717 lines
18 KiB
Go
717 lines
18 KiB
Go
//go:build indexbuild
|
|
|
|
package explore
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"yellowjacket/backend/database"
|
|
)
|
|
|
|
// serveRangeBlob serves payload with Range support, counting the bytes
|
|
// actually delivered so a test can assert how much crossed the wire.
|
|
func serveRangeBlob(t *testing.T, payload []byte) (*httptest.Server, *atomic.Int64) {
|
|
t.Helper()
|
|
|
|
var served atomic.Int64
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
rec := &countingResponseWriter{ResponseWriter: w, n: &served}
|
|
http.ServeContent(rec, r, "dump.tar", time.Unix(0, 0), bytes.NewReader(payload))
|
|
},
|
|
))
|
|
|
|
t.Cleanup(srv.Close)
|
|
|
|
return srv, &served
|
|
}
|
|
|
|
type countingResponseWriter struct {
|
|
http.ResponseWriter
|
|
|
|
n *atomic.Int64
|
|
}
|
|
|
|
func (w *countingResponseWriter) Write(b []byte) (int, error) {
|
|
n, err := w.ResponseWriter.Write(b)
|
|
w.n.Add(int64(n))
|
|
|
|
return n, err
|
|
}
|
|
|
|
// bigSparkTar builds a tar whose single parquet member has enough rows
|
|
// that the unprojected columns dominate its size.
|
|
func bigSparkTar(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
rows := make([]sparkFixtureRow, 0, 20_000)
|
|
|
|
for i := range 20_000 {
|
|
rows = append(rows, sparkFixtureRow{
|
|
ListenedAt: int64(i),
|
|
UserID: int64(i % 977),
|
|
// A high-cardinality column that is not projected: it is what
|
|
// projection must avoid downloading.
|
|
ArtistName: strings.Repeat("padding-", 12) + string(rune('a'+i%26)) + itoa(i),
|
|
RecordingMBID: recA,
|
|
ReleaseMBID: relA,
|
|
ArtistMBIDs: []string{artA},
|
|
})
|
|
}
|
|
|
|
name := "listenbrainz-spark-dump-1-20260101-000003-full/1.parquet"
|
|
|
|
return makeTar(t, map[string][]byte{name: makeParquet(t, rows)}, []string{name})
|
|
}
|
|
|
|
func itoa(i int) string {
|
|
if i == 0 {
|
|
return "0"
|
|
}
|
|
|
|
var b []byte
|
|
|
|
for i > 0 {
|
|
b = append([]byte{byte('0' + i%10)}, b...)
|
|
i /= 10
|
|
}
|
|
|
|
return string(b)
|
|
}
|
|
|
|
func TestWalkTarMembersFindsMembersWithoutReadingThem(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := fixtureSparkTar(t)
|
|
srv, served := serveRangeBlob(t, payload)
|
|
|
|
f := &rangeFetcher{ctx: t.Context(), client: srv.Client(), url: srv.URL}
|
|
out := make(chan tarMember, 8)
|
|
|
|
if err := walkTarMembers(t.Context(), f, 0, int64(len(payload)), out); err != nil {
|
|
t.Fatalf("walk: %v", err)
|
|
}
|
|
|
|
var members []tarMember
|
|
for m := range out {
|
|
members = append(members, m)
|
|
}
|
|
|
|
if len(members) != 2 {
|
|
t.Fatalf("got %d members, want 2", len(members))
|
|
}
|
|
|
|
for i, m := range members {
|
|
if !strings.HasSuffix(m.name, ".parquet") {
|
|
t.Errorf("member %d: name %q", i, m.name)
|
|
}
|
|
|
|
if m.size <= 0 {
|
|
t.Errorf("member %d: size %d", i, m.size)
|
|
}
|
|
|
|
// The member's declared bytes must match what the tar really holds.
|
|
want := payload[m.dataOffset : m.dataOffset+m.size]
|
|
if !bytes.HasPrefix(want, []byte("PAR1")) {
|
|
t.Errorf("member %d: data offset %d does not start a parquet file", i, m.dataOffset)
|
|
}
|
|
}
|
|
|
|
// The walk must read headers only — not the multi-KB member bodies.
|
|
if n := served.Load(); n > int64(4*tarHeaderSize) {
|
|
t.Errorf("walk downloaded %d bytes, want only tar headers", n)
|
|
}
|
|
}
|
|
|
|
func TestFetchProjectedMemberMatchesFullParse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := bigSparkTar(t)
|
|
srv, served := serveRangeBlob(t, payload)
|
|
|
|
f := &rangeFetcher{ctx: t.Context(), client: srv.Client(), url: srv.URL}
|
|
out := make(chan tarMember, 4)
|
|
|
|
if err := walkTarMembers(t.Context(), f, 0, int64(len(payload)), out); err != nil {
|
|
t.Fatalf("walk: %v", err)
|
|
}
|
|
|
|
var member tarMember
|
|
|
|
for m := range out {
|
|
if strings.HasSuffix(m.name, ".parquet") {
|
|
member = m
|
|
}
|
|
}
|
|
|
|
if member.size == 0 {
|
|
t.Fatal("no parquet member found")
|
|
}
|
|
|
|
// Ground truth: parse the member as the sequential path would.
|
|
full := payload[member.dataOffset : member.dataOffset+member.size]
|
|
|
|
wantDeltas, err := parseListenParquet(full)
|
|
if err != nil {
|
|
t.Fatalf("full parse: %v", err)
|
|
}
|
|
|
|
served.Store(0)
|
|
|
|
buf := make([]byte, member.size)
|
|
|
|
fetched, err := fetchProjectedMember(t.Context(), f, member, buf)
|
|
if err != nil {
|
|
t.Fatalf("projected fetch: %v", err)
|
|
}
|
|
|
|
gotDeltas, err := parseListenParquet(buf)
|
|
if err != nil {
|
|
t.Fatalf("projected parse: %v", err)
|
|
}
|
|
|
|
if len(gotDeltas) != len(wantDeltas) {
|
|
t.Fatalf("projected parse produced %d entities, want %d", len(gotDeltas), len(wantDeltas))
|
|
}
|
|
|
|
for k, want := range wantDeltas {
|
|
if got := gotDeltas[k]; got != want {
|
|
t.Errorf("entity %x: count %d, want %d", k, got, want)
|
|
}
|
|
}
|
|
|
|
// The point of the exercise: materially fewer bytes than the member.
|
|
if fetched >= member.size {
|
|
t.Errorf("projected fetch pulled %d bytes of a %d byte member", fetched, member.size)
|
|
}
|
|
|
|
t.Logf("projected fetch: %d of %d bytes (%.1f%%)",
|
|
fetched, member.size, 100*float64(fetched)/float64(member.size))
|
|
}
|
|
|
|
func TestProjectedMemberRangesSkipsUnwantedColumns(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := bigSparkTar(t)
|
|
srv, _ := serveRangeBlob(t, payload)
|
|
|
|
f := &rangeFetcher{ctx: t.Context(), client: srv.Client(), url: srv.URL}
|
|
out := make(chan tarMember, 4)
|
|
|
|
if err := walkTarMembers(t.Context(), f, 0, int64(len(payload)), out); err != nil {
|
|
t.Fatalf("walk: %v", err)
|
|
}
|
|
|
|
var member tarMember
|
|
|
|
for m := range out {
|
|
if strings.HasSuffix(m.name, ".parquet") {
|
|
member = m
|
|
}
|
|
}
|
|
|
|
buf := make([]byte, member.size)
|
|
footerLen := min(f.probeBytes(), member.size)
|
|
tailStart := member.dataOffset + member.size - footerLen
|
|
copy(buf[member.size-footerLen:], payload[tailStart:member.dataOffset+member.size])
|
|
|
|
meta, err := readFooterMetadata(buf, member.size, footerLen)
|
|
if err != nil {
|
|
t.Fatalf("footer: %v", err)
|
|
}
|
|
|
|
ranges := projectedMemberRanges(meta, member.size, footerLen)
|
|
if len(ranges) == 0 {
|
|
t.Fatal("no ranges computed")
|
|
}
|
|
|
|
var total int64
|
|
|
|
for _, r := range ranges {
|
|
if r.lo < 0 || r.hi > member.size || r.hi <= r.lo {
|
|
t.Fatalf("range %v out of bounds for member size %d", r, member.size)
|
|
}
|
|
|
|
total += r.len()
|
|
}
|
|
|
|
if total >= member.size {
|
|
t.Errorf("projected ranges cover %d of %d bytes", total, member.size)
|
|
}
|
|
}
|
|
|
|
func TestCoalesceRangesMergesNeighbours(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
in []byteRange
|
|
want []byteRange
|
|
}{
|
|
{
|
|
name: "adjacent merge",
|
|
in: []byteRange{{0, 100}, {100, 200}},
|
|
want: []byteRange{{0, 200}},
|
|
},
|
|
{
|
|
name: "small gap merges",
|
|
in: []byteRange{{0, 100}, {100 + rangeGapCoalesce - 1, 500}},
|
|
want: []byteRange{{0, 500}},
|
|
},
|
|
{
|
|
name: "large gap stays split",
|
|
in: []byteRange{{0, 100}, {100 + rangeGapCoalesce + 1, 500}},
|
|
want: []byteRange{{0, 100}, {100 + rangeGapCoalesce + 1, 500}},
|
|
},
|
|
{
|
|
name: "unsorted input",
|
|
in: []byteRange{{10 * rangeGapCoalesce, 10*rangeGapCoalesce + 100}, {0, 100}},
|
|
want: []byteRange{{0, 100}, {10 * rangeGapCoalesce, 10*rangeGapCoalesce + 100}},
|
|
},
|
|
{
|
|
name: "overlap",
|
|
in: []byteRange{{0, 300}, {100, 200}},
|
|
want: []byteRange{{0, 300}},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := coalesceRanges(tt.in)
|
|
if len(got) != len(tt.want) {
|
|
t.Fatalf("got %v, want %v", got, tt.want)
|
|
}
|
|
|
|
for i := range got {
|
|
if got[i] != tt.want[i] {
|
|
t.Fatalf("got %v, want %v", got, tt.want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetchProjectedMemberRejectsWrongBuffer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
f := &rangeFetcher{
|
|
ctx: context.Background(),
|
|
client: http.DefaultClient,
|
|
url: "http://example.invalid",
|
|
}
|
|
|
|
if _, err := fetchProjectedMember(
|
|
t.Context(), f, tarMember{size: 100}, make([]byte, 50),
|
|
); err == nil {
|
|
t.Fatal("expected an error for a mis-sized buffer")
|
|
}
|
|
}
|
|
|
|
// serveDumpNoRanges serves the spark dump without advertising Range
|
|
// support, which is what forces the streamed fallback.
|
|
func serveDumpNoRanges(t *testing.T, sparkTar []byte) *httptest.Server {
|
|
t.Helper()
|
|
|
|
const (
|
|
listensDir = "listenbrainz-dump-1-20260101-000003-full"
|
|
sparkFile = "listenbrainz-spark-dump-1-20260101-000003-full.tar"
|
|
)
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/listens/", func(w http.ResponseWriter, r *http.Request) {
|
|
switch strings.TrimPrefix(r.URL.Path, "/listens/") {
|
|
case "":
|
|
_, _ = fmt.Fprintf(w, `<a href="%s/">%s/</a>`, listensDir, listensDir)
|
|
case listensDir + "/":
|
|
_, _ = fmt.Fprintf(w, `<a href="%s">%s</a>`, sparkFile, sparkFile)
|
|
case listensDir + "/" + sparkFile:
|
|
// No Accept-Ranges, and Range headers are ignored.
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(sparkTar)))
|
|
_, _ = w.Write(sparkTar)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
})
|
|
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
|
|
return srv
|
|
}
|
|
|
|
// The projected and streamed paths must agree exactly: projection is an
|
|
// optimisation, not a different answer.
|
|
func TestProjectedAndStreamedCountsAgree(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
sparkTar := bigSparkTar(t)
|
|
|
|
counts := func(srv *httptest.Server) *countsState {
|
|
t.Helper()
|
|
|
|
db := database.NewTestDB(t)
|
|
si := NewSearchIndex(db, nil, nil, testLogger())
|
|
imp := testImporter(t, si, srv)
|
|
|
|
url := srv.URL + "/listens/listenbrainz-dump-1-20260101-000003-full/" +
|
|
"listenbrainz-spark-dump-1-20260101-000003-full.tar"
|
|
|
|
st := &countsState{SparkURL: url}
|
|
if err := imp.aggregateListenCounts(t.Context(), st); err != nil {
|
|
t.Fatalf("aggregate: %v", err)
|
|
}
|
|
|
|
if !st.Done {
|
|
t.Fatal("aggregation did not complete")
|
|
}
|
|
|
|
return st
|
|
}
|
|
|
|
ranged := serveDumps(t, sparkTar, nil)
|
|
plain := serveDumpNoRanges(t, sparkTar)
|
|
|
|
projected := counts(ranged)
|
|
streamed := counts(plain)
|
|
|
|
if len(projected.counts) == 0 {
|
|
t.Fatal("projected run produced no counts")
|
|
}
|
|
|
|
if len(projected.counts) != len(streamed.counts) {
|
|
t.Fatalf("projected %d entities, streamed %d",
|
|
len(projected.counts), len(streamed.counts))
|
|
}
|
|
|
|
for k, want := range streamed.counts {
|
|
if got := projected.counts[k]; got != want {
|
|
t.Errorf("entity %x: projected %d, streamed %d", k, got, want)
|
|
}
|
|
}
|
|
|
|
if projected.Offset != streamed.Offset {
|
|
t.Errorf("checkpoint offset: projected %d, streamed %d",
|
|
projected.Offset, streamed.Offset)
|
|
}
|
|
|
|
if projected.MemberIdx != streamed.MemberIdx {
|
|
t.Errorf("member index: projected %d, streamed %d",
|
|
projected.MemberIdx, streamed.MemberIdx)
|
|
}
|
|
}
|
|
|
|
func TestDeclaredFooterLenReadsTrailer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := bigSparkTar(t)
|
|
srv, _ := serveRangeBlob(t, payload)
|
|
|
|
f := &rangeFetcher{ctx: t.Context(), client: srv.Client(), url: srv.URL}
|
|
out := make(chan tarMember, 4)
|
|
|
|
if err := walkTarMembers(t.Context(), f, 0, int64(len(payload)), out); err != nil {
|
|
t.Fatalf("walk: %v", err)
|
|
}
|
|
|
|
var m tarMember
|
|
|
|
for got := range out {
|
|
if strings.HasSuffix(got.name, ".parquet") {
|
|
m = got
|
|
}
|
|
}
|
|
|
|
member := payload[m.dataOffset : m.dataOffset+m.size]
|
|
|
|
got := declaredFooterLen(member, m.size)
|
|
if got <= 8 || got > m.size {
|
|
t.Fatalf("declared footer length %d for a %d byte member", got, m.size)
|
|
}
|
|
|
|
// A tail of exactly that length must be enough to parse the footer.
|
|
buf := make([]byte, m.size)
|
|
copy(buf[m.size-got:], member[m.size-got:])
|
|
|
|
if _, err := readFooterMetadata(buf, m.size, got); err != nil {
|
|
t.Fatalf("footer parse with declared length: %v", err)
|
|
}
|
|
}
|
|
|
|
// A footer bigger than the initial probe must trigger a second, larger
|
|
// tail fetch rather than failing.
|
|
func TestFetchProjectedMemberRefetchesOversizedFooter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := bigSparkTar(t)
|
|
srv, _ := serveRangeBlob(t, payload)
|
|
|
|
f := &rangeFetcher{ctx: t.Context(), client: srv.Client(), url: srv.URL}
|
|
out := make(chan tarMember, 4)
|
|
|
|
if err := walkTarMembers(t.Context(), f, 0, int64(len(payload)), out); err != nil {
|
|
t.Fatalf("walk: %v", err)
|
|
}
|
|
|
|
var m tarMember
|
|
|
|
for got := range out {
|
|
if strings.HasSuffix(got.name, ".parquet") {
|
|
m = got
|
|
}
|
|
}
|
|
|
|
member := payload[m.dataOffset : m.dataOffset+m.size]
|
|
|
|
want, err := parseListenParquet(member)
|
|
if err != nil {
|
|
t.Fatalf("full parse: %v", err)
|
|
}
|
|
|
|
// Force the probe to land short of the real footer.
|
|
footer := declaredFooterLen(member, m.size)
|
|
|
|
short := &rangeFetcher{
|
|
ctx: t.Context(),
|
|
client: srv.Client(),
|
|
url: srv.URL,
|
|
footerProbe: footer / 2,
|
|
}
|
|
|
|
buf := make([]byte, m.size)
|
|
|
|
if _, err := fetchProjectedMember(t.Context(), short, m, buf); err != nil {
|
|
t.Fatalf("projected fetch with short probe: %v", err)
|
|
}
|
|
|
|
got, err := parseListenParquet(buf)
|
|
if err != nil {
|
|
t.Fatalf("projected parse: %v", err)
|
|
}
|
|
|
|
if len(got) != len(want) {
|
|
t.Fatalf("got %d entities, want %d", len(got), len(want))
|
|
}
|
|
}
|
|
|
|
// makePaxTar writes members preceded by PAX extension headers, which is
|
|
// how the real ListenBrainz dump is written. A lone header block from
|
|
// such an archive cannot be decoded with archive/tar, so this is the
|
|
// layout the walker must handle directly.
|
|
func makePaxTar(t *testing.T, members map[string][]byte, order []string) []byte {
|
|
t.Helper()
|
|
|
|
var buf bytes.Buffer
|
|
|
|
tw := tar.NewWriter(&buf)
|
|
|
|
for _, name := range order {
|
|
data := members[name]
|
|
hdr := &tar.Header{
|
|
Name: name,
|
|
Mode: 0o644,
|
|
Size: int64(len(data)),
|
|
Typeflag: tar.TypeReg,
|
|
// Sub-second precision cannot be expressed in ustar, so the
|
|
// writer emits a PAX extension header ahead of the member.
|
|
ModTime: time.Unix(1700000000, 123456789),
|
|
Format: tar.FormatPAX,
|
|
}
|
|
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
t.Fatalf("tar header: %v", err)
|
|
}
|
|
|
|
if _, err := tw.Write(data); err != nil {
|
|
t.Fatalf("tar write: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatalf("tar close: %v", err)
|
|
}
|
|
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func TestWalkTarMembersHandlesPaxHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rows := listensOf(4, recA, relA, []string{artA})
|
|
name := "listenbrainz-spark-dump-1-20260101-000003-full/1.parquet"
|
|
member := makeParquet(t, rows)
|
|
|
|
payload := makePaxTar(t, map[string][]byte{name: member}, []string{name})
|
|
|
|
// Guard the premise: the fixture really does contain a PAX header.
|
|
if !bytes.Contains(payload[:4096], []byte("PaxHeader")) {
|
|
t.Fatal("fixture has no PAX extension header")
|
|
}
|
|
|
|
srv, _ := serveRangeBlob(t, payload)
|
|
|
|
f := &rangeFetcher{ctx: t.Context(), client: srv.Client(), url: srv.URL}
|
|
out := make(chan tarMember, 16)
|
|
|
|
if err := walkTarMembers(t.Context(), f, 0, int64(len(payload)), out); err != nil {
|
|
t.Fatalf("walk: %v", err)
|
|
}
|
|
|
|
var found tarMember
|
|
|
|
for m := range out {
|
|
if isProjectableMember(m) {
|
|
found = m
|
|
}
|
|
}
|
|
|
|
if found.size != int64(len(member)) {
|
|
t.Fatalf("member size %d, want %d", found.size, len(member))
|
|
}
|
|
|
|
if got := payload[found.dataOffset : found.dataOffset+4]; string(got) != "PAR1" {
|
|
t.Fatalf("data offset %d does not start a parquet file (%q)", found.dataOffset, got)
|
|
}
|
|
}
|
|
|
|
// End to end through the aggregator, against a PAX archive.
|
|
func TestAggregateProjectedWithPaxHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prefix := "listenbrainz-spark-dump-1-20260101-000003-full/"
|
|
m1 := prefix + "1.parquet"
|
|
m2 := prefix + "2.parquet"
|
|
|
|
payload := makePaxTar(t,
|
|
map[string][]byte{
|
|
m1: makeParquet(t, listensOf(12, recA, relA, []string{artA})),
|
|
m2: makeParquet(t, listensOf(11, recB, relA, []string{artA})),
|
|
},
|
|
[]string{m1, m2},
|
|
)
|
|
|
|
srv := serveDumps(t, payload, nil)
|
|
|
|
db := database.NewTestDB(t)
|
|
si := NewSearchIndex(db, nil, nil, testLogger())
|
|
imp := testImporter(t, si, srv)
|
|
|
|
url := srv.URL + "/listens/listenbrainz-dump-1-20260101-000003-full/" +
|
|
"listenbrainz-spark-dump-1-20260101-000003-full.tar"
|
|
|
|
st := &countsState{SparkURL: url}
|
|
if err := imp.aggregateListenCounts(t.Context(), st); err != nil {
|
|
t.Fatalf("aggregate: %v", err)
|
|
}
|
|
|
|
if !st.Done {
|
|
t.Fatal("aggregation did not complete")
|
|
}
|
|
|
|
assert := func(kind byte, mbid string, want uint32) {
|
|
t.Helper()
|
|
|
|
key, ok := makeMBIDKey(kind, mbid)
|
|
if !ok {
|
|
t.Fatalf("bad fixture mbid %s", mbid)
|
|
}
|
|
|
|
if got := st.counts[key]; got != want {
|
|
t.Errorf("count(kind=%d, %s) = %d, want %d", kind, mbid, got, want)
|
|
}
|
|
}
|
|
|
|
assert(countKindRecording, recA, 12)
|
|
assert(countKindRecording, recB, 11)
|
|
assert(countKindRelease, relA, 23)
|
|
assert(countKindArtist, artA, 23)
|
|
}
|
|
|
|
func TestParseTarHeaderRejectsCorruptBlock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
payload := fixtureSparkTar(t)
|
|
|
|
valid := make([]byte, tarHeaderSize)
|
|
copy(valid, payload[:tarHeaderSize])
|
|
|
|
if _, ok, err := parseTarHeader(valid, 0); err != nil || !ok {
|
|
t.Fatalf("valid header rejected: ok=%v err=%v", ok, err)
|
|
}
|
|
|
|
// A desynced walk lands mid-member; the checksum must catch it.
|
|
corrupt := make([]byte, tarHeaderSize)
|
|
copy(corrupt, valid)
|
|
corrupt[10] ^= 0xFF
|
|
|
|
if _, _, err := parseTarHeader(corrupt, 0); err == nil {
|
|
t.Fatal("corrupt header accepted")
|
|
}
|
|
}
|
|
|
|
// A member smaller than the footer probe arrives in the probe request;
|
|
// it must not then be fetched a second time.
|
|
func TestFetchProjectedMemberSkipsRefetchForTinyMembers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rows := listensOf(2, recA, relA, []string{artA})
|
|
name := "listenbrainz-spark-dump-1-20260101-000003-full/1.parquet"
|
|
member := makeParquet(t, rows)
|
|
|
|
if int64(len(member)) >= defaultFooterProbe {
|
|
t.Skipf("fixture member is %d bytes, not smaller than the probe", len(member))
|
|
}
|
|
|
|
payload := makeTar(t, map[string][]byte{name: member}, []string{name})
|
|
srv, served := serveRangeBlob(t, payload)
|
|
|
|
f := &rangeFetcher{ctx: t.Context(), client: srv.Client(), url: srv.URL}
|
|
out := make(chan tarMember, 8)
|
|
|
|
if err := walkTarMembers(t.Context(), f, 0, int64(len(payload)), out); err != nil {
|
|
t.Fatalf("walk: %v", err)
|
|
}
|
|
|
|
var m tarMember
|
|
|
|
for got := range out {
|
|
if isProjectableMember(got) {
|
|
m = got
|
|
}
|
|
}
|
|
|
|
served.Store(0)
|
|
|
|
buf := make([]byte, m.size)
|
|
|
|
fetched, err := fetchProjectedMember(t.Context(), f, m, buf)
|
|
if err != nil {
|
|
t.Fatalf("projected fetch: %v", err)
|
|
}
|
|
|
|
if fetched != m.size {
|
|
t.Errorf("fetched %d bytes for a %d byte member", fetched, m.size)
|
|
}
|
|
|
|
if n := served.Load(); n > m.size {
|
|
t.Errorf("server delivered %d bytes for a %d byte member", n, m.size)
|
|
}
|
|
|
|
if _, err := parseListenParquet(buf); err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
}
|