Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -268,6 +280,7 @@ func NewApiServer(config config.Config) *ApiServer {
oauthTokenCache: &oauthTokenCache,
qualifiedPlaylistsCache: &qualifiedPlaylistsCache,
relatedUsersCache: &relatedUsersCache,
genresPopularCache: &genresPopularCache,
requestValidator: requestValidator,
rewardAttester: rewardAttester,
transactionSender: transactionSender,
Expand Down Expand Up @@ -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
Expand Down
68 changes: 51 additions & 17 deletions api/v1_genres_popular.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
package api

import (
"context"
"fmt"
"sort"
"time"

"api.audius.co/api/dbv1"
"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"`
Expand All @@ -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
Expand Down Expand Up @@ -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
}
52 changes: 52 additions & 0 deletions api/v1_genres_popular_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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"`
Expand Down
Loading