Skip to content

Commit a76ef28

Browse files
Optimize notification timestamp pagination (#988)
## Summary - Bound timestamp-based notification pagination to the newest 25k single-recipient and 25k multi-recipient candidates before the expensive grouping/filtering step. - Keep the existing full query shape for non-timestamp notification loads. - Execute timestamp pagination in a read-only transaction with `SET LOCAL enable_seqscan = off` so the multi-recipient candidate branch uses the partial GIN path instead of the bad sequential scan plan. ## Live evidence - Remaining post-rollout failures were full notification pagination (`notifications query timed out`), not unread polling. - On the hot production cursor (`user_id=51`, `timestamp=1783054526.961588`), the unbounded full page timed out under the 8s read guard. - The bounded 25k-per-branch query with the transaction-local planner setting returned a full 20-row page in ~2.1s. - The same bounded query without the planner setting still timed out under 8s, so the local setting is still needed for the multi-recipient overlap branch. - A 100k-per-branch cap crossed the 8s timeout, so 25k keeps useful margin while reducing truncation risk versus the 5k test. ## Test plan - `go test ./api -run ^'$'`
1 parent 42c4ada commit a76ef28

1 file changed

Lines changed: 147 additions & 28 deletions

File tree

api/v1_notifications.go

Lines changed: 147 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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

3842
type 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 + `
242313
SELECT
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

Comments
 (0)