Skip to content

perf(notifications): cap the notification counts (#654) - #697

Merged
omattsson merged 4 commits into
mainfrom
fix/654-notification-count
Oct 23, 2026
Merged

omattsson merged 4 commits into
mainfrom
fix/654-notification-count

Conversation

@omattsson

@omattsson omattsson commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Closes #654

Summary

GET /api/v1/notifications read every notification of the user on each call to compute the exact total, and every unread one for unread_count. Both counts now stop early.

  • total is exact up to offset + 1000. Above that it is offset + 1000 and total_capped is true. The count reads at most offset + 1001 index rows, through a LIMIT in a derived table (countWindow).
  • unread_count (list and GET /notifications/count) is exact up to 1000. Above that it is 1000 and unread_count_capped is true.
  • unread_only: on the first page (offset 0) the handler uses the capped total as the unread count and skips the second query. On later pages it counts again, so the list and /count always agree.
  • Notifications page: shows "1–25 of 1000+" and "Unread (1000+)", and paging past 1000 keeps working. The bell already shows "99+".

The index (user_id, created_at DESC) and the definition-lock fix of this issue are already on main (#659).

API change

  • total, unread_count and total_capped/unread_count_capped are always present. Existing clients keep working: below the caps the numbers mean what they did before, and above them they are a lower bound.
  • GET /notifications/count now returns {"unread_count": N, "unread_count_capped": false} (Swagger models.UnreadNotificationCount) instead of a map of integers. A client generated from the old Swagger that decodes into a map of integers fails on the boolean. The web UI and stackctl handle it.
  • stackctl follow-up: feat(notification): show the capped notification counts stackctl#172 shows "1000+" and passes the flags through.

Load test

cleanup-notify (k6-stress.js, 20 VUs, 5 min, one user with up to 11,605 notifications, MySQL 8.4.4, a fresh database each run):

main this branch
Row reads on notifications 34.6 million 7.0 million
List total count, rows per call 5,803 966
List total count, total time 21.2 s 5.2 s

Both runs: 5,700 iterations, 24,180 requests, no failures. The unread count stayed at about 120 rows per call in both runs, because the scenario marks all read every fifth iteration.

EXPLAIN ANALYZE (2,500 rows of one user): the new count is a covering index lookup with Limit: 1001, so it reads 1,001 index rows instead of 2,500. TestDatabaseMySQL_NotificationTotalCap logs it and checks the bound.

Tests

  • Backend: table-driven repository tests on SQLite with a lowered cap (boundaries, offset, overflow, unread only), handler tests with gojsonschema schemas for both endpoints, and a MySQL integration test for the counts and their row reads.
  • Frontend: Vitest for the capped total, the capped unread count, and responses without the flags.
  • Run: go vet ./... (also -tags integration), go test ./... -short, the MySQL integration tests in internal/database, make gosec (0 issues), npm test (1,972 pass) and npm run lint (0 errors, 5 existing warnings).

🤖 Generated with Claude Code

omattsson and others added 4 commits October 23, 2026 20:32
GET /api/v1/notifications counted every notification of the user on each
call for `total` (about 6,000 row reads per call in the cleanup-notify
load test). The count now stops after offset + 1001 rows, through a LIMIT
in a derived table (models.NotificationTotalCap = 1000):

- up to offset + 1000 matching rows, `total` is exact and `total_capped`
  is false;
- above that, `total` is offset + 1000 and `total_capped` is true.

`unread_count` stays exact. With unread_only=true and an exact total, the
handler uses the total and skips CountUnread. The window saturates at
math.MaxInt64 for a huge offset.

On MySQL 8.4 with 2,500 rows of one user, the count reads 1,001 covering
index rows instead of 2,500 (TestDatabaseMySQL_NotificationTotalCap logs
EXPLAIN, EXPLAIN ANALYZE and the handler read counters). Swagger and the
agent docs describe the new field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Notifications page shows "1–25 of 1000+" when the API sets
`total_capped`, and keeps paging forward. A response without the field
(an older backend) counts as not capped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CountUnread read every unread row of the user on each list call and on
each GET /notifications/count. It now uses the windowed count of the list
total: exact up to 1000 unread notifications (at most 1001 rows read),
else 1000 with `unread_count_capped: true`.

- GET /notifications adds `unread_count_capped`.
- GET /notifications/count returns models.UnreadNotificationCount
  ({unread_count, unread_count_capped}) instead of a map.
- With unread_only=true at offset 0, the list uses its total as the unread
  count and skips CountUnread. At a larger offset the total can be exact
  above the cap, so the handler counts again: the list and /count always
  agree.

cleanup-notify load test (k6-stress.js, 20 VUs, 5 min, one user with up to
11,605 notifications, MySQL 8.4.4, fresh database each run):

| | origin/main | this branch |
|---|---|---|
| notifications row reads | 34.6 million | 7.0 million |
| list total count, rows per call | 5,803 | 966 |
| list total count, total time | 21.2 s | 5.2 s |

The unread count stayed at about 120 rows per call in both runs, because
the scenario marks all read every fifth iteration. The branch run predates
the offset-0 shortcut change, which does not affect that scenario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Notifications page shows "Unread (1000+)" when the API sets
`unread_count_capped`. Reading one notification keeps the "+" (999+), and
"Mark all read" clears it. A response without the field counts as not
capped. The bell badge already shows "99+" from 100 on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@omattsson omattsson added the performance Performance optimization label Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3aa107bf-3995-46c2-9142-95736fd2258f
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@omattsson
omattsson merged commit 97040b6 into main Oct 23, 2026
8 checks passed
@omattsson
omattsson deleted the fix/654-notification-count branch October 23, 2026 19:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Performance optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Notification list reads all rows of the user on each call

1 participant