@@ -34,9 +34,15 @@ const (
3434 // polling counted at most the first page of notification groups.
3535 notificationUnreadPollLimit = 20
3636
37- // Timestamp pagination should only need the newest rows before the cursor.
38- // This keeps hot users from grouping years of notifications before LIMIT.
39- notificationPaginationCandidateLimit = 25000
37+ // Notification reads should only need the newest rows in the requested
38+ // window. This keeps hot users from grouping years of notifications before
39+ // LIMIT.
40+ notificationReadCandidateLimit = 25000
41+
42+ // Keep the expensive validation/JSON aggregation work bounded after the
43+ // newest notification groups have been selected.
44+ notificationGroupCandidateLimit = 200
45+ notificationActionsPerGroupLimit = 100
4046)
4147
4248type GetNotificationsQueryParams struct {
@@ -78,8 +84,8 @@ WITH latest_seen AS (
7884-- Equivalent to ARRAY[@user_id] && n.user_ids, split so single-recipient rows
7985-- can use notification_single_recipient_user_timestamp_idx while
8086-- multi-recipient arrays keep the existing overlap semantics.
81- single_recipient_groups AS MATERIALIZED (
82- SELECT n.type, n.group_id
87+ single_recipient_candidates AS MATERIALIZED (
88+ SELECT n.type, n.group_id, n.timestamp
8389 FROM notification n
8490 CROSS JOIN latest_seen
8591 WHERE array_length(n.user_ids, 1) = 1
@@ -88,7 +94,13 @@ single_recipient_groups AS MATERIALIZED (
8894 AND n.timestamp > COALESCE(latest_seen.seen_at, '-infinity'::timestamp)
8995 AND (n.type = ANY(@types) OR @types IS NULL)
9096 AND (n.type != ALL(@unsupported_types))
91- GROUP BY n.type, n.group_id
97+ ORDER BY n.timestamp DESC, n.group_id DESC
98+ LIMIT @candidate_limit
99+ ),
100+ single_recipient_groups AS MATERIALIZED (
101+ SELECT type, group_id
102+ FROM single_recipient_candidates
103+ GROUP BY type, group_id
92104 LIMIT @limit
93105),
94106single_recipient_count AS MATERIALIZED (
@@ -97,8 +109,8 @@ single_recipient_count AS MATERIALIZED (
97109),
98110-- Only touch the broader multi-recipient GIN path if the fast single-recipient
99111-- path did not already satisfy the capped unread count.
100- multi_recipient_groups AS MATERIALIZED (
101- SELECT n.type, n.group_id
112+ multi_recipient_candidates AS MATERIALIZED (
113+ SELECT n.type, n.group_id, n.timestamp
102114 FROM notification n
103115 CROSS JOIN latest_seen
104116 WHERE (SELECT unread_count FROM single_recipient_count) < @limit
@@ -114,7 +126,13 @@ multi_recipient_groups AS MATERIALIZED (
114126 WHERE sg.type = n.type
115127 AND sg.group_id = n.group_id
116128 )
117- GROUP BY n.type, n.group_id
129+ ORDER BY n.timestamp DESC, n.group_id DESC
130+ LIMIT @candidate_limit
131+ ),
132+ multi_recipient_groups AS MATERIALIZED (
133+ SELECT type, group_id
134+ FROM multi_recipient_candidates
135+ GROUP BY type, group_id
118136 LIMIT GREATEST(@limit - (SELECT unread_count FROM single_recipient_count), 0)
119137)
120138SELECT COUNT(*)
@@ -129,12 +147,15 @@ FROM (
129147 defer cancel ()
130148
131149 var unreadCount int
132- err := app .pool .QueryRow (ctx , sql , pgx.NamedArgs {
133- "user_id" : app .getUserId (c ),
134- "limit" : notificationUnreadPollLimit ,
135- "types" : params .Types ,
136- "unsupported_types" : unsupportedNotificationTypes ,
137- }).Scan (& unreadCount )
150+ err := app .v1NotificationsReadTx (ctx , func (tx pgx.Tx ) error {
151+ return tx .QueryRow (ctx , sql , pgx.NamedArgs {
152+ "user_id" : app .getUserId (c ),
153+ "limit" : notificationUnreadPollLimit ,
154+ "candidate_limit" : notificationReadCandidateLimit ,
155+ "types" : params .Types ,
156+ "unsupported_types" : unsupportedNotificationTypes ,
157+ }).Scan (& unreadCount )
158+ })
138159 if err != nil {
139160 if errors .Is (err , context .DeadlineExceeded ) {
140161 return fiber .NewError (fiber .StatusGatewayTimeout , "notifications unread query timed out" )
@@ -167,6 +188,7 @@ func (app *ApiServer) v1Notifications(c *fiber.Ctx) error {
167188 return app .v1NotificationsUnreadPoll (c , params )
168189 }
169190
191+ usesCandidateLimit := params .Timestamp > 0 || (params .Timestamp == 0 && params .GroupID == "" )
170192 matchedNotificationsCTE := `
171193-- Equivalent to ARRAY[@user_id] && n.user_ids, split so single-recipient rows
172194-- can use notification_single_recipient_user_timestamp_idx while
@@ -226,7 +248,75 @@ matched_notifications AS (
226248)
227249`
228250
229- if params .Timestamp > 0 {
251+ if params .Timestamp == 0 && params .GroupID == "" {
252+ matchedNotificationsCTE = `
253+ -- Initial loads are bounded to 90 days, but hot users can still have enough
254+ -- rows in that window to time out while grouping. Pull newest candidates from
255+ -- each recipient shape first, then apply the same grouping/filtering.
256+ single_recipient_candidates AS MATERIALIZED (
257+ SELECT
258+ n.id,
259+ n.specifier,
260+ n.group_id,
261+ n.type,
262+ n.timestamp,
263+ n.data
264+ FROM notification n
265+ WHERE
266+ array_length(n.user_ids, 1) = 1
267+ AND n.user_ids[1] = @user_id
268+ AND (n.type = ANY(@types) OR @types IS NULL)
269+ AND (n.type != ALL(@unsupported_types))
270+ AND n.timestamp > (now()::timestamp - interval '90 days')
271+ ORDER BY n.timestamp DESC, n.group_id DESC
272+ LIMIT @candidate_limit
273+ ),
274+ multi_recipient_candidates AS MATERIALIZED (
275+ SELECT
276+ n.id,
277+ n.specifier,
278+ n.group_id,
279+ n.type,
280+ n.timestamp,
281+ n.data
282+ FROM notification n
283+ WHERE
284+ COALESCE(array_length(n.user_ids, 1), 0) != 1
285+ AND ARRAY[@user_id] && n.user_ids
286+ AND (n.type = ANY(@types) OR @types IS NULL)
287+ AND (n.type != ALL(@unsupported_types))
288+ AND n.timestamp > (now()::timestamp - interval '90 days')
289+ ORDER BY n.timestamp DESC, n.group_id DESC
290+ LIMIT @candidate_limit
291+ ),
292+ matched_notifications AS (
293+ SELECT * FROM single_recipient_candidates
294+ UNION ALL
295+ SELECT * FROM multi_recipient_candidates
296+ ),
297+ candidate_groups AS MATERIALIZED (
298+ SELECT type, group_id, max(timestamp) AS max_timestamp
299+ FROM matched_notifications
300+ GROUP BY type, group_id
301+ ORDER BY max(timestamp) DESC, group_id DESC
302+ LIMIT @group_candidate_limit
303+ ),
304+ capped_notifications AS MATERIALIZED (
305+ SELECT id, specifier, group_id, type, timestamp, data
306+ FROM (
307+ SELECT
308+ n.*,
309+ row_number() OVER (
310+ PARTITION BY n.type, n.group_id
311+ ORDER BY n.timestamp DESC, n.id DESC
312+ ) AS action_rank
313+ FROM matched_notifications n
314+ JOIN candidate_groups g USING (type, group_id)
315+ ) ranked
316+ WHERE action_rank <= @actions_per_group_limit
317+ )
318+ `
319+ } else if params .Timestamp > 0 {
230320 matchedNotificationsCTE = `
231321-- Timestamp pagination can otherwise aggregate every older notification for
232322-- prolific users before LIMIT. Pull a generous timestamp-ordered candidate set
@@ -254,7 +344,7 @@ single_recipient_candidates AS MATERIALIZED (
254344 )
255345 )
256346 ORDER BY n.timestamp DESC, n.group_id DESC
257- LIMIT @pagination_candidate_limit
347+ LIMIT @candidate_limit
258348),
259349multi_recipient_candidates AS MATERIALIZED (
260350 SELECT
@@ -279,16 +369,42 @@ multi_recipient_candidates AS MATERIALIZED (
279369 )
280370 )
281371 ORDER BY n.timestamp DESC, n.group_id DESC
282- LIMIT @pagination_candidate_limit
372+ LIMIT @candidate_limit
283373),
284374matched_notifications AS (
285375 SELECT * FROM single_recipient_candidates
286376 UNION ALL
287377 SELECT * FROM multi_recipient_candidates
378+ ),
379+ candidate_groups AS MATERIALIZED (
380+ SELECT type, group_id, max(timestamp) AS max_timestamp
381+ FROM matched_notifications
382+ GROUP BY type, group_id
383+ ORDER BY max(timestamp) DESC, group_id DESC
384+ LIMIT @group_candidate_limit
385+ ),
386+ capped_notifications AS MATERIALIZED (
387+ SELECT id, specifier, group_id, type, timestamp, data
388+ FROM (
389+ SELECT
390+ n.*,
391+ row_number() OVER (
392+ PARTITION BY n.type, n.group_id
393+ ORDER BY n.timestamp DESC, n.id DESC
394+ ) AS action_rank
395+ FROM matched_notifications n
396+ JOIN candidate_groups g USING (type, group_id)
397+ ) ranked
398+ WHERE action_rank <= @actions_per_group_limit
288399)
289400`
290401 }
291402
403+ notificationSourceCTE := "matched_notifications"
404+ if usesCandidateLimit {
405+ notificationSourceCTE = "capped_notifications"
406+ }
407+
292408 sql := `
293409-- user_seen is a window function that gets windows between seen events.
294410--
@@ -344,7 +460,7 @@ SELECT
344460 ELSE null
345461 END AS seen_at
346462FROM
347- matched_notifications n
463+ ` + notificationSourceCTE + ` n
348464LEFT JOIN user_seen ON
349465 user_seen.seen_at >= n.timestamp AND user_seen.prev_seen_at < n.timestamp
350466-- Join with tracks table to filter out deleted tracks for "create" notifications that have track_id
@@ -475,14 +591,16 @@ limit @limit::int
475591 "unsupported_types" : unsupportedNotificationTypes ,
476592 }
477593
478- if params .Timestamp > 0 {
479- args ["pagination_candidate_limit" ] = notificationPaginationCandidateLimit
594+ if usesCandidateLimit {
595+ args ["candidate_limit" ] = notificationReadCandidateLimit
596+ args ["group_candidate_limit" ] = notificationGroupCandidateLimit
597+ args ["actions_per_group_limit" ] = notificationActionsPerGroupLimit
480598 }
481599
482600 var notifs []* notificationRow
483601 var err error
484- if params . Timestamp > 0 {
485- notifs , err = app .v1NotificationsPaginatedRows (ctx , sql , args )
602+ if usesCandidateLimit {
603+ notifs , err = app .v1NotificationsRowsWithReadTx (ctx , sql , args )
486604 } else {
487605 rows , queryErr := app .pool .Query (ctx , sql , args )
488606 if queryErr != nil {
@@ -605,15 +723,15 @@ limit @limit::int
605723
606724}
607725
608- func (app * ApiServer ) v1NotificationsPaginatedRows (ctx context.Context , sql string , args pgx.NamedArgs ) ([] * notificationRow , error ) {
726+ func (app * ApiServer ) v1NotificationsReadTx (ctx context.Context , fn func ( pgx.Tx ) error ) error {
609727 pool := dbv1 .ChooseReplica (app .pool .Replicas )
610728 if pool == nil {
611- return nil , pgx .ErrNoRows
729+ return pgx .ErrNoRows
612730 }
613731
614732 tx , err := pool .BeginTx (ctx , pgx.TxOptions {AccessMode : pgx .ReadOnly })
615733 if err != nil {
616- return nil , err
734+ return err
617735 }
618736 defer func () {
619737 rollbackCtx , cancel := context .WithTimeout (context .Background (), 2 * time .Second )
@@ -624,20 +742,28 @@ func (app *ApiServer) v1NotificationsPaginatedRows(ctx context.Context, sql stri
624742 // The production planner overestimates the multi-recipient overlap branch
625743 // and prefers a table scan; keep the override scoped to this read only.
626744 if _ , err := tx .Exec (ctx , "SET LOCAL enable_seqscan = off" ); err != nil {
627- return nil , err
745+ return err
628746 }
629747
630- rows , err := tx .Query (ctx , sql , args )
631- if err != nil {
632- return nil , err
748+ if err := fn (tx ); err != nil {
749+ return err
633750 }
634751
635- notifs , err := pgx .CollectRows (rows , pgx .RowToAddrOfStructByNameLax [notificationRow ])
636- if err != nil {
637- return nil , err
638- }
752+ return tx .Commit (ctx )
753+ }
639754
640- if err := tx .Commit (ctx ); err != nil {
755+ func (app * ApiServer ) v1NotificationsRowsWithReadTx (ctx context.Context , sql string , args pgx.NamedArgs ) ([]* notificationRow , error ) {
756+ var notifs []* notificationRow
757+ err := app .v1NotificationsReadTx (ctx , func (tx pgx.Tx ) error {
758+ rows , err := tx .Query (ctx , sql , args )
759+ if err != nil {
760+ return err
761+ }
762+
763+ notifs , err = pgx .CollectRows (rows , pgx .RowToAddrOfStructByNameLax [notificationRow ])
764+ return err
765+ })
766+ if err != nil {
641767 return nil , err
642768 }
643769
0 commit comments