From 15ed39d63c96a072560142ba12b2b9e7760ae88a Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Thu, 18 Jun 2026 11:22:54 -0700 Subject: [PATCH] perf: cache /v1/genres/popular with 15-minute TTL The endpoint ran a GROUP BY genre scan over the tracks table plus an in-process normalize/merge/sort on every request. Cache the normalized result slice in an otter TTL cache (matching the existing relatedUsersCache / qualifiedPlaylistsCache pattern), keyed by (limit, offset, startTime bucket) where the bucket is startTime truncated to the 15m TTL. min_count is applied per request after the cache lookup so a single cached slice serves every threshold, and the cached slice is treated as immutable (the handler builds a fresh filtered slice rather than aliasing it). Adds a DB-free unit test for the cache key/bucketing contract, and clears the cache in the access-authorities test so its post-mutation re-read isn't served a stale cached count. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/server.go | 14 ++++++++ api/v1_genres_popular.go | 68 ++++++++++++++++++++++++++--------- api/v1_genres_popular_test.go | 52 +++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 17 deletions(-) diff --git a/api/server.go b/api/server.go index 3daf7df4..11edccf1 100644 --- a/api/server.go +++ b/api/server.go @@ -158,6 +158,18 @@ func NewApiServer(config config.Config) *ApiServer { panic(err) } + // Caches the normalized popular-genre slice returned by /v1/genres/popular, + // which otherwise runs a GROUP BY genre scan over the tracks table on every + // request. Keyed by (limit, offset, startTime bucket); the result is an + // approximate popularity ranking, so a 15-minute TTL is fine. + genresPopularCache, err := otter.MustBuilder[string, []PopularGenre](1_000). + WithTTL(genresPopularCacheTTL). + CollectStats(). + Build() + if err != nil { + panic(err) + } + privateKey, err := crypto.HexToECDSA(config.DelegatePrivateKey) if err != nil { panic(err) @@ -268,6 +280,7 @@ func NewApiServer(config config.Config) *ApiServer { oauthTokenCache: &oauthTokenCache, qualifiedPlaylistsCache: &qualifiedPlaylistsCache, relatedUsersCache: &relatedUsersCache, + genresPopularCache: &genresPopularCache, requestValidator: requestValidator, rewardAttester: rewardAttester, transactionSender: transactionSender, @@ -827,6 +840,7 @@ type ApiServer struct { oauthTokenCache *otter.Cache[string, oauthTokenCacheEntry] qualifiedPlaylistsCache *otter.Cache[string, []int32] relatedUsersCache *otter.Cache[string, []int32] + genresPopularCache *otter.Cache[string, []PopularGenre] requestValidator *RequestValidator rewardManagerClient *reward_manager.RewardManagerClient claimableTokensClient *claimable_tokens.ClaimableTokensClient diff --git a/api/v1_genres_popular.go b/api/v1_genres_popular.go index 6045a224..b6546b1f 100644 --- a/api/v1_genres_popular.go +++ b/api/v1_genres_popular.go @@ -1,6 +1,8 @@ package api import ( + "context" + "fmt" "sort" "time" @@ -8,6 +10,12 @@ import ( "github.com/gofiber/fiber/v2" ) +// genresPopularCacheTTL bounds how stale the popular-genre ranking may be. +// The endpoint runs a GROUP BY genre scan over the tracks table plus an +// in-process normalize/merge/sort, and the result is an approximate popularity +// ranking, so a short TTL is fine. +const genresPopularCacheTTL = 15 * time.Minute + type GetPopularGenresParams struct { StartTime int `query:"start_time" default:"0"` Limit int `query:"limit" default:"100" validate:"min=1,max=100"` @@ -28,13 +36,49 @@ func (app *ApiServer) v1GenresPopular(c *fiber.Ctx) error { startTime := time.Unix(int64(params.StartTime), 0) - genres, err := app.queries.GetGenres(c.Context(), dbv1.GetGenresParams{ - LimitVal: int32(params.Limit), - OffsetVal: int32(params.Offset), + genres, err := app.getPopularGenres(c.Context(), params.Limit, params.Offset, startTime) + if err != nil { + return err + } + + // min_count is applied per request rather than baked into the cache key, so + // a single cached (limit, offset, startTime bucket) slice serves every + // threshold. Build a fresh slice — `genres` is the shared cached value and + // must not be mutated/aliased. + filtered := make([]PopularGenre, 0, len(genres)) + for _, g := range genres { + if g.Count < int64(params.MinCount) { + continue + } + filtered = append(filtered, g) + } + + return c.JSON(fiber.Map{ + "data": filtered, + }) +} + +// getPopularGenres returns the normalized, merged, and sorted popular-genre +// slice for the given query window, caching it for genresPopularCacheTTL. The +// min_count filter is intentionally NOT applied here so the same cached slice +// serves every threshold. startTime is a rolling window (now minus a +// client-supplied offset), so it's bucketed to the TTL to keep the cache key +// stable within each window; the DB query still uses the exact startTime on a +// miss. The returned slice is shared across callers and must not be mutated. +func (app *ApiServer) getPopularGenres(ctx context.Context, limit, offset int, startTime time.Time) ([]PopularGenre, error) { + bucket := startTime.Truncate(genresPopularCacheTTL).Unix() + cacheKey := fmt.Sprintf("%d:%d:%d", limit, offset, bucket) + if hit, ok := app.genresPopularCache.Get(cacheKey); ok { + return hit, nil + } + + genres, err := app.queries.GetGenres(ctx, dbv1.GetGenresParams{ + LimitVal: int32(limit), + OffsetVal: int32(offset), StartTime: startTime, }) if err != nil { - return err + return nil, err } // Genre values are written upstream (by the discovery provider) and are not @@ -63,21 +107,11 @@ func (app *ApiServer) v1GenresPopular(c *fiber.Ctx) error { }) } - // Re-sort because merging variants can change the relative ordering, then - // drop anything below min_count (evaluated against the merged total). + // Re-sort because merging variants can change the relative ordering. sort.SliceStable(result, func(i, j int) bool { return result[i].Count > result[j].Count }) - filtered := result[:0] - for _, g := range result { - if g.Count < int64(params.MinCount) { - continue - } - filtered = append(filtered, g) - } - - return c.JSON(fiber.Map{ - "data": filtered, - }) + app.genresPopularCache.Set(cacheKey, result) + return result, nil } diff --git a/api/v1_genres_popular_test.go b/api/v1_genres_popular_test.go index cd596c39..daf47659 100644 --- a/api/v1_genres_popular_test.go +++ b/api/v1_genres_popular_test.go @@ -4,11 +4,59 @@ import ( "context" "fmt" "testing" + "time" + "github.com/maypok86/otter" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// TestGetPopularGenresCache exercises the cache layer of getPopularGenres +// without a DB: with no pool wired, any cache miss would nil-panic on +// app.queries, so every assertion here that returns successfully proves the +// value came from the cache. It also pins the (limit, offset, startTime bucket) +// key contract. +func TestGetPopularGenresCache(t *testing.T) { + cache, err := otter.MustBuilder[string, []PopularGenre](16). + WithTTL(genresPopularCacheTTL). + Build() + require.NoError(t, err) + app := &ApiServer{genresPopularCache: &cache} + + // Aligned to a 15m boundary (1_700_000_100 = 1888889 * 900) so a small + // positive delta stays inside the same bucket. + startTime := time.Unix(1_700_000_100, 0) + seeded := []PopularGenre{{Name: "Electronic", Count: 42}, {Name: "Hip-Hop", Count: 17}} + + // Seed under the exact key getPopularGenres computes for (100, 0, startTime). + bucket := startTime.Truncate(genresPopularCacheTTL).Unix() + cache.Set(fmt.Sprintf("%d:%d:%d", 100, 0, bucket), seeded) + + // A request later in the same TTL bucket shares the key and hits the cache. + got, err := app.getPopularGenres(context.Background(), 100, 0, startTime.Add(time.Minute)) + require.NoError(t, err) + assert.Equal(t, seeded, got, "same 15m bucket should hit the seeded entry") + + // A different bucket / limit / offset is a distinct key. With no DB pool a + // miss would panic, so we assert the panic to prove the key actually differs + // (rather than silently returning the seeded slice). + for name, call := range map[string]func() ([]PopularGenre, error){ + "next bucket": func() ([]PopularGenre, error) { + return app.getPopularGenres(context.Background(), 100, 0, startTime.Add(genresPopularCacheTTL)) + }, + "diff limit": func() ([]PopularGenre, error) { + return app.getPopularGenres(context.Background(), 50, 0, startTime) + }, + "diff offset": func() ([]PopularGenre, error) { + return app.getPopularGenres(context.Background(), 100, 10, startTime) + }, + } { + t.Run(name, func(t *testing.T) { + assert.Panics(t, func() { _, _ = call() }, "distinct key should miss and reach the nil DB pool") + }) + } +} + func TestGenresPopular(t *testing.T) { app := testAppWithFixtures(t) @@ -81,6 +129,10 @@ func TestGenresPopularExcludesAccessAuthoritiesTracks(t *testing.T) { _, err := app.writePool.Exec(ctx, `UPDATE tracks SET access_authorities = ARRAY['0xgate']::text[] WHERE track_id = 100 AND is_current = true`) require.NoError(t, err) + // The endpoint caches its result for genresPopularCacheTTL; clear it so the + // re-read reflects the DB mutation we just made rather than the cached count. + app.genresPopularCache.Clear() + var after struct { Data []struct { Name string `json:"name"`