@@ -33,6 +33,10 @@ const (
3333 // Historically `limit=0` fell back to the default limit of 20, so unread
3434 // polling counted at most the first page of notification groups.
3535 notificationUnreadPollLimit = 20
36+
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
3640)
3741
3842type GetNotificationsQueryParams struct {
@@ -163,26 +167,7 @@ func (app *ApiServer) v1Notifications(c *fiber.Ctx) error {
163167 return app .v1NotificationsUnreadPoll (c , params )
164168 }
165169
166- sql := `
167- -- user_seen is a window function that gets windows between seen events.
168- --
169- -- seen_at prev_seen_at
170- -- now() "2025-08-05 16:27:53"
171- -- "2025-08-05 16:27:53" "2025-08-04 21:50:38"
172- -- "2025-08-04 21:50:38" "2025-08-04 18:12:41"
173- --
174- WITH user_seen as (
175- SELECT
176- LAG(seen_at, 1, now()::timestamp) OVER ( ORDER BY seen_at desc ) AS seen_at,
177- seen_at as prev_seen_at
178- FROM
179- notification_seen
180- WHERE
181- user_id = @user_id
182- ORDER BY
183- seen_at desc
184- LIMIT 10
185- ),
170+ matchedNotificationsCTE := `
186171-- Equivalent to ARRAY[@user_id] && n.user_ids, split so single-recipient rows
187172-- can use notification_single_recipient_user_timestamp_idx while
188173-- multi-recipient arrays keep the existing overlap semantics.
@@ -239,6 +224,92 @@ matched_notifications AS (
239224 )
240225 )
241226)
227+ `
228+
229+ if params .Timestamp > 0 {
230+ matchedNotificationsCTE = `
231+ -- Timestamp pagination can otherwise aggregate every older notification for
232+ -- prolific users before LIMIT. Pull a generous timestamp-ordered candidate set
233+ -- from each recipient shape first, then apply the same grouping/filtering.
234+ single_recipient_candidates AS MATERIALIZED (
235+ SELECT
236+ n.id,
237+ n.specifier,
238+ n.group_id,
239+ n.type,
240+ n.timestamp,
241+ n.data
242+ FROM notification n
243+ WHERE
244+ array_length(n.user_ids, 1) = 1
245+ AND n.user_ids[1] = @user_id
246+ AND (n.type = ANY(@types) OR @types IS NULL)
247+ AND (n.type != ALL(@unsupported_types))
248+ AND (
249+ n.timestamp < to_timestamp(@timestamp_offset) OR
250+ (
251+ @group_id_offset != '' AND
252+ n.timestamp = to_timestamp(@timestamp_offset) AND
253+ n.group_id < @group_id_offset
254+ )
255+ )
256+ ORDER BY n.timestamp DESC, n.group_id DESC
257+ LIMIT @pagination_candidate_limit
258+ ),
259+ multi_recipient_candidates AS MATERIALIZED (
260+ SELECT
261+ n.id,
262+ n.specifier,
263+ n.group_id,
264+ n.type,
265+ n.timestamp,
266+ n.data
267+ FROM notification n
268+ WHERE
269+ COALESCE(array_length(n.user_ids, 1), 0) != 1
270+ AND ARRAY[@user_id] && n.user_ids
271+ AND (n.type = ANY(@types) OR @types IS NULL)
272+ AND (n.type != ALL(@unsupported_types))
273+ AND (
274+ n.timestamp < to_timestamp(@timestamp_offset) OR
275+ (
276+ @group_id_offset != '' AND
277+ n.timestamp = to_timestamp(@timestamp_offset) AND
278+ n.group_id < @group_id_offset
279+ )
280+ )
281+ ORDER BY n.timestamp DESC, n.group_id DESC
282+ LIMIT @pagination_candidate_limit
283+ ),
284+ matched_notifications AS (
285+ SELECT * FROM single_recipient_candidates
286+ UNION ALL
287+ SELECT * FROM multi_recipient_candidates
288+ )
289+ `
290+ }
291+
292+ sql := `
293+ -- user_seen is a window function that gets windows between seen events.
294+ --
295+ -- seen_at prev_seen_at
296+ -- now() "2025-08-05 16:27:53"
297+ -- "2025-08-05 16:27:53" "2025-08-04 21:50:38"
298+ -- "2025-08-04 21:50:38" "2025-08-04 18:12:41"
299+ --
300+ WITH user_seen as (
301+ SELECT
302+ LAG(seen_at, 1, now()::timestamp) OVER ( ORDER BY seen_at desc ) AS seen_at,
303+ seen_at as prev_seen_at
304+ FROM
305+ notification_seen
306+ WHERE
307+ user_id = @user_id
308+ ORDER BY
309+ seen_at desc
310+ LIMIT 10
311+ ),
312+ ` + matchedNotificationsCTE + `
242313SELECT
243314 n.type,
244315 n.group_id AS group_id,
@@ -395,22 +466,31 @@ limit @limit::int
395466 ctx , cancel := context .WithTimeout (c .Context (), notificationReadTimeout )
396467 defer cancel ()
397468
398- rows , err := app . pool . Query ( ctx , sql , pgx.NamedArgs {
469+ args := pgx.NamedArgs {
399470 "user_id" : userId ,
400471 "limit" : params .Limit ,
401472 "types" : params .Types ,
402473 "group_id_offset" : params .GroupID ,
403474 "timestamp_offset" : params .Timestamp ,
404475 "unsupported_types" : unsupportedNotificationTypes ,
405- })
406- if err != nil {
407- if errors .Is (err , context .DeadlineExceeded ) {
408- return fiber .NewError (fiber .StatusGatewayTimeout , "notifications query timed out" )
409- }
410- return err
411476 }
412477
413- notifs , err := pgx .CollectRows (rows , pgx .RowToAddrOfStructByNameLax [notificationRow ])
478+ if params .Timestamp > 0 {
479+ args ["pagination_candidate_limit" ] = notificationPaginationCandidateLimit
480+ }
481+
482+ var notifs []* notificationRow
483+ var err error
484+ if params .Timestamp > 0 {
485+ notifs , err = app .v1NotificationsPaginatedRows (ctx , sql , args )
486+ } else {
487+ rows , queryErr := app .pool .Query (ctx , sql , args )
488+ if queryErr != nil {
489+ err = queryErr
490+ } else {
491+ notifs , err = pgx .CollectRows (rows , pgx .RowToAddrOfStructByNameLax [notificationRow ])
492+ }
493+ }
414494 if err != nil {
415495 if errors .Is (err , context .DeadlineExceeded ) {
416496 return fiber .NewError (fiber .StatusGatewayTimeout , "notifications query timed out" )
@@ -525,6 +605,45 @@ limit @limit::int
525605
526606}
527607
608+ func (app * ApiServer ) v1NotificationsPaginatedRows (ctx context.Context , sql string , args pgx.NamedArgs ) ([]* notificationRow , error ) {
609+ pool := dbv1 .ChooseReplica (app .pool .Replicas )
610+ if pool == nil {
611+ return nil , pgx .ErrNoRows
612+ }
613+
614+ tx , err := pool .BeginTx (ctx , pgx.TxOptions {AccessMode : pgx .ReadOnly })
615+ if err != nil {
616+ return nil , err
617+ }
618+ defer func () {
619+ rollbackCtx , cancel := context .WithTimeout (context .Background (), 2 * time .Second )
620+ defer cancel ()
621+ _ = tx .Rollback (rollbackCtx )
622+ }()
623+
624+ // The production planner overestimates the multi-recipient overlap branch
625+ // and prefers a table scan; keep the override scoped to this read only.
626+ if _ , err := tx .Exec (ctx , "SET LOCAL enable_seqscan = off" ); err != nil {
627+ return nil , err
628+ }
629+
630+ rows , err := tx .Query (ctx , sql , args )
631+ if err != nil {
632+ return nil , err
633+ }
634+
635+ notifs , err := pgx .CollectRows (rows , pgx .RowToAddrOfStructByNameLax [notificationRow ])
636+ if err != nil {
637+ return nil , err
638+ }
639+
640+ if err := tx .Commit (ctx ); err != nil {
641+ return nil , err
642+ }
643+
644+ return notifs , nil
645+ }
646+
528647// collectNotificationRelatedIds extracts user/track/playlist IDs from a single
529648// raw (pre-hashify) notification action's data so the caller can batch-load
530649// the related entities in one shot. Field names mirror the Python
0 commit comments