Skip to content

Commit 86bd58d

Browse files
Bound initial notification reads (#989)
## Summary - Bound unread notification polling to the newest 25k single-recipient and 25k multi-recipient candidates before grouping. - Bound first-page and timestamp notification reads to newest candidate groups, then cap expensive validation/JSON aggregation to the newest 100 actions per selected group. - Reuse the read-only transaction with scoped `SET LOCAL enable_seqscan = off` for candidate-limited notification reads, including unread polling. ## Why - Today after PR #988, the remaining failures shifted to first-page `limit=20` and unread `limit=0`, not just timestamp pagination. - Since midnight Pacific on 2026-07-07, Cloud Logging showed 49 `notifications unread query timed out` and 13 `notifications query timed out` messages. - The hot first page for `eJ57D` includes giant groups: roughly 11.7k `save:4281:type:playlist` rows and 12.3k `follow:51` rows inside the newest candidate groups. Candidate rows alone were still too many for the full validation/aggregation stack. ## Live validation - Bounded unread query for `eJ57D` returned count 20 in ~0.6s. - First-page candidate rows without action capping still timed out under the 8s statement timeout when the full filter stack was included. - First-page full filter stack with newest 100 actions per selected group returned a full 20-group page in ~0.42s. ## Product impact - Very large notification groups now return the newest 100 actions for that group in candidate-limited reads instead of trying to return thousands of action rows. - The endpoint still returns the same response shape and group ordering, but giant grouped notifications may include fewer action objects. ## Test plan - `go test ./api -run ^'$'`\n- Attempted `go test ./api -run TestV1Notifications`; blocked locally because the test DB at `localhost:21300` is not running.
1 parent a76ef28 commit 86bd58d

1 file changed

Lines changed: 161 additions & 35 deletions

File tree

api/v1_notifications.go

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

4248
type 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
),
94106
single_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
)
120138
SELECT 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
),
259349
multi_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
),
284374
matched_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
346462
FROM
347-
matched_notifications n
463+
` + notificationSourceCTE + ` n
348464
LEFT 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

Comments
 (0)