The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.
Checked: non-comment tokens identical to HEAD; gofmt clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
8.5 KiB
Go
215 lines
8.5 KiB
Go
package recommendation
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Small libraries must behave exactly as before. Scaling is meant to help a
|
|
// growing collection, not to change what every existing install already does.
|
|
func TestScaleForLibrary_SmallLibrariesAreUntouched(t *testing.T) {
|
|
base := DefaultCandidateSourceLimits()
|
|
for _, size := range []int64{0, 1, 500, libraryScaleReference} {
|
|
if got := ScaleForLibrary(base, size); got != base {
|
|
t.Errorf("library of %d changed the limits: %+v, want %+v", size, got, base)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A bigger library must reach further into itself.
|
|
func TestScaleForLibrary_BigLibrariesGetABiggerPool(t *testing.T) {
|
|
base := DefaultCandidateSourceLimits()
|
|
small := ScaleForLibrary(base, libraryScaleReference)
|
|
big := ScaleForLibrary(base, libraryScaleReference*16)
|
|
|
|
if big.RandomFill <= small.RandomFill {
|
|
t.Errorf("RandomFill %d did not grow for a 16x library (was %d); that arm "+
|
|
"IS the fraction-of-library problem", big.RandomFill, small.RandomFill)
|
|
}
|
|
if big.LBSimilar <= small.LBSimilar {
|
|
t.Errorf("LBSimilar %d did not grow for a 16x library (was %d)",
|
|
big.LBSimilar, small.LBSimilar)
|
|
}
|
|
}
|
|
|
|
// THE SUBSTANCE: scaling is per-arm, decided by what each arm is BOUNDED BY.
|
|
// Raising a limit only helps if there are rows for it to cut off, so arms
|
|
// bounded by the user's likes, the instance's co-play graph or the taste
|
|
// profile gain nothing from a bigger library — raising them would sample more
|
|
// of a set that did not grow.
|
|
//
|
|
// A uniform scale would look right and be wrong, which is why this is pinned
|
|
// separately from "the pool got bigger".
|
|
func TestScaleForLibrary_OnlyLibraryBoundedArmsGrow(t *testing.T) {
|
|
base := DefaultCandidateSourceLimits()
|
|
big := ScaleForLibrary(base, libraryScaleReference*16)
|
|
|
|
for _, tc := range []struct {
|
|
arm string
|
|
got, want int
|
|
why string
|
|
}{
|
|
{"LikesOverlap", big.LikesOverlap, base.LikesOverlap, "bounded by the user's likes"},
|
|
{"UserCoplay", big.UserCoplay, base.UserCoplay, "bounded by the instance's co-play graph"},
|
|
{"TasteOverlap", big.TasteOverlap, base.TasteOverlap, "bounded by the taste profile's artists"},
|
|
} {
|
|
if tc.got != tc.want {
|
|
t.Errorf("%s scaled to %d (base %d) but is %s — a bigger library gives it "+
|
|
"nothing more to return", tc.arm, tc.got, tc.want, tc.why)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Growth is bounded, or a huge library turns every recommendation query into
|
|
// a huge scan. sqrt keeps it proportional; the ceiling keeps it affordable.
|
|
func TestScaleForLibrary_GrowthIsBounded(t *testing.T) {
|
|
base := DefaultCandidateSourceLimits()
|
|
huge := ScaleForLibrary(base, 100_000_000)
|
|
|
|
if huge.RandomFill > int(float64(base.RandomFill)*maxLibraryScale) {
|
|
t.Errorf("RandomFill %d exceeds the %.0fx ceiling on base %d",
|
|
huge.RandomFill, maxLibraryScale, base.RandomFill)
|
|
}
|
|
// sqrt, not linear — and the test point matters. At SIXTEEN times the
|
|
// reference both curves land on 4x, because linear has already been
|
|
// clamped by the ceiling; asserting there proves nothing. Four times the
|
|
// reference is below the ceiling for both, so the curves separate: sqrt
|
|
// gives 2x, linear would give 4x.
|
|
four := ScaleForLibrary(base, libraryScaleReference*4)
|
|
if four.RandomFill != base.RandomFill*2 {
|
|
t.Errorf("4x library gave RandomFill %d, want %d — sqrt growth (linear "+
|
|
"would give %d)", four.RandomFill, base.RandomFill*2, base.RandomFill*4)
|
|
}
|
|
}
|
|
|
|
// Never below the base. The base limits are a floor, not a midpoint, and
|
|
// shrinking an arm ordered by unseeded random() would change pool membership
|
|
// between same-day rebuilds.
|
|
func TestScaleForLibrary_NeverShrinksAnArm(t *testing.T) {
|
|
base := DefaultCandidateSourceLimits()
|
|
for _, size := range []int64{0, 1, 100, 4999, 5001, 1_000_000} {
|
|
got := ScaleForLibrary(base, size)
|
|
for _, tc := range []struct {
|
|
arm string
|
|
got, want int
|
|
}{
|
|
{"LBSimilar", got.LBSimilar, base.LBSimilar},
|
|
{"SimilarArtist", got.SimilarArtist, base.SimilarArtist},
|
|
{"TagOverlap", got.TagOverlap, base.TagOverlap},
|
|
{"RandomFill", got.RandomFill, base.RandomFill},
|
|
{"LikesOverlap", got.LikesOverlap, base.LikesOverlap},
|
|
{"UserCoplay", got.UserCoplay, base.UserCoplay},
|
|
{"TasteOverlap", got.TasteOverlap, base.TasteOverlap},
|
|
} {
|
|
if tc.got < tc.want {
|
|
t.Errorf("library %d shrank %s to %d (base %d)", size, tc.arm, tc.got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A pool-sizing hint must never fail the request it is sizing. A count that
|
|
// errors leaves the previous value in place, and a never-counted cache
|
|
// returns 0 — which scales to the base limits, i.e. exactly today's
|
|
// behaviour.
|
|
func TestLibrarySize_DegradesOnFailure(t *testing.T) {
|
|
clock := time.Now()
|
|
c := NewLibrarySize(func() time.Time { return clock })
|
|
|
|
boom := func(context.Context) (int64, error) { return 0, errors.New("db is down") }
|
|
if got := c.Get(context.Background(), boom); got != 0 {
|
|
t.Errorf("first failure returned %d, want 0 (which scales to the base limits)", got)
|
|
}
|
|
if got := ScaleForLibrary(DefaultCandidateSourceLimits(), 0); got != DefaultCandidateSourceLimits() {
|
|
t.Error("a zero library size did not scale to the base limits")
|
|
}
|
|
|
|
// A good count, then a failure: the last known value survives.
|
|
if got := c.Get(context.Background(), func(context.Context) (int64, error) { return 40_000, nil }); got != 40_000 {
|
|
t.Fatalf("got %d, want 40000", got)
|
|
}
|
|
clock = clock.Add(librarySizeTTL + time.Second)
|
|
if got := c.Get(context.Background(), boom); got != 40_000 {
|
|
t.Errorf("a failed refresh returned %d, discarding the last known 40000", got)
|
|
}
|
|
}
|
|
|
|
// The count is a full table scan, so it must not run per request.
|
|
func TestLibrarySize_CachesWithinTheTTL(t *testing.T) {
|
|
clock := time.Now()
|
|
c := NewLibrarySize(func() time.Time { return clock })
|
|
calls := 0
|
|
load := func(context.Context) (int64, error) { calls++; return 1234, nil }
|
|
|
|
for i := 0; i < 5; i++ {
|
|
c.Get(context.Background(), load)
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("counted %d times within the TTL, want 1 — this is a full table scan", calls)
|
|
}
|
|
|
|
clock = clock.Add(librarySizeTTL + time.Second)
|
|
c.Get(context.Background(), load)
|
|
if calls != 2 {
|
|
t.Errorf("counted %d times after the TTL expired, want 2 — the size never refreshes", calls)
|
|
}
|
|
}
|
|
|
|
// A transient failure must not pin a stale value for the whole TTL: the
|
|
// failed refresh does not stamp the clock, so the next call retries.
|
|
func TestLibrarySize_RetriesAfterAFailedRefresh(t *testing.T) {
|
|
clock := time.Now()
|
|
c := NewLibrarySize(func() time.Time { return clock })
|
|
|
|
c.Get(context.Background(), func(context.Context) (int64, error) { return 100, nil })
|
|
clock = clock.Add(librarySizeTTL + time.Second)
|
|
c.Get(context.Background(), func(context.Context) (int64, error) { return 0, errors.New("blip") })
|
|
|
|
// Immediately after, with no clock movement — a stamped failure would
|
|
// serve the stale 100 until the TTL expired again.
|
|
if got := c.Get(context.Background(), func(context.Context) (int64, error) { return 900, nil }); got != 900 {
|
|
t.Errorf("got %d after a failed refresh, want 900 — the failure pinned a stale value", got)
|
|
}
|
|
}
|
|
|
|
// A nil cache must not panic, and must still be CORRECT — uncached, not
|
|
// broken.
|
|
//
|
|
// This is not a hypothetical hardening. internal/api builds its handlers
|
|
// struct directly in a dozen tests, none of which know about every field, so
|
|
// librarySize arrives nil there. The first version of this panicked inside
|
|
// handleRadio and took down TestHandleRadio_ColdStart_OnlySeedReturned with a
|
|
// SIGSEGV — turning a missing pool-sizing HINT into a request-killing crash,
|
|
// which is the opposite of what a value that "degrades rather than fails" is
|
|
// supposed to do.
|
|
func TestLibrarySize_NilReceiverStillCounts(t *testing.T) {
|
|
var c *LibrarySize // deliberately not constructed
|
|
|
|
calls := 0
|
|
got := c.Get(context.Background(), func(context.Context) (int64, error) {
|
|
calls++
|
|
return 40_000, nil
|
|
})
|
|
if got != 40_000 {
|
|
t.Errorf("nil cache returned %d, want 40000 — it should still count, just not memoise", got)
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("nil cache called the loader %d times, want 1", calls)
|
|
}
|
|
|
|
// Uncached: a second call counts again rather than reusing anything.
|
|
c.Get(context.Background(), func(context.Context) (int64, error) { calls++; return 40_000, nil })
|
|
if calls != 2 {
|
|
t.Errorf("nil cache memoised across calls (%d loads); it has nowhere to store a value", calls)
|
|
}
|
|
|
|
// And it still degrades on error rather than panicking.
|
|
if got := c.Get(context.Background(), func(context.Context) (int64, error) {
|
|
return 0, errors.New("db is down")
|
|
}); got != 0 {
|
|
t.Errorf("nil cache returned %d on a failed count, want 0 (base limits)", got)
|
|
}
|
|
}
|