Skip to content

fix(notifications): paginate and index the notification list endpoints - #3

Closed
tagpro wants to merge 4 commits into
masterfrom
fix/notifications-pagination-fork
Closed

fix(notifications): paginate and index the notification list endpoints#3
tagpro wants to merge 4 commits into
masterfrom
fix/notifications-pagination-fork

Conversation

@tagpro

@tagpro tagpro commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Fork-side copy of upstream maxdorninger/MediaManager#564, including the review fixes.

Problem

Both notification list endpoints returned the entire table — no LIMIT/OFFSET. Every visit to the notifications tab, and every 30s poll, fetched every row, pydantic-validated each one and shipped the lot to the browser. On the instance that surfaced this the table had ~437,000 rows. There was also no index beyond the primary key, so ORDER BY timestamp DESC sorted the whole table each load and the unread query (WHERE read = false) was a full scan.

Changes

Backend

  • GET /notification and GET /notification/unread take limit (default 50, le=200) and offset (ge=0), threaded through the service into .limit()/.offset(). Ordering unchanged (timestamp descending).
  • Total matching count returned in an X-Total-Count header (CORS-exposed) so the UI knows how many pages exist.
  • GET /notification takes an optional read filter — the UI used to build its "read" list by fetching everything and filtering client-side, which a paginated endpoint can't support.
  • Migration b3d51c7fa9e2 adds an index on timestamp DESC and a partial index (WHERE read = false) for the unread query.
  • New PATCH /notification/read marks every unread notification read in one UPDATE. "Mark All as Read" previously looped a PATCH over the notifications the client held, which only worked because it held all of them.

Frontend

  • Notifications page loads a page of 50 with "Load more" and a "Showing N of M" count, for both sections. Read notifications — the bulk of the table — aren't fetched until that section is expanded.

Review fixes (on top of the upstream branch)

  • A missing X-Total-Count no longer collapses the totals to zero: Number(null) is 0, not NaN, so the isNaN fallback never ran, which could hide the unread count and the "Mark All as Read" button while rows were still on screen.
  • The 30s poll and a "Load More" click both wrote the unread list and could overlap, letting whichever response landed last win — dropping a page the user had just loaded. Unread fetches are now queued so each reads its offset only after the previous settles, and the poll skips while a "Load More" is pending.

Tests

  • Repository tests: limit caps the result, offset skips correctly, ordering is timestamp descending, the unread query returns only unread rows and respects limit/offset, and bulk mark-as-read reaches past the loaded page. Seeded with more rows than the page size so pagination is actually exercised. Adds a small pytest harness (async SQLite via aiosqlite, plus a test dependency group), since there wasn't one.

Not in this PR

The reason that instance had 437k notifications is a separate root cause — identical failure notifications are written every import cycle. Worth fixing on its own; this PR is limited to making the endpoints bounded and indexed.

Verification

  • uv run pytest — 14 passed
  • uv run ruff check ./media_manager — clean
  • npm run lint (in web/) — clean
  • Drove the router end-to-end against a seeded DB: default page returns 50 rows with X-Total-Count, limit=500 is rejected with 422, offset pages don't overlap, the unread page never contains read rows, and PATCH /notification/read empties the unread list.
  • alembic upgrade e60ae827ed98:b3d51c7fa9e2 --sql renders the expected DDL, including the partial index.
  • Simulated the poll/"Load More" overlap: the old concurrent code produced a list that jumped from row 49 to row 100, silently dropping a page; queued fetches produce a contiguous list.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SC2BDd7wA7hPa7k2h2yAv1

Summary by CodeRabbit

  • New Features

    • Added paginated notification lists with configurable limits, offsets, and read/unread filtering.
    • Added total notification counts through response headers.
    • Added “Load More” controls and “Showing X of Y” indicators for unread and read notifications.
    • Added a one-click option to mark all notifications as read.
    • Read notifications now load only when requested.
  • Bug Fixes

    • Improved notification loading reliability during polling and concurrent actions.
    • Improved performance for notification retrieval and unread searches.

tagpro and others added 4 commits July 11, 2026 16:23
The notification tab loaded slowly because both list endpoints returned the
entire notification table. GET /notification and GET /notification/unread ran
`select(Notification).order_by(timestamp desc)` with no limit, so every page
load fetched every row, pydantic-validated each one, serialised the lot to JSON
and shipped it to the browser. On the instance this was found on the table had
~437,000 rows.

The table also had no index beyond the primary key on `id`, so the ORDER BY
sorted the whole table on every load and the unread filter was a full scan.

Backend:
- Both list endpoints now take `limit` (default 50, max 200) and `offset`
  (>= 0), threaded through the service into `.limit()/.offset()` in the SQL.
  Ordering stays timestamp descending. The number of matching notifications is
  returned in the `X-Total-Count` response header (exposed via CORS) so the UI
  can paginate.
- GET /notification takes an optional `read` filter, so the UI can page through
  read notifications rather than fetching everything and filtering client-side.
- New migration indexes the table for these queries: an index on `timestamp`
  descending for the ORDER BY, and a partial index for the unread query.
- New PATCH /notification/read marks every unread notification read in a single
  statement. The UI used to do this by looping a PATCH over every notification
  it held, which only worked because it held all of them.

Frontend:
- The notifications page now loads a page of 50 with "load more", and does not
  fetch read notifications at all until that section is expanded.

Tests:
- Repository tests covering the limit cap, offset, timestamp-descending
  ordering, the unread query, and the bulk mark-as-read, seeded with more rows
  than the page size so pagination is actually exercised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wrZPxJDip5x3mzjWnpwos
Brings the notification pagination work (based on upstream/master, PR
maxdorninger#564) onto the fork's master, which carries the
fork-specific CI and MissingGreenlet commits.

The only conflict was tests/conftest.py, which both sides add: the fork's
version (movie/tv fixtures for the eager-loading tests) is kept, with the
notification models registered on Base.metadata alongside them so the
notification repository tests can create their table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019wrZPxJDip5x3mzjWnpwos
…ad fetches

Number(null) is 0, so an absent X-Total-Count header collapsed unreadTotal
and readTotal to zero rather than falling back to the loaded count, hiding
the unread count and the "Mark All as Read" button while rows were on screen.

The 30s poll and a "Load More" click both wrote unreadNotifications and could
overlap, letting whichever response landed last win: a poll issued before a
"Load More" but resolving after it truncates the list back to the first page,
dropping the page the user just asked for. Queue the unread fetches so each
reads its offset only once the previous one has settled, and skip the poll
while a "Load More" is pending so it cannot discard a loaded page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SC2BDd7wA7hPa7k2h2yAv1
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Notifications now support indexed pagination, read filtering, total-count headers, bulk mark-as-read operations, generated API typings, repository tests, and incremental dashboard loading for unread and read sections.

Changes

Notification pagination and read management

Layer / File(s) Summary
Indexed notification queries
alembic/versions/..., media_manager/notification/models.py, media_manager/notification/repository.py, tests/...
Adds timestamp and unread indexes, paginated read-filtered repository queries, notification counts, bulk read updates, and async repository coverage.
Service and HTTP notification contract
media_manager/notification/service.py, media_manager/notification/router.py, media_manager/main.py, web/src/lib/api/api.d.ts
Propagates pagination through the service, exposes paginated endpoints with X-Total-Count, adds PATCH /notification/read, updates generated typings, and exposes the response header through CORS.
Dashboard paginated notification state
web/src/routes/dashboard/notifications/+page.svelte
Loads notification pages incrementally, tracks totals, serializes unread refreshes, supports lazy read loading and load-more controls, and updates read state locally.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Dashboard
  participant NotificationAPI
  participant NotificationService
  participant NotificationRepository
  participant Database

  Dashboard->>NotificationAPI: GET notification page
  NotificationAPI->>NotificationService: Count and fetch page
  NotificationService->>NotificationRepository: Apply filters and pagination
  NotificationRepository->>Database: Execute indexed query
  Database-->>NotificationRepository: Rows and count
  NotificationRepository-->>NotificationService: Results
  NotificationService-->>NotificationAPI: Results and total
  NotificationAPI-->>Dashboard: Notifications with X-Total-Count
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: pagination and indexing for notification list endpoints.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/notifications-pagination-fork
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/notifications-pagination-fork

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
media_manager/notification/repository.py (1)

86-95: 🗄️ Data Integrity & Integration | 🔵 Trivial

Count and page queries are issued separately — consider the race window.

The router calls count_notifications and get_all_notifications as independent queries. If a notification is inserted or deleted between the two calls, the X-Total-Count header can disagree with the actual page contents. For a notification list this is typically acceptable (the user simply sees a slightly stale count), but if strict consistency is needed, consider counting within the same transaction snapshot or using a single query with window functions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@media_manager/notification/repository.py` around lines 86 - 95, Review the
notification-list flow around count_notifications and get_all_notifications and
avoid issuing them against separate transaction snapshots when strict
consistency is required. Use one shared transaction snapshot for both queries,
or combine the count and page retrieval into a single window-function query,
while preserving the existing read filter and pagination behavior.
web/src/routes/dashboard/notifications/+page.svelte (1)

316-317: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Inconsistent event-binding syntax.

This button uses the legacy on:click directive while other buttons in this same file (e.g., Line 222, Line 296, Line 390) use the Svelte 5 onclick={...} attribute form. Both work, but mixing styles in one component is inconsistent.

-			<button
-				on:click={() => toggleShowRead()}
+			<button
+				onclick={() => toggleShowRead()}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/routes/dashboard/notifications/`+page.svelte around lines 316 - 317,
Update the button invoking toggleShowRead to use Svelte 5’s onclick attribute
syntax, matching the other buttons in this component, and remove the legacy
on:click directive.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/src/routes/dashboard/notifications/`+page.svelte:
- Around line 177-189: Handle the promise returned by the polling call to
loadUnread({ reset: true }) inside the setInterval callback by attaching a
rejection handler, preventing fetch failures from becoming unhandled rejections
while preserving the existing polling guards and loading behavior.
- Around line 78-87: Update loadRead to mirror the serialized request flow used
by loadUnread: add a read-notification queue and move offset calculation into
the queued fetch operation so it uses the latest readNotifications state. Route
reset and append loads through this queue, preserving reset semantics and
ensuring overlapping calls from loadMoreRead, toggleShowRead, and markAllAsRead
cannot apply stale results.

---

Nitpick comments:
In `@media_manager/notification/repository.py`:
- Around line 86-95: Review the notification-list flow around
count_notifications and get_all_notifications and avoid issuing them against
separate transaction snapshots when strict consistency is required. Use one
shared transaction snapshot for both queries, or combine the count and page
retrieval into a single window-function query, while preserving the existing
read filter and pagination behavior.

In `@web/src/routes/dashboard/notifications/`+page.svelte:
- Around line 316-317: Update the button invoking toggleShowRead to use Svelte
5’s onclick attribute syntax, matching the other buttons in this component, and
remove the legacy on:click directive.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6910fc63-f47a-45ac-9780-fa72e0905358

📥 Commits

Reviewing files that changed from the base of the PR and between e070dc6 and 77b3486.

📒 Files selected for processing (10)
  • alembic/versions/b3d51c7fa9e2_index_notification_timestamp_and_read.py
  • media_manager/main.py
  • media_manager/notification/models.py
  • media_manager/notification/repository.py
  • media_manager/notification/router.py
  • media_manager/notification/service.py
  • tests/conftest.py
  • tests/test_notification_repository.py
  • web/src/lib/api/api.d.ts
  • web/src/routes/dashboard/notifications/+page.svelte

Comment on lines +78 to +87
async function loadRead({ reset = false } = {}) {
const offset = reset ? 0 : readNotifications.length;
const { data, response } = await client.GET('/api/v1/notification', {
params: { query: { limit: PAGE_SIZE, offset, read: true } }
});
if (!data) return;

readNotifications = reset ? data : [...readNotifications, ...data];
readTotal = totalCountOf(response, readNotifications.length);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

loadRead lacks the same write-serialization loadUnread has, reintroducing the race the PR set out to fix.

Unlike loadUnread, which computes offset lazily inside fetchPage and serializes calls through unreadQueue, loadRead computes offset = reset ? 0 : readNotifications.length synchronously at call time and has no queue. This is invoked from three places that can genuinely overlap:

  • loadMoreRead (Line 98) — a "Load More" click, guarded only by disabling its own button.
  • toggleShowRead (Line 107) — opens the section and does an initial reset: true load.
  • markAllAsRead (Line 155) — resets readNotifications/readTotal to empty, then (if showRead) calls loadRead({ reset: true }).

If a user opens the "Read Notifications" section, clicks "Load More", and then clicks "Mark All as Read" before the load-more request resolves, both loadRead calls run concurrently. Whichever resolves last wins: the stale, pre-reset "load more" response (computed with the old, larger offset) can overwrite the freshly reset list, or duplicate/drop items in the keyed {#each readNotifications as notification (notification.id)} block — exactly the class of bug the PR's unreadQueue was built to prevent, just not applied to loadRead.

🔒️ Proposed fix: mirror `unreadQueue` for read notifications
+	let readQueue: Promise<void> = Promise.resolve();
+
-	async function loadRead({ reset = false } = {}) {
-		const offset = reset ? 0 : readNotifications.length;
-		const { data, response } = await client.GET('/api/v1/notification', {
-			params: { query: { limit: PAGE_SIZE, offset, read: true } }
-		});
-		if (!data) return;
-
-		readNotifications = reset ? data : [...readNotifications, ...data];
-		readTotal = totalCountOf(response, readNotifications.length);
-	}
+	function loadRead({ reset = false } = {}): Promise<void> {
+		const fetchPage = async () => {
+			const offset = reset ? 0 : readNotifications.length;
+			const { data, response } = await client.GET('/api/v1/notification', {
+				params: { query: { limit: PAGE_SIZE, offset, read: true } }
+			});
+			if (!data) return;
+
+			readNotifications = reset ? data : [...readNotifications, ...data];
+			readTotal = totalCountOf(response, readNotifications.length);
+		};
+
+		readQueue = readQueue.then(fetchPage, fetchPage);
+		return readQueue;
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function loadRead({ reset = false } = {}) {
const offset = reset ? 0 : readNotifications.length;
const { data, response } = await client.GET('/api/v1/notification', {
params: { query: { limit: PAGE_SIZE, offset, read: true } }
});
if (!data) return;
readNotifications = reset ? data : [...readNotifications, ...data];
readTotal = totalCountOf(response, readNotifications.length);
}
let readQueue: Promise<void> = Promise.resolve();
function loadRead({ reset = false } = {}): Promise<void> {
const fetchPage = async () => {
const offset = reset ? 0 : readNotifications.length;
const { data, response } = await client.GET('/api/v1/notification', {
params: { query: { limit: PAGE_SIZE, offset, read: true } }
});
if (!data) return;
readNotifications = reset ? data : [...readNotifications, ...data];
readTotal = totalCountOf(response, readNotifications.length);
};
readQueue = readQueue.then(fetchPage, fetchPage);
return readQueue;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/routes/dashboard/notifications/`+page.svelte around lines 78 - 87,
Update loadRead to mirror the serialized request flow used by loadUnread: add a
read-notification queue and move offset calculation into the queued fetch
operation so it uses the latest readNotifications state. Route reset and append
loads through this queue, preserving reset semantics and ensuring overlapping
calls from loadMoreRead, toggleShowRead, and markAllAsRead cannot apply stale
results.

Comment on lines 177 to 189
onMount(() => {
fetchNotifications();
loading = true;
loadUnread({ reset: true }).finally(() => (loading = false));

const interval = setInterval(fetchNotifications, 30000);
const interval = setInterval(() => {
if (loading || markingAllAsRead || loadingMoreUnread) return;
// Only refresh while a single page is shown, so polling cannot discard
// pages the user has loaded.
if (unreadNotifications.length > PAGE_SIZE) return;
loadUnread({ reset: true });
}, 30000);
return () => clearInterval(interval);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unhandled rejection risk from unguarded polling call.

loadUnread({ reset: true }) inside setInterval (Line 186) is fire-and-forget with no .catch. Since unreadQueue = unreadQueue.then(fetchPage, fetchPage) will re-throw if fetchPage itself fails, a persistent network error will produce an unhandled promise rejection every 30 seconds indefinitely.

🔧 Proposed fix
-			loadUnread({ reset: true });
+			loadUnread({ reset: true }).catch((error) => console.error('Failed to refresh unread notifications:', error));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onMount(() => {
fetchNotifications();
loading = true;
loadUnread({ reset: true }).finally(() => (loading = false));
const interval = setInterval(fetchNotifications, 30000);
const interval = setInterval(() => {
if (loading || markingAllAsRead || loadingMoreUnread) return;
// Only refresh while a single page is shown, so polling cannot discard
// pages the user has loaded.
if (unreadNotifications.length > PAGE_SIZE) return;
loadUnread({ reset: true });
}, 30000);
return () => clearInterval(interval);
});
onMount(() => {
loading = true;
loadUnread({ reset: true }).finally(() => (loading = false));
const interval = setInterval(() => {
if (loading || markingAllAsRead || loadingMoreUnread) return;
// Only refresh while a single page is shown, so polling cannot discard
// pages the user has loaded.
if (unreadNotifications.length > PAGE_SIZE) return;
loadUnread({ reset: true }).catch((error) => console.error('Failed to refresh unread notifications:', error));
}, 30000);
return () => clearInterval(interval);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/routes/dashboard/notifications/`+page.svelte around lines 177 - 189,
Handle the promise returned by the polling call to loadUnread({ reset: true })
inside the setInterval callback by attaching a rejection handler, preventing
fetch failures from becoming unhandled rejections while preserving the existing
polling guards and loading behavior.

@tagpro

tagpro commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Superseded — this branch re-applied the pagination commits that are already on master via #2, so it conflicted. Replaced by a clean PR carrying only the review fixes.

@tagpro tagpro closed this Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant