Skip to content

Commit 602f7b0

Browse files
dylanjeffersclaude
andauthored
[Perf] Bundle related entities in /v1/notifications response (#799)
## Summary - Hydrate users/tracks/playlists referenced by `/v1/notifications` action data inline under a new `related` block, eliminating the N+1 client round trips currently needed to render notifications. - Mirrors the pattern already used by the comments endpoints (`app.queries.Parallel(...)` + `data` / `related` envelope). - Cap actor mining at 1 action per notification group (clients render a single avatar per group, so additional initiator profiles are wasted bytes). Target entities are duplicated across every action in a group, so the cap doesn't drop them. - Polymorphic fields (`*_item_id`, `content_id`, comment `entity_id`) are routed to tracks vs. playlists by their sibling discriminator (`type` / `content_type` / `entity_type`). - Pure addition to the response shape — existing clients that ignore `related` keep working. ## Test plan - [x] `go build ./...` and `go vet ./api/...` clean - [ ] CI runs `TestV1Notifications_RelatedEntities` (covers hydration of users, tracks, playlists; asserts the per-group actor cap bounds a 5-follower fan-out group) - [ ] CI runs the existing `TestV1Notifications*` suite (response is purely additive — existing assertions on `data.notifications.*` should still pass) - [ ] Manual sanity check on staging: hit `/v1/notifications/{me}` and confirm `related.users`, `related.tracks`, `related.playlists` populate for follow / repost / save / comment / tip notifications ## Notes - `IncludeUnlisted: true` for the parallel hydration — notifications are recipient-personal and may legitimately reference unlisted tracks the user has a relationship with. - Comment `entity_id` is folded into `related.tracks` only when `entity_type == "Track"`. The `FanClub` and `Event` entity types don't have a hydrator in `Parallel` today; clients can keep round-tripping for those until we wire them in. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent da04209 commit 602f7b0

2 files changed

Lines changed: 238 additions & 0 deletions

File tree

api/v1_notifications.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,22 @@ import (
55
"slices"
66
"strings"
77

8+
"api.audius.co/api/dbv1"
89
"api.audius.co/trashid"
910
"github.com/gofiber/fiber/v2"
1011
"github.com/jackc/pgx/v5"
1112
"github.com/tidwall/gjson"
1213
"github.com/tidwall/sjson"
1314
)
1415

16+
// Per-group cap on how many actions we mine for actor user IDs. Notification
17+
// groups can fan out (e.g. one row representing 100 followers); the client
18+
// only renders one avatar per group, so a single actor profile is enough.
19+
// Target entity IDs (the followee, the reposted track, etc.) are duplicated
20+
// across every action in a group, so reading just the first action still
21+
// surfaces every target — only the actor list is bounded by this cap.
22+
const notificationRelatedActorsPerGroup = 1
23+
1524
type GetNotificationsQueryParams struct {
1625
// Note that when limit is 0, we return 20 items to calculate unread count
1726
Limit int `query:"limit" default:"20" validate:"min=0,max=100"`
@@ -239,6 +248,10 @@ limit @limit::int
239248
return err
240249
}
241250

251+
userIds := []int32{}
252+
trackIds := []int32{}
253+
playlistIds := []int32{}
254+
242255
unreadCount := 0
243256
for _, notif := range notifs {
244257

@@ -248,6 +261,16 @@ limit @limit::int
248261
return strings.Compare(specA, specB)
249262
})
250263

264+
// Mine related entity IDs from the first N actions of each group. This
265+
// must happen BEFORE HashifyJson re-encodes ints as opaque strings.
266+
mineLimit := len(notif.Actions)
267+
if mineLimit > notificationRelatedActorsPerGroup {
268+
mineLimit = notificationRelatedActorsPerGroup
269+
}
270+
for _, action := range notif.Actions[:mineLimit] {
271+
collectNotificationRelatedIds(action, &userIds, &trackIds, &playlistIds)
272+
}
273+
251274
// each row from notification table has `actions`
252275
// which is a jsonb field that is an array of objects.
253276
// we need to hash encode all id fields (HashifyJson)
@@ -306,11 +329,111 @@ limit @limit::int
306329
}
307330
}
308331

332+
related, err := app.queries.Parallel(c.Context(), dbv1.ParallelParams{
333+
UserIds: userIds,
334+
TrackIds: trackIds,
335+
PlaylistIds: playlistIds,
336+
MyID: app.getMyId(c),
337+
AuthedWallet: app.tryGetAuthedWallet(c),
338+
IncludeUnlisted: true,
339+
})
340+
if err != nil {
341+
return err
342+
}
343+
309344
return c.JSON(fiber.Map{
310345
"data": fiber.Map{
311346
"notifications": notifs,
312347
"unread_count": unreadCount,
313348
},
349+
"related": fiber.Map{
350+
"users": related.UserList(),
351+
"tracks": related.TrackList(),
352+
"playlists": related.PlaylistList(),
353+
},
314354
})
315355

