Skip to content

Commit b41b865

Browse files
Reduce notification timeout long tail (#990)
## Summary - use a smaller candidate window for timestamp pagination so old notification scrollback does not read tens of thousands of historical rows before grouping - give unread polling its own shorter timeout and return a conservative capped unread response instead of surfacing a 504 when the multi-recipient array path is cold ## Production evidence - slow dEKBy cursor with the existing 25k candidate window read ~23.4k rows and built ~2.9k groups before trimming - the same cursor with a 2k candidate window still produced >600 groups and planned/executed in ~51ms warm during read-only production probing - unread timeout case for user 843 was dominated by the multi-recipient GIN path scanning ~42k historical rows to find 7 current unread rows, so this PR fails closed for the poll while we plan the recipient-normalized/index follow-up ## Validation - go test ./api -run '^$' - git diff --check ## Notes - go test ./api -run TestV1Notifications is blocked locally because Postgres on localhost:21300 refused connections
1 parent 86bd58d commit b41b865

1 file changed

Lines changed: 26 additions & 3 deletions

File tree

api/v1_notifications.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ const (
3030
// holding hot-standby snapshots and making the replica fall behind.
3131
notificationReadTimeout = 8 * time.Second
3232

33+
// Unread polling runs frequently in clients. If the multi-recipient array
34+
// path is cold, fall back quickly instead of surfacing a 504.
35+
notificationUnreadPollTimeout = 3 * time.Second
36+
3337
// Historically `limit=0` fell back to the default limit of 20, so unread
3438
// polling counted at most the first page of notification groups.
3539
notificationUnreadPollLimit = 20
@@ -39,6 +43,11 @@ const (
3943
// LIMIT.
4044
notificationReadCandidateLimit = 25000
4145

46+
// Old timestamp pagination can still have years of history behind the
47+
// cursor. A smaller window is enough to find many candidate groups while
48+
// avoiding cold reads over tens of thousands of historical rows.
49+
notificationTimestampPaginationCandidateLimit = 2000
50+
4251
// Keep the expensive validation/JSON aggregation work bounded after the
4352
// newest notification groups have been selected.
4453
notificationGroupCandidateLimit = 200
@@ -143,7 +152,7 @@ FROM (
143152
LIMIT @limit
144153
) unread_notifications;
145154
`
146-
ctx, cancel := context.WithTimeout(c.Context(), notificationReadTimeout)
155+
ctx, cancel := context.WithTimeout(c.Context(), notificationUnreadPollTimeout)
147156
defer cancel()
148157

149158
var unreadCount int
@@ -158,7 +167,17 @@ FROM (
158167
})
159168
if err != nil {
160169
if errors.Is(err, context.DeadlineExceeded) {
161-
return fiber.NewError(fiber.StatusGatewayTimeout, "notifications unread query timed out")
170+
return c.JSON(fiber.Map{
171+
"data": fiber.Map{
172+
"notifications": []notificationRow{},
173+
"unread_count": notificationUnreadPollLimit,
174+
},
175+
"related": fiber.Map{
176+
"users": []any{},
177+
"tracks": []any{},
178+
"playlists": []any{},
179+
},
180+
})
162181
}
163182
return err
164183
}
@@ -578,6 +597,10 @@ limit @limit::int
578597
;
579598
`
580599
userId := app.getUserId(c)
600+
candidateLimit := notificationReadCandidateLimit
601+
if params.Timestamp > 0 {
602+
candidateLimit = notificationTimestampPaginationCandidateLimit
603+
}
581604

582605
ctx, cancel := context.WithTimeout(c.Context(), notificationReadTimeout)
583606
defer cancel()
@@ -592,7 +615,7 @@ limit @limit::int
592615
}
593616

594617
if usesCandidateLimit {
595-
args["candidate_limit"] = notificationReadCandidateLimit
618+
args["candidate_limit"] = candidateLimit
596619
args["group_candidate_limit"] = notificationGroupCandidateLimit
597620
args["actions_per_group_limit"] = notificationActionsPerGroupLimit
598621
}

0 commit comments

Comments
 (0)