perf(api): index saves by (user_id, created_at) for suggested-follows - #1022
Merged
Conversation
/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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #1020. That endpoint measured 1.5–3.8s on a cold cache for users with large libraries on prod (cache hits are 50–280ms). This is the fix.
Cause
/v1/users/:id/suggested-followstakes the user's most recent favorites:No existing index can serve that ordering:
saves_user_idxis(user_id, save_type, save_item_id, is_delete)—created_atisn't in it.saves_user_track_current_blocknumber_idxorders byblocknumberand is partial onsave_type = 'track', and the endpoint also reads album/playlist saves.So Postgres reads every save the user has and top-N sorts them.
repostsalready has exactly the right index (reposts_user_created_at_active_idx, migration 0223) for the reposts half of the same query. This mirrors it forsaves.Measured
On a synthetic 50k-save user locally. The favorites CTE alone:
Whole query, 3 runs each:
Local absolutes are far below prod's because my dataset is tiny by comparison — but prod is precisely where "read all the user's saves" hurts most, so the win should be considerably larger there.
Why the cap is unchanged
Lowering
suggestedFollowsEngagementCapis the other available lever and the table above shows it's worth ~4.5x on top of the index. I'm not taking it.Without the index there's a hard floor around 9ms no matter how low the cap goes — that floor is the full scan, so the cap can't fix the actual problem. And 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 ends up with a short list rather than a full page. I hit exactly that while testing (cap=500 returned 1 result instead of 10) — that specific case was an artifact of my seed data, which had made 499 of the 500 candidates already-followed, but the mechanism is real and I haven't quantified it against production follow ratios.
The index gets the fix without that trade. A comment in the handler records the reasoning.
Rollout
CREATE INDEX CONCURRENTLY IF NOT EXISTS, same as 0223. Verified it applies to a fresh schema and is idempotent on re-run.savesis a high-write table, so worth an eye on write latency after it lands — the index is ~24% of table size on my synthetic data.🤖 Generated with Claude Code