Skip to content

Commit c8556ba

Browse files
dylanjeffersclaude
andauthored
feat(for-you): re-introduce /users/{id}/feed/for-you with lean 3-source pipeline (#817)
## Summary Brings back `GET /v1/users/{id}/feed/for-you` (removed in #807) with a leaner pipeline that drops the source that was causing the prod timeouts. The killer in the original was the `similar_artists` CTE — a 1-hop saves-graph self-join that produced a 301M-row merge for power users. Dropped entirely. The lean pipeline keeps the other three sources: | Source | What it pulls | Cap | |---|---|---| | `in_network` | Tracks uploaded in the last 14 days by users I follow | 200 | | `trending` | Top week-trending from `track_trending_scores` | 100 | | `underground` | Week-trending tracks whose owner has < 1500 follower & following count | 50 | Same ranking formula as before: ``` recency_score = exp(-ln(2) * age_hours / 48) engagement_score = ln(1 + 3*saves + 2*reposts + 1*plays) / 12 social_boost = 1.0 + least(affinity/4, 1.0) source_weight = {in_network: 1.20, trending: 1.00, underground: 0.95} final_score = (0.55*recency + 0.45*engagement) * social_boost * source_weight ``` Same Go-side diversity pass (per-artist `ROW_NUMBER()` cap + 5-position lookahead to break consecutive-same-artist runs). ## Perf guardrails Retained from #805 / #806: - `follow_set` capped at 500 most-recently-followed. - `my_artist_affinity` sub-selects capped (200 saves / 200 reposts / 500 plays, all by recency). Additional: - `my_artist_affinity` inner `JOIN tracks` is now further restricted to tracks created in the last 90 days — old uploads can't pull the CTE wide. - New partial index `idx_track_trending_scores_for_you` on `track_trending_scores (score DESC, track_id)` covering the `TRACKS / pnagD / week / null-genre` slice. Without it, EXPLAIN showed a fixed ~12s scan of `track_trending_scores` for every request, regardless of caller. ## Files | File | What | |---|---| | [`api/v1_users_feed_for_you.go`](https://github.com/AudiusProject/api/blob/claude/eager-wilson-e22923/api/v1_users_feed_for_you.go) | Handler + the 3-source candidate-pool SQL (no similar_artists) | | [`api/v1_users_feed_for_you_test.go`](https://github.com/AudiusProject/api/blob/claude/eager-wilson-e22923/api/v1_users_feed_for_you_test.go) | Basic tests: valid user_id required, empty feed for new user, pagination | | [`api/server.go`](https://github.com/AudiusProject/api/blob/claude/eager-wilson-e22923/api/server.go) | Route re-registration | | [`api/auth_middleware.go`](https://github.com/AudiusProject/api/blob/claude/eager-wilson-e22923/api/auth_middleware.go) | Re-add the `/feed/for-you` exemption (query `user_id` is advisory; path `:userId` controls personalization) | | [`api/swagger/swagger-v1.yaml`](https://github.com/AudiusProject/api/blob/claude/eager-wilson-e22923/api/swagger/swagger-v1.yaml) | Re-add the endpoint spec | | [`ddl/migrations/0198_track_trending_scores_for_you_idx.sql`](https://github.com/AudiusProject/api/blob/claude/eager-wilson-e22923/ddl/migrations/0198_track_trending_scores_for_you_idx.sql) | The missing partial index | ## Test plan - [x] `go build ./api/...` clean - [x] `go vet ./api/...` clean - [x] `go test -c ./api/...` compiles - [ ] After deploy: hit `/v1/users/{id}/feed/for-you?user_id={id}&limit=5` for a power-user account that previously timed out — should now return 200 in < 2s. - [ ] Eyeball the mix of in-network vs trending vs underground on a real account. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f7b9678 commit c8556ba

6 files changed

Lines changed: 660 additions & 2 deletions

File tree

api/auth_middleware.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,16 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error {
347347
return fiber.NewError(fiber.StatusUnauthorized, "Invalid or expired access token")
348348
}
349349

350-
// Not authorized to act on behalf of myId
351-
if myId != 0 && !pkceAuthed && !app.isAuthorizedRequest(c.Context(), myId, wallet) {
350+
// Not authorized to act on behalf of myId.
351+
//
352+
// Exception: /users/:userId/feed/for-you accepts user_id as a viewer hint
353+
// used only for response decoration (has_current_user_reposted etc.); the
354+
// path :userId — not user_id — controls what gets personalized. Treat the
355+
// query user_id as advisory rather than authoritative on this route so
356+
// the endpoint can be called like the other public read endpoints.
357+
allowUnauthenticatedViewerId := strings.HasSuffix(c.Path(), "/feed/for-you")
358+
359+
if myId != 0 && !pkceAuthed && !allowUnauthenticatedViewerId && !app.isAuthorizedRequest(c.Context(), myId, wallet) {
352360
return fiber.NewError(
353361
fiber.StatusForbidden,
354362
fmt.Sprintf(

api/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ func NewApiServer(config config.Config) *ApiServer {
454454
g.Get("/users/:userId/contests", app.v1UserContests)
455455
g.Get("/users/:userId/playlists", app.v1UserPlaylists)
456456
g.Get("/users/:userId/feed", app.v1UsersFeed)
457+
g.Get("/users/:userId/feed/for-you", app.v1FeedForYou)
457458
g.Get("/users/:userId/connected_wallets", app.v1UsersConnectedWallets)
458459
g.Get("/users/:userId/transactions/audio", app.v1UsersTransactionsAudio)
459460
g.Get("/users/:userId/transactions/audio/count", app.v1UsersTransactionsAudioCount)

api/swagger/swagger-v1.yaml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9475,6 +9475,75 @@ paths:
94759475
"500":
94769476
description: Server error
94779477
content: {}
9478+
/users/{id}/feed/for-you:
9479+
get:
9480+
tags:
9481+
- users
9482+
summary: Get For You feed for user
9483+
description:
9484+
Returns a personalized For You feed for the user identified in the
9485+
path. Twitter-style multi-source pipeline — candidate retrieval
9486+
(in-network, trending, underground) → linear ranking (recency
9487+
decay × engagement × social affinity, weighted by source) →
9488+
diversity (per-artist cap + consecutive-same-artist lookahead).
9489+
operationId: Get User For You Feed
9490+
security:
9491+
- {}
9492+
- OAuth2:
9493+
- read
9494+
parameters:
9495+
- name: id
9496+
in: path
9497+
description: A User ID
9498+
required: true
9499+
schema:
9500+
type: string
9501+
- name: limit
9502+
in: query
9503+
description: The number of items to fetch
9504+
schema:
9505+
type: integer
9506+
default: 25
9507+
minimum: 1
9508+
maximum: 100
9509+
- name: offset
9510+
in: query
9511+
description:
9512+
The number of items to skip. Useful for pagination (page number
9513+
* limit)
9514+
schema:
9515+
type: integer
9516+
default: 0
9517+
minimum: 0
9518+
maximum: 200
9519+
- name: max_per_artist
9520+
in: query
9521+
description:
9522+
Maximum number of tracks per artist on a single page. Used by the
9523+
diversity pass to cap consecutive same-artist results.
9524+
schema:
9525+
type: integer
9526+
default: 3
9527+
minimum: 1
9528+
maximum: 10
9529+
- name: user_id
9530+
in: query
9531+
description: The user ID of the user making the request
9532+
schema:
9533+
type: string
9534+
responses:
9535+
"200":
9536+
description: Success
9537+
content:
9538+
application/json:
9539+
schema:
9540+
$ref: "#/components/schemas/tracks"
9541+
"400":
9542+
description: Bad request
9543+
content: {}
9544+
"500":
9545+
description: Server error
9546+
content: {}
94789547
/users/{id}/library/albums:
94799548
get:
94809549
tags:

0 commit comments

Comments
 (0)