Recommendation relevance, the rollback unit, and a version that names what shipped #131
@@ -118,8 +118,7 @@ func CountLibraryTracks(ctx context.Context, q *dbq.Queries) (int64, error) {
|
|||||||
// per request.
|
// per request.
|
||||||
const librarySizeTTL = 5 * time.Minute
|
const librarySizeTTL = 5 * time.Minute
|
||||||
|
|
||||||
// librarySizeTimeout bounds the count itself. Rule 156: the caller is a user
|
// librarySizeTimeout bounds the count itself — see loadWithDeadline.
|
||||||
// waiting on a radio, and a pool-sizing hint is never worth hanging for.
|
|
||||||
const librarySizeTimeout = 3 * time.Second
|
const librarySizeTimeout = 3 * time.Second
|
||||||
|
|
||||||
// LibrarySize memoises the library track count.
|
// LibrarySize memoises the library track count.
|
||||||
@@ -142,7 +141,22 @@ func NewLibrarySize(now func() time.Time) *LibrarySize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the cached size, refreshing through load when stale.
|
// Get returns the cached size, refreshing through load when stale.
|
||||||
|
//
|
||||||
|
// A NIL RECEIVER IS VALID and means "no cache": the count still runs, it is
|
||||||
|
// just not memoised. That is deliberate rather than defensive habit. The api
|
||||||
|
// handlers struct is built directly by a dozen tests that cannot know about
|
||||||
|
// every field, and a nil here previously panicked inside a radio request —
|
||||||
|
// turning a missing pool-sizing HINT into a 500. Uncached-but-correct is the
|
||||||
|
// right failure for something whose whole contract is that it degrades.
|
||||||
func (l *LibrarySize) Get(ctx context.Context, load func(context.Context) (int64, error)) int64 {
|
func (l *LibrarySize) Get(ctx context.Context, load func(context.Context) (int64, error)) int64 {
|
||||||
|
if l == nil {
|
||||||
|
n, err := loadWithDeadline(ctx, load)
|
||||||
|
if err != nil {
|
||||||
|
return 0 // scales to the base limits
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
@@ -154,9 +168,7 @@ func (l *LibrarySize) Get(ctx context.Context, load func(context.Context) (int64
|
|||||||
return l.size
|
return l.size
|
||||||
}
|
}
|
||||||
|
|
||||||
cctx, cancel := context.WithTimeout(ctx, librarySizeTimeout)
|
n, err := loadWithDeadline(ctx, load)
|
||||||
defer cancel()
|
|
||||||
n, err := load(cctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Keep the last known value and re-try at the next call rather than
|
// Keep the last known value and re-try at the next call rather than
|
||||||
// stamping `at`, so a transient failure does not pin a stale number
|
// stamping `at`, so a transient failure does not pin a stale number
|
||||||
@@ -166,3 +178,11 @@ func (l *LibrarySize) Get(ctx context.Context, load func(context.Context) (int64
|
|||||||
l.size, l.at = n, now()
|
l.size, l.at = n, now()
|
||||||
return l.size
|
return l.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadWithDeadline bounds the count. Rule 156: the caller is a user waiting
|
||||||
|
// on a radio, and a pool-sizing hint is never worth hanging for.
|
||||||
|
func loadWithDeadline(ctx context.Context, load func(context.Context) (int64, error)) (int64, error) {
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, librarySizeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return load(cctx)
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,3 +175,42 @@ func TestLibrarySize_RetriesAfterAFailedRefresh(t *testing.T) {
|
|||||||
t.Errorf("got %d after a failed refresh, want 900 — the failure pinned a stale value", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user