From 2b72f45b222bd4c3ab3035e56fdf8beb95d8dc8c Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Tue, 18 Aug 2026 15:41:28 -0700 Subject: [PATCH] perf(api): index saves by (user_id, created_at) for suggested-follows /v1/users/:id/suggested-follows takes the user's most recent favorites via `WHERE user_id = ? AND is_delete = false ORDER BY created_at DESC LIMIT ?`, and no existing index can serve that ordering: saves_user_idx orders by save_type and save_item_id before created_at, and saves_user_track_current_blocknumber_idx orders by blocknumber and is partial on save_type = 'track' (the endpoint also reads album/playlist saves). So Postgres reads every one of the user's saves and top-N sorts them. Measured on prod after the endpoint shipped: cache hits 50-280ms, cold misses 1.5-3.8s for users with large libraries. This mirrors reposts_user_created_at_active_idx (migration 0223), which already solves the identical problem for the reposts half of the same query. On a synthetic 50k-save user the CTE goes from a 50k-row parallel scan + top-N heapsort (cost 4305..4535, ~9ms floor regardless of LIMIT) to a plain ordered index scan that stops at the limit (cost 0.41..181, 0.5ms). Whole query: ~18ms -> ~9ms locally, and the win should be far larger on prod where the scan is over a vastly bigger table. Deliberately does NOT lower suggestedFollowsEngagementCap. Lowering it does help -- with the index, cap=500 measured ~2ms vs ~9ms at 2000 -- but the cap bounds the candidate pool *before* already-followed artists are filtered out, so a user who follows most of the artists they recently engaged with would get a short list. That trade isn't worth making when the index removes the reason for it. Comment added recording the tradeoff. Co-Authored-By: Claude Opus 5 --- api/v1_users_suggested_follows.go | 11 ++++++++++ .../0239_saves_user_created_at_idx.sql | 21 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 ddl/migrations/0239_saves_user_created_at_idx.sql diff --git a/api/v1_users_suggested_follows.go b/api/v1_users_suggested_follows.go index 57668aa3..f839bbc6 100644 --- a/api/v1_users_suggested_follows.go +++ b/api/v1_users_suggested_follows.go @@ -19,6 +19,17 @@ const ( // engagement history is unbounded, and everything downstream of it joins // per-row, so this caps the worst case for heavy users. Recent engagement // is also the better signal, so the cap costs little. + // + // Note this cap, not the decay below, is the effective window for anyone + // with a large library: a user who favorites 50 tracks a day is scored on + // their last ~40 days, well inside the decay's range. For everyone else the + // cap never binds and the decay does the shaping. + // + // Lowering it further is tempting for latency but trades against fill rate: + // the cap bounds the candidate pool *before* already-followed artists are + // filtered out, so a user who follows most of the artists they recently + // engaged with gets a short list. saves_user_created_at_active_idx + // (migration 0239) removes the reason to make that trade. suggestedFollowsEngagementCap = 2000 // Engagement weight decays with e^(-age/tau). At tau = 180 days a favorite diff --git a/ddl/migrations/0239_saves_user_created_at_idx.sql b/ddl/migrations/0239_saves_user_created_at_idx.sql new file mode 100644 index 00000000..463f4368 --- /dev/null +++ b/ddl/migrations/0239_saves_user_created_at_idx.sql @@ -0,0 +1,21 @@ +-- Supports /v1/users/:id/suggested-follows, which takes a bounded slice of the +-- user's most recent favorites: +-- +-- WHERE user_id = ? +-- AND is_delete = false +-- ORDER BY created_at DESC +-- LIMIT ? +-- +-- No existing index can serve that ordering. saves_user_idx orders by save_type +-- and save_item_id before created_at, and saves_user_track_current_blocknumber_idx +-- orders by blocknumber and is partial on save_type = 'track' (the endpoint also +-- reads album/playlist saves, so it can't use a track-only index). Postgres +-- therefore reads every one of the user's saves and top-N sorts them, which for +-- a user with a large library dominates the request. +-- +-- Mirrors reposts_user_created_at_active_idx (migration 0223), which already +-- solves exactly this for the reposts half of the same query. +CREATE INDEX CONCURRENTLY IF NOT EXISTS saves_user_created_at_active_idx + ON public.saves USING btree (user_id, created_at DESC) + INCLUDE (save_type, save_item_id) + WHERE is_delete = false;