316356
}
357+
358+
// collectNotificationRelatedIds extracts user/track/playlist IDs from a single
359+
// raw (pre-hashify) notification action's data so the caller can batch-load
360+
// the related entities in one shot. Field names mirror the Python
361+
// extend_notification.py mapping; *_item_id and content_id fields are
362+
// polymorphic and disambiguated by the sibling type field.
363+
func collectNotificationRelatedIds(action json.RawMessage, userIds, trackIds, playlistIds *[]int32) {
364+
appendInt := func(target *[]int32, val gjson.Result) {
365+
if val.Exists() && val.Type == gjson.Number {
366+
*target = append(*target, int32(val.Int()))
367+
}
368+
}
369+
370+
for _, path := range []string{
371+
"data.user_id",
372+
"data.follower_user_id",
373+
"data.followee_user_id",
374+
"data.comment_user_id",
375+
"data.entity_user_id",
376+
"data.reacter_user_id",
377+
"data.sender_user_id",
378+
"data.receiver_user_id",
379+
"data.dethroned_user_id",
380+
"data.grantee_user_id",
381+
"data.tastemaker_user_id",
382+
"data.tastemaker_item_owner_id",
383+
"data.track_owner_id",
384+
"data.parent_track_owner_id",
385+
"data.playlist_owner_id",
386+
"data.buyer_user_id",
387+
"data.seller_user_id",
388+
} {
389+
appendInt(userIds, gjson.GetBytes(action, path))
390+
}
391+
392+
appendInt(trackIds, gjson.GetBytes(action, "data.track_id"))
393+
appendInt(trackIds, gjson.GetBytes(action, "data.parent_track_id"))
394+
appendInt(playlistIds, gjson.GetBytes(action, "data.playlist_id"))
395+
396+
// Polymorphic fields: split by sibling type discriminator.
397+
itemType := strings.ToLower(gjson.GetBytes(action, "data.type").String())
398+
for _, path := range []string{
399+
"data.repost_item_id",
400+
"data.save_item_id",
401+
"data.repost_of_repost_item_id",
402+
"data.save_of_repost_item_id",
403+
} {
404+
val := gjson.GetBytes(action, path)
405+
if !val.Exists() || val.Type != gjson.Number {
406+
continue
407+
}
408+
if itemType == "track" {
409+
*trackIds = append(*trackIds, int32(val.Int()))
410+
} else if itemType == "playlist" || itemType == "album" {
411+
*playlistIds = append(*playlistIds, int32(val.Int()))
412+
}
413+
}
414+
415+
if val := gjson.GetBytes(action, "data.tastemaker_item_id"); val.Exists() && val.Type == gjson.Number {
416+
switch strings.ToLower(gjson.GetBytes(action, "data.tastemaker_item_type").String()) {
417+
case "track":
418+
*trackIds = append(*trackIds, int32(val.Int()))
419+
case "playlist", "album":
420+
*playlistIds = append(*playlistIds, int32(val.Int()))
421+
}
422+
}
423+
424+
if val := gjson.GetBytes(action, "data.content_id"); val.Exists() && val.Type == gjson.Number {
425+
switch strings.ToLower(gjson.GetBytes(action, "data.content_type").String()) {
426+
case "track":
427+
*trackIds = append(*trackIds, int32(val.Int()))
428+
case "playlist", "album":
429+
*playlistIds = append(*playlistIds, int32(val.Int()))
430+
}
431+
}
432+
433+
// Comment notifications: entity_id is a track when entity_type is Track.
434+
if val := gjson.GetBytes(action, "data.entity_id"); val.Exists() && val.Type == gjson.Number {
435+
if strings.EqualFold(gjson.GetBytes(action, "data.entity_type").String(), "track") {
436+
*trackIds = append(*trackIds, int32(val.Int()))
437+
}
438+
}
439+
}

api/v1_notifications_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package api
22

