Skip to content

[Perf] Cache /v1/users/:userId/related candidate ids#796

Merged
raymondjacobson merged 1 commit intomainfrom
ray/perf-cache-related-users
May 8, 2026
Merged

[Perf] Cache /v1/users/:userId/related candidate ids#796
raymondjacobson merged 1 commit intomainfrom
ray/perf-cache-related-users

Conversation

@raymondjacobson
Copy link
Copy Markdown
Member

Summary

Cache the candidate user-id list returned by /v1/users/:userId/related for 10 minutes per request shape. Recommendations are not authoritative state, so a brief cache is safe.

Why

The two SQL paths (collaborative filter for high-follower artists, genre fallback for low-follower artists) are both expensive:

  • pg_stat_statements: collaborative filter mean 100 ms, 43.5M calls; slow variant mean 4,478 ms, 240k calls.
  • Axiom /v1/users/:userId/related: p50 154 ms, p95 957 ms, p99 2,377 ms.

The candidate set is request-shape-dependent but not viewer-dependent in the common case (filter_followed=false), so most callers hit the same cache entry.

Impact

Local server pointed at prod read replica:

Path Cache miss Cache hit
Phuture (collab filter, ~30k followers) 1,140 ms 110-130 ms
Low-follower artist (genre fallback) 835 ms 105-110 ms

~10× faster on cache hit. Cache hit time is dominated by the unchanged GetUsers follow-up fetch, not the cached query.

Cache key design

"%d:%t:%d:%d:%d:%t" — userId : filterFollowed : cacheMyId : limit : offset : lowFollowerCount

cacheMyId = myId only when filter_followed=true; otherwise it's 0. This keeps the unfiltered (and far-more-common) path viewer-independent so cache hit rate is maximized, while the filter-followed path correctly partitions per viewer.

Risk

  • 10-min TTL on recommendations. Worst case: someone unfollows an artist and re-loads the artist page; the recommendation list might still include the unfollowed user for up to 10 minutes when filter_followed=true. Acceptable for a "you may also like" surface.
  • The follow-up GetUsers fetch carries the my-perspective fields (is_followed, follower_count, etc.), so those stay fresh even on cache hits.
  • New cache-isolation regression test (TestV1UsersRelated) verifies that filter_followed=true and filter_followed=false cannot bleed into each other.

Test plan

  • go test -count=1 ./api/... (full suite, all green)
  • Local server confirms ~10× speedup on cache hit
  • New cache-isolation regression assertion in TestV1UsersRelated

🤖 Generated with Claude Code

The collaborative-filter and genre-fallback queries that power
/v1/users/:userId/related are expensive — pg_stat_statements shows
mean 100ms with a 4.5s slow variant; Axiom shows p95 957ms / p99
2.4s. The output is a recommendation, not authoritative state, so
caching the candidate id list briefly is safe.

Cache key includes (userId, filter_followed, [myId only when
filter_followed is true], limit, offset, low_follower_count) so
viewers querying the same artist hit the same entry when they're
not asking for follow filtering. The follow-up GetUsers fetch
(which carries the my-perspective fields) still runs fresh.

Verified on local server pointed at prod replica:

  Cache miss: 1.14s (Phuture, collaborative filter)
              0.84s (low-follower artist, genre fallback)
  Cache hit:  100-130ms                ~10x faster

10-minute TTL: recommendations don't need to be real-time, and
artist follower counts/genre distributions don't shift fast.
@raymondjacobson raymondjacobson force-pushed the ray/perf-cache-related-users branch from 331e4da to fc92e6f Compare May 8, 2026 01:50
@raymondjacobson raymondjacobson merged commit fe245d4 into main May 8, 2026
5 checks passed
@raymondjacobson raymondjacobson deleted the ray/perf-cache-related-users branch May 8, 2026 01:55
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