11package api
22
33import (
4+ "context"
45 "encoding/json"
6+ "errors"
57 "slices"
68 "strings"
9+ "time"
710
811 "api.audius.co/api/dbv1"
912 "api.audius.co/trashid"
@@ -21,14 +24,32 @@ import (
2124// surfaces every target — only the actor list is bounded by this cap.
2225const notificationRelatedActorsPerGroup = 1
2326
27+ const (
28+ // Keep notification reads well below the production replica's
29+ // max_standby_streaming_delay so pathological reads fail fast instead of
30+ // holding hot-standby snapshots and making the replica fall behind.
31+ notificationReadTimeout = 8 * time .Second
32+
33+ // Historically `limit=0` fell back to the default limit of 20, so unread
34+ // polling counted at most the first page of notification groups.
35+ notificationUnreadPollLimit = 20
36+ )
37+
2438type GetNotificationsQueryParams struct {
25- // Note that when limit is 0, we return 20 items to calculate unread count
2639 Limit int `query:"limit" default:"20" validate:"min=0,max=100"`
2740 Types []string `query:"types" validate:"dive,oneof=announcement follow repost save remix cosign create tip_receive tip_send challenge_reward repost_of_repost save_of_repost tastemaker reaction supporter_dethroned supporter_rank_up supporting_rank_up milestone track_added_to_playlist tier_change trending trending_playlist trending_underground usdc_purchase_buyer usdc_purchase_seller track_added_to_purchased_album request_manager approve_manager_request track_collaborator_invite track_collaborator_accept claimable_reward comment comment_thread comment_mention comment_reaction listen_streak_reminder fan_remix_contest_started fan_remix_contest_ended fan_remix_contest_ending_soon fan_remix_contest_winners_selected fan_remix_contest_submission artist_remix_contest_ended artist_remix_contest_ending_soon artist_remix_contest_submissions fan_club_text_post remix_contest_update"`
2841 GroupID string `query:"group_id" validate:"omitempty"`
2942 Timestamp float64 `query:"timestamp" validate:"omitempty,min=0"`
3043}
3144
45+ type notificationRow struct {
46+ Type string `json:"type"`
47+ GroupID string `json:"group_id"`
48+ Actions []json.RawMessage `json:"actions"`
49+ IsSeen bool `json:"is_seen"`
50+ SeenAt interface {} `json:"seen_at"`
51+ }
52+
3253var unsupportedNotificationTypes = []string {
3354 // No frontend support
3455 "usdc_transfer" ,
@@ -43,12 +64,87 @@ var unsupportedNotificationTypes = []string{
4364 "remix" ,
4465}
4566
67+ func (app * ApiServer ) v1NotificationsUnreadPoll (c * fiber.Ctx , params GetNotificationsQueryParams ) error {
68+ sql := `
69+ WITH latest_seen AS (
70+ SELECT MAX(seen_at) AS seen_at
71+ FROM notification_seen
72+ WHERE user_id = @user_id
73+ ),
74+ -- Equivalent to ARRAY[@user_id] && n.user_ids, split so single-recipient rows
75+ -- can use notification_single_recipient_user_timestamp_idx while
76+ -- multi-recipient arrays keep the existing overlap semantics.
77+ matched_notifications AS (
78+ SELECT n.type, n.group_id
79+ FROM notification n
80+ CROSS JOIN latest_seen
81+ WHERE array_length(n.user_ids, 1) = 1
82+ AND n.user_ids[1] = @user_id
83+ AND n.timestamp > (now()::timestamp - interval '90 days')
84+ AND n.timestamp > COALESCE(latest_seen.seen_at, '-infinity'::timestamp)
85+ AND (n.type = ANY(@types) OR @types IS NULL)
86+ AND (n.type != ALL(@unsupported_types))
87+ UNION ALL
88+ SELECT n.type, n.group_id
89+ FROM notification n
90+ CROSS JOIN latest_seen
91+ WHERE COALESCE(array_length(n.user_ids, 1), 0) != 1
92+ AND ARRAY[@user_id] && n.user_ids
93+ AND n.timestamp > (now()::timestamp - interval '90 days')
94+ AND n.timestamp > COALESCE(latest_seen.seen_at, '-infinity'::timestamp)
95+ AND (n.type = ANY(@types) OR @types IS NULL)
96+ AND (n.type != ALL(@unsupported_types))
97+ )
98+ SELECT COUNT(*)
99+ FROM (
100+ SELECT 1
101+ FROM matched_notifications
102+ GROUP BY type, group_id
103+ LIMIT @limit
104+ ) unread_notifications;
105+ `
106+ ctx , cancel := context .WithTimeout (c .Context (), notificationReadTimeout )
107+ defer cancel ()
108+
109+ var unreadCount int
110+ err := app .pool .QueryRow (ctx , sql , pgx.NamedArgs {
111+ "user_id" : app .getUserId (c ),
112+ "limit" : notificationUnreadPollLimit ,
113+ "types" : params .Types ,
114+ "unsupported_types" : unsupportedNotificationTypes ,
115+ }).Scan (& unreadCount )
116+ if err != nil {
117+ if errors .Is (err , context .DeadlineExceeded ) {
118+ return fiber .NewError (fiber .StatusGatewayTimeout , "notifications unread query timed out" )
119+ }
120+ return err
121+ }
122+
123+ return c .JSON (fiber.Map {
124+ "data" : fiber.Map {
125+ "notifications" : []notificationRow {},
126+ "unread_count" : unreadCount ,
127+ },
128+ "related" : fiber.Map {
129+ "users" : []any {},
130+ "tracks" : []any {},
131+ "playlists" : []any {},
132+ },
133+ })
134+ }
135+
46136func (app * ApiServer ) v1Notifications (c * fiber.Ctx ) error {
137+ limitZeroPoll := c .Query ("limit" ) == "0"
138+
47139 params := GetNotificationsQueryParams {}
48140 if err := app .ParseAndValidateQueryParams (c , & params ); err != nil {
49141 return err
50142 }
51143
144+ if limitZeroPoll {
145+ return app .v1NotificationsUnreadPoll (c , params )
146+ }
147+
52148 sql := `
53149-- user_seen is a window function that gets windows between seen events.
54150--
@@ -68,6 +164,62 @@ WITH user_seen as (
68164 ORDER BY
69165 seen_at desc
70166 LIMIT 10
167+ ),
168+ -- Equivalent to ARRAY[@user_id] && n.user_ids, split so single-recipient rows
169+ -- can use notification_single_recipient_user_timestamp_idx while
170+ -- multi-recipient arrays keep the existing overlap semantics.
171+ matched_notifications AS (
172+ SELECT
173+ n.id,
174+ n.specifier,
175+ n.group_id,
176+ n.type,
177+ n.timestamp,
178+ n.data
179+ FROM notification n
180+ WHERE
181+ array_length(n.user_ids, 1) = 1
182+ AND n.user_ids[1] = @user_id
183+ AND (n.type = ANY(@types) OR @types IS NULL)
184+ AND (n.type != ALL(@unsupported_types))
185+ AND (
186+ -- Initial load: bound to the last 90 days so heavy users don't fan out
187+ -- over their entire notification history. Pagination (timestamp_offset > 0)
188+ -- is unbounded so scrolling further back still works.
189+ (@timestamp_offset = 0 AND @group_id_offset = '' AND n.timestamp > (now()::timestamp - interval '90 days')) OR
190+ (@timestamp_offset = 0 AND @group_id_offset != '' AND n.group_id < @group_id_offset) OR
191+ (@timestamp_offset > 0 AND n.timestamp < to_timestamp(@timestamp_offset)) OR
192+ (
193+ @group_id_offset != '' AND @timestamp_offset > 0 AND
194+ (n.timestamp = to_timestamp(@timestamp_offset) AND n.group_id < @group_id_offset)
195+ )
196+ )
197+ UNION ALL
198+ SELECT
199+ n.id,
200+ n.specifier,
201+ n.group_id,
202+ n.type,
203+ n.timestamp,
204+ n.data
205+ FROM notification n
206+ WHERE
207+ COALESCE(array_length(n.user_ids, 1), 0) != 1
208+ AND ARRAY[@user_id] && n.user_ids
209+ AND (n.type = ANY(@types) OR @types IS NULL)
210+ AND (n.type != ALL(@unsupported_types))
211+ AND (
212+ -- Initial load: bound to the last 90 days so heavy users don't fan out
213+ -- over their entire notification history. Pagination (timestamp_offset > 0)
214+ -- is unbounded so scrolling further back still works.
215+ (@timestamp_offset = 0 AND @group_id_offset = '' AND n.timestamp > (now()::timestamp - interval '90 days')) OR
216+ (@timestamp_offset = 0 AND @group_id_offset != '' AND n.group_id < @group_id_offset) OR
217+ (@timestamp_offset > 0 AND n.timestamp < to_timestamp(@timestamp_offset)) OR
218+ (
219+ @group_id_offset != '' AND @timestamp_offset > 0 AND
220+ (n.timestamp = to_timestamp(@timestamp_offset) AND n.group_id < @group_id_offset)
221+ )
222+ )
71223)
72224SELECT
73225 n.type,
@@ -103,7 +255,7 @@ SELECT
103255 ELSE null
104256 END AS seen_at
105257FROM
106- notification n
258+ matched_notifications n
107259LEFT JOIN user_seen ON
108260 user_seen.seen_at >= n.timestamp AND user_seen.prev_seen_at < n.timestamp
109261-- Join with tracks table to filter out deleted tracks for "create" notifications that have track_id
@@ -125,12 +277,8 @@ LEFT JOIN track_collaborators tc ON
125277 tc.track_id = (n.data->>'track_id')::integer AND
126278 tc.collaborator_user_id = (n.data->>'collaborator_user_id')::integer
127279WHERE
128- (ARRAY[@user_id] && n.user_ids)
129- AND (n.type = ANY(@types) OR @types IS NULL)
130- -- Ignore notification types not supported by frontend
131- AND (n.type != ALL(@unsupported_types))
132280 -- Filter out notifications for deleted tracks (only for create notifications that have track_id)
133- AND (
281+ (
134282 n.type != 'create'
135283 OR NOT (n.data ? 'track_id')
136284 OR (t.is_delete = false AND t.is_unlisted = false)
@@ -204,18 +352,6 @@ WHERE
204352 )
205353 )
206354 )
207- AND (
208- -- Initial load: bound to the last 90 days so heavy users don't fan out
209- -- over their entire notification history. Pagination (timestamp_offset > 0)
210- -- is unbounded so scrolling further back still works.
211- (@timestamp_offset = 0 AND @group_id_offset = '' AND n.timestamp > (now()::timestamp - interval '90 days')) OR
212- (@timestamp_offset = 0 AND @group_id_offset != '' AND n.group_id < @group_id_offset) OR
213- (@timestamp_offset > 0 AND n.timestamp < to_timestamp(@timestamp_offset)) OR
214- (
215- @group_id_offset != '' AND @timestamp_offset > 0 AND
216- (n.timestamp = to_timestamp(@timestamp_offset) AND n.group_id < @group_id_offset)
217- )
218- )
219355GROUP BY
220356 n.type, n.group_id, user_seen.seen_at, user_seen.prev_seen_at,
221357 CASE
@@ -234,15 +370,11 @@ limit @limit::int
234370;
235371`
236372 userId := app .getUserId (c )
237- type GetNotifsRow struct {
238- Type string `json:"type"`
239- GroupID string `json:"group_id"`
240- Actions []json.RawMessage `json:"actions"`
241- IsSeen bool `json:"is_seen"`
242- SeenAt interface {} `json:"seen_at"`
243- }
244373
245- rows , err := app .pool .Query (c .Context (), sql , pgx.NamedArgs {
374+ ctx , cancel := context .WithTimeout (c .Context (), notificationReadTimeout )
375+ defer cancel ()
376+
377+ rows , err := app .pool .Query (ctx , sql , pgx.NamedArgs {
246378 "user_id" : userId ,
247379 "limit" : params .Limit ,
248380 "types" : params .Types ,
@@ -251,11 +383,17 @@ limit @limit::int
251383 "unsupported_types" : unsupportedNotificationTypes ,
252384 })
253385 if err != nil {
386+ if errors .Is (err , context .DeadlineExceeded ) {
387+ return fiber .NewError (fiber .StatusGatewayTimeout , "notifications query timed out" )
388+ }
254389 return err
255390 }
256391
257- notifs , err := pgx .CollectRows (rows , pgx .RowToAddrOfStructByNameLax [GetNotifsRow ])
392+ notifs , err := pgx .CollectRows (rows , pgx .RowToAddrOfStructByNameLax [notificationRow ])
258393 if err != nil {
394+ if errors .Is (err , context .DeadlineExceeded ) {
395+ return fiber .NewError (fiber .StatusGatewayTimeout , "notifications query timed out" )
396+ }
259397 return err
260398 }
261399
0 commit comments