33
import (
4+
"strconv"
45
"testing"
56

67
"api.audius.co/database"
@@ -468,3 +469,117 @@ func TestV1Notifications_AnnouncementRequiresUserIdInUserIds(t *testing.T) {
468469
"data.notifications.0.actions.0.data.title": "For user 1",
469470
})
470471
}
472+
473+
// TestV1Notifications_RelatedEntities exercises the response's `related` block:
474+
//
475+
// - users/tracks/playlists referenced by notification action data are
476+
// hydrated server-side so the client doesn't need follow-up round trips
477+
// - actor IDs are capped at notificationRelatedActorsPerGroup per group so
478+
// a fan-out notification (e.g. 100 followers) doesn't bloat the response;
479+
// the target entity (the followee, in this case) is duplicated in every
480+
// action's data so it's still picked up under the cap
481+
// - polymorphic *_item_id fields (repost_item_id here) are routed to the
482+
// right bucket based on the sibling `type` discriminator
483+
func TestV1Notifications_RelatedEntities(t *testing.T) {
484+
app := emptyTestApp(t)
485+
486+
const recipient = 1
487+
// Five followers, but the per-group cap should drop us to
488+
// notificationRelatedActorsPerGroup followers + the followee.
489+
followers := []int{100, 101, 102, 103, 104}
490+
const reposter = 300
491+
const repostedTrackID = 50
492+
const repostedTrackOwner = 200
493+
const savedPlaylistID = 60
494+
const saver = 400
495+
496+
users := []map[string]any{
497+
{"user_id": recipient},
498+
{"user_id": reposter},
499+
{"user_id": repostedTrackOwner},
500+
{"user_id": saver},
501+
}
502+
for _, fid := range followers {
503+
users = append(users, map[string]any{"user_id": fid})
504+
}
505+
506+
// timestamp is intentionally omitted — the seed default (time.Now()) keeps
507+
// these notifications inside the SQL handler's 90-day initial-load window.
508+
notifs := []map[string]any{
509+
{
510+
"id": 10,
511+
"specifier": "300",
512+
"group_id": "repost:track:50",
513+
"type": "repost",
514+
"user_ids": []int{recipient},
515+
"data": []byte(`{"type": "track", "user_id": 300, "repost_item_id": 50}`),
516+
},
517+
{
518+
"id": 11,
519+
"specifier": "400",
520+
"group_id": "save:playlist:60",
521+
"type": "save",
522+
"user_ids": []int{recipient},
523+
"data": []byte(`{"type": "playlist", "user_id": 400, "save_item_id": 60}`),
524+
},
525+
}
526+
// Five follow notifications, all in the same group (one logical
527+
// "you got followed by 5 people" notification after json_agg).
528+
for i, fid := range followers {
529+
notifs = append(notifs, map[string]any{
530+
"id": 20 + i,
531+
"specifier": strconv.Itoa(fid),
532+
"group_id": "follow:1",
533+
"type": "follow",
534+
"user_ids": []int{recipient},
535+
"data": []byte(`{"follower_user_id": ` + strconv.Itoa(fid) +
536+
`, "followee_user_id": ` + strconv.Itoa(recipient) + `}`),
537+
})
538+
}
539+
540+
fixtures := database.FixtureMap{
541+
"users": users,
542+
"tracks": []map[string]any{{"track_id": repostedTrackID, "owner_id": repostedTrackOwner}},
543+
"playlists": []map[string]any{
544+
{"playlist_id": savedPlaylistID, "playlist_owner_id": recipient},
545+
},
546+
"notification": notifs,
547+
}
548+
549+
database.Seed(app.pool.Replicas[0], fixtures)
550+
551+
status, body := testGet(t, app, "/v1/notifications/"+trashid.MustEncodeHashID(recipient))
552+
assert.Equal(t, 200, status)
553+
554+
gotTrackIds := pluckStrings(body, "related.tracks.#.id")
555+
assert.ElementsMatch(t,
556+
[]string{trashid.MustEncodeHashID(repostedTrackID)},
557+
gotTrackIds,
558+
"reposted track must be hydrated under related.tracks",
559+
)
560+
561+
gotPlaylistIds := pluckStrings(body, "related.playlists.#.id")
562+
assert.ElementsMatch(t,
563+
[]string{trashid.MustEncodeHashID(savedPlaylistID)},
564+
gotPlaylistIds,
565+
"saved playlist must be hydrated under related.playlists",
566+
)
567+
568+
gotUserIds := pluckStrings(body, "related.users.#.id")
569+
570+
// Fan-out cap: at most notificationRelatedActorsPerGroup followers from the
571+
// follow group, plus the reposter, the saver, and the followee (recipient).
572+
maxFollowersHydrated := notificationRelatedActorsPerGroup
573+
maxExpected := maxFollowersHydrated + 3 // reposter, saver, followee
574+
assert.LessOrEqual(t, len(gotUserIds), maxExpected,
575+
"actor cap must bound the related.users size for fan-out groups; got %v", gotUserIds)
576+
577+
// Always-included targets: the recipient (followee), the reposter, the saver.
578+
assert.Contains(t, gotUserIds, trashid.MustEncodeHashID(recipient),
579+
"followee (recipient) must appear in related.users")
580+
assert.Contains(t, gotUserIds, trashid.MustEncodeHashID(reposter),
581+
"reposter must appear in related.users")
582+
assert.Contains(t, gotUserIds, trashid.MustEncodeHashID(saver),
583+
"saver must appear in related.users")
584+
}
585+

0 commit comments

Comments
 (0)