Skip to content

[Perf] Cache qualified_playlists for /v1/playlists/trending#795

Merged
raymondjacobson merged 1 commit intomainfrom
ray/perf-cache-qualified-playlists
May 8, 2026
Merged

[Perf] Cache qualified_playlists for /v1/playlists/trending#795
raymondjacobson merged 1 commit intomainfrom
ray/perf-cache-qualified-playlists

Conversation

@raymondjacobson
Copy link
Copy Markdown
Member

Summary

Cache the "trending eligible" playlist id set in app memory with a 5-minute TTL. The set is request-independent (same ~6,952 ids for every caller), but the previous implementation recomputed it from scratch on every request.

Why

/v1/playlists/trending shows up as the second-worst signed-in endpoint by p95 (8.4s, p50 1.95s) and the underlying SQL has a 3-5s mean across multiple variants in pg_stat_statements. The dominant cost is the qualified_playlists CTE walking every public playlist × its tracks (~94k playlists, ~5M playlist_tracks rows) to determine which playlists have ≥5 tracks and ≥5 distinct owners. None of those inputs depend on the caller — caller_id, time_range, auth, none of it.

Impact

EXPLAIN ANALYZE on prod read replica:

Path Time
Cache miss (1× per 5 min, populates cache) ~2.2s
Cache hit (every other request) 36-41 ms (~125× faster)

Local server end-to-end (full GetPlaylists fetch included):

Before (this branch) After
First call (cache miss) 3-5s 3.4s
Subsequent calls 3-5s 350-900ms

Caching trade-offs (the bits I want eyes on)

  1. TTL: 5 minutes. Trending playlists are by definition popular — privacy/delete flips are rare. Worst case: a playlist becomes private and can be re-surfaced for up to 5 min. Mitigated by the safety net below.

  2. Safety net post-filter. After fetching the playlists from GetPlaylists, the handler drops any with is_private=true or is_delete=true. Even if the cache is stale, a now-private playlist can never reach the response.

  3. Drop of per-caller access_authorities filter in the qualified-set computation. Only ~0.05% of visible tracks (716 / 1.39M) have access_authorities set, so structural eligibility (≥5 tracks, ≥5 owners) is overwhelmingly the dominant signal. Dropping this filter is what makes the result wallet-independent and cacheable.

  4. Cache size. 4 entries max (one per type × possibly time_range if I add that key later). Memory footprint is trivial — at ~7k int32s each, ~30 KB total.

Risk

  • The is_private / is_delete safety net catches the only correctness-relevant staleness window. New TestGetTrendingPlaylists_Albums includes a regression case that flips a playlist private, exercises the cache hit path, and asserts it's filtered out of the response.
  • All existing trending tests pass unchanged.

Test plan

  • go test -count=1 ./api/... (full suite, all green)
  • EXPLAIN ANALYZE confirms ~125× speedup on cache hit path
  • Local server timing: /v1/playlists/trending 3.4s first, then 350-900ms warm
  • Cache safety net regression test

🤖 Generated with Claude Code

The qualified_playlists CTE in /v1/playlists/trending walks every
public playlist x its tracks (~94k playlists, ~5M playlist_tracks)
to determine which playlists are "trending eligible" (>=5 tracks,
>=5 distinct owners). This computation is request-independent and
returns the same ~6,952 ids regardless of caller, but it was running
fresh on every request — pg_stat_statements shows ~3-5s mean
across multiple variants, with /v1/playlists/trending p95=8.4s.

Cache the qualified set in app memory with a 5-minute TTL keyed by
type (playlist|album). Holds at most 4 entries (~30 KB total).

Verified on the prod read replica:

  Cache miss (1x per 5min): ~2.2s computation (unchanged)
  Cache hit:                36-41ms        (down from 4.6s) ~125x

End-to-end on local server pointing at prod replica:

  First request:  3.4s (cache miss + 5min TTL begins)
  Subsequent:     350-900ms (was 1.95s p50 in production)

The cached query also drops the per-caller access_authorities
filter — only ~0.05% of visible tracks have it set, so structural
eligibility is overwhelmingly the dominant signal.

Safety net: response post-filter drops any playlist currently
flagged is_private=true or is_delete=true, so a stale cache entry
can never surface a now-private playlist.
@raymondjacobson raymondjacobson merged commit ba0e878 into main May 8, 2026
5 checks passed
@raymondjacobson raymondjacobson deleted the ray/perf-cache-qualified-playlists branch May 8, 2026 01:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant