Skip to content

perf(notifications): deduplicate user queries across notification agents#2877

Open
dougrathbone wants to merge 2 commits intoseerr-team:developfrom
dougrathbone:dougrathbone/perf/notification-user-query-dedup
Open

perf(notifications): deduplicate user queries across notification agents#2877
dougrathbone wants to merge 2 commits intoseerr-team:developfrom
dougrathbone:dougrathbone/perf/notification-user-query-dedup

Conversation

@dougrathbone
Copy link
Copy Markdown
Contributor

@dougrathbone dougrathbone commented Apr 14, 2026

Description

While investigating the subscriber notification chain in #2745 and #2743, I found a separate performance optimisation along the way: the notification dispatch layer itself was triggering redundant database queries across all active agents.

When a notification fires, NotificationManager.sendNotification() dispatches to all active agents in parallel. Six of those agents (Discord, Email, Telegram, Webpush, Pushover, Pushbullet) each independently call userRepository.find() with no filter, loading every user row plus the eager-loaded UserSettings relation. A single notification event triggers up to 6 concurrent full-table scans of the user table.

This PR moves the user query into the dispatch layer so it runs once, passing the result to agents via payload.adminUsers. Each agent replaces its own query with the pre-loaded list and continues to apply its agent-specific filter as before.

Additionally, the Webpush agent was loading all pending MediaRequest entities (with eager-loaded Media, Seasons, Users, and SeasonRequests) just to read .length for a badge count. Replaced with a lightweight count() query.

Changes:

  • Add adminUsers?: User[] to NotificationPayload
  • Pre-load users once in sendNotification() when notifyAdmin is true or type is MEDIA_APPROVED/MEDIA_DECLINED (the Webpush agent needs users for badge-update pushes on those types even when notifyAdmin is false)
  • Replace userRepository.find() with payload.adminUsers in all 6 agents
  • Replace requestRepository.find() with requestRepository.count() in the Webpush agent
  • Remove unused getRepository and User imports from 5 agents

AI Disclosure: I used Claude Code to help validate my approach.

How Has This Been Tested?

  • Full pnpm build passes with no type errors
  • All existing tests pass (pnpm test - 33/33)
  • The change is mechanical: query moves from agents to dispatch, data flows through existing payload. No behavioral change for any notification type.

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly. (no documentation changes required)
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract (no new UI strings added)
  • Database migration (if required) (no schema changes)

Summary by CodeRabbit

  • New Features
    • Notifications can now include an explicit list of admin recipients so admin alerts follow the provided recipient list.
  • Refactor
    • Notification dispatch now runs asynchronously to improve delivery flow.
    • Agents no longer query all users during sends; admin recipients are taken from the payload when provided.
    • Pending-request counts are computed more efficiently for relevant notification types.

Each notification agent independently called userRepository.find() to
load all users, resulting in up to 6 concurrent full-table scans per
notification event. Move the query into NotificationManager.sendNotification()
so it runs once and passes the result to agents via payload.adminUsers.

Also replace requestRepository.find() with count() in the webpush agent,
which was loading all pending MediaRequest entities with eager relations
just to read .length for a badge count.
@dougrathbone dougrathbone requested a review from a team as a code owner April 14, 2026 04:50
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 14, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6529b03e-6e14-411e-acbd-68d7f228abe2

📥 Commits

Reviewing files that changed from the base of the PR and between 5aa1263 and 71761fb.

📒 Files selected for processing (2)
  • server/lib/notifications/agents/webpush.ts
  • server/lib/notifications/index.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/lib/notifications/index.ts

📝 Walkthrough

Walkthrough

NotificationPayload gained an optional adminUsers?: User[]. NotificationManager now preloads payload.adminUsers (async) when admin notifications are needed and dispatches to agents without agents querying the database. Individual agents (Discord, Email, Pushbullet, Pushover, Telegram, WebPush) consume payload.adminUsers and no longer perform user repository lookups; WebPush also uses a count for pending requests.

Changes

Cohort / File(s) Summary
Type Definition
server/lib/notifications/agents/agent.ts
Added optional adminUsers?: User[] to NotificationPayload.
Notification Agents
server/lib/notifications/agents/discord.ts, server/lib/notifications/agents/email.ts, server/lib/notifications/agents/pushbullet.ts, server/lib/notifications/agents/pushover.ts, server/lib/notifications/agents/telegram.ts
Removed repository lookups for admin users; agents now iterate payload.adminUsers ?? [] for admin-targeted sends and retain existing per-user filtering/validation logic.
WebPush Agent
server/lib/notifications/agents/webpush.ts
Replaced fetching pending requests array with a conditional count() to compute pendingRequestsCount. Switched admin recipients to payload.adminUsers ?? [] and updated payload construction to use pendingRequestsCount.
Notification Manager
server/lib/notifications/index.ts
sendNotification remains void but now invokes a private async dispatchNotification(...) that, when notifyAdmin is true or type is MEDIA_APPROVED/MEDIA_DECLINED, attempts to fetch all User entities and populate payload.adminUsers (or sets it to [] on error) before dispatching to agents.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant NotificationManager
  participant Database as UserRepo
  participant Agents

  Caller->>NotificationManager: sendNotification(type, payload)
  NotificationManager->>NotificationManager: void dispatchNotification(type, payload)
  alt notifyAdmin or MEDIA_APPROVED/DECLINED
    NotificationManager->>UserRepo: find() (async)
    UserRepo-->>NotificationManager: users[] / error
    NotificationManager->>NotificationManager: payload.adminUsers = users[] or []
  end
  NotificationManager->>Agents: invoke each active agent with (type, payload)
  Agents-->>NotificationManager: send results/errors (handled per-agent)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~23 minutes

Poem

🐰 I hopped through code with a twitchy nose,
Gathered admins where the central river flows,
Agents now sip from one clear cup,
No more digging—just pass it up! 🥕✨

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective: deduplicating user queries across notification agents by consolidating six parallel userRepository.find() calls into a single query.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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 and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/lib/notifications/index.ts (1)

101-123: ⚠️ Potential issue | 🟠 Major

Handle admin-user preload failures inside the manager.

sendNotification() now awaits userRepository.find() before any agent runs, but the existing fire-and-forget call sites in server/entity/MediaRequest.ts and the subscribers still do not await this method. A transient failure here now rejects the promise, skips every downstream notification path, and can surface as an unhandled rejection even for MEDIA_APPROVED / MEDIA_DECLINED, where the lookup is only needed for the WebPush badge update. Please catch/log this preload locally or keep a synchronous wrapper around an internal async dispatch so user/system notifications still fan out when the admin lookup fails.

Suggested change
-  public async sendNotification(
+  public sendNotification(
     type: Notification,
     payload: NotificationPayload
-  ): Promise<void> {
+  ): void {
+    void this.dispatchNotification(type, payload);
+  }
+
+  private async dispatchNotification(
+    type: Notification,
+    payload: NotificationPayload
+  ): Promise<void> {
     logger.info(`Sending notification(s) for ${Notification[type]}`, {
       label: 'Notifications',
       subject: payload.subject,
     });

     if (
       payload.notifyAdmin ||
       type === Notification.MEDIA_APPROVED ||
       type === Notification.MEDIA_DECLINED
     ) {
-      const userRepository = getRepository(User);
-      payload.adminUsers = await userRepository.find();
+      try {
+        payload.adminUsers = await getRepository(User).find();
+      } catch (e) {
+        logger.error('Failed to preload admin users for notification', {
+          label: 'Notifications',
+          type: Notification[type],
+          subject: payload.subject,
+          errorMessage: e instanceof Error ? e.message : String(e),
+        });
+        payload.adminUsers = [];
+      }
     }

     this.activeAgents.forEach((agent) => {
       if (agent.shouldSend()) {
-        agent.send(type, payload);
+        void agent.send(type, payload);
       }
     });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/lib/notifications/index.ts` around lines 101 - 123, sendNotification
currently awaits userRepository.find() and will reject the whole notification
flow on transient DB failures; wrap the admin preload in a try/catch so failures
are handled locally: when type is MEDIA_APPROVED or MEDIA_DECLINED or
payload.notifyAdmin, attempt to load admin users via userRepository.find()
inside a try block, on success assign payload.adminUsers, on error log the error
(use logger with label 'Notifications' and context) and set payload.adminUsers
to an empty array (or leave unchanged) so execution continues; then proceed to
iterate this.activeAgents.forEach(...) calling agent.shouldSend() and
agent.send(...) so other notification paths still run even if the admin lookup
failed. Include references to sendNotification, userRepository.find(),
Notification.MEDIA_APPROVED / MEDIA_DECLINED, this.activeAgents,
agent.shouldSend and agent.send in the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/lib/notifications/agents/webpush.ts`:
- Around line 171-173: The code eagerly executes
getRepository(MediaRequest).count() into pendingRequestsCount for every Web Push
notification; make this count lazy so it only runs for notification types that
need it (the MEDIA_PENDING flow and the approval/decline badge-update payload).
Refactor so that pendingRequestsCount is obtained on-demand (e.g., call
getRepository(MediaRequest).count() or a small helper only inside the
MEDIA_PENDING branch and the approval/decline badge-update branches), leaving
other notification paths untouched; update references at the existing
pendingRequestsCount usage sites (the MEDIA_PENDING handling and the
approval/decline badge payload construction) to call the lazy helper or local
count instead of the eagerly-evaluated variable.

---

Outside diff comments:
In `@server/lib/notifications/index.ts`:
- Around line 101-123: sendNotification currently awaits userRepository.find()
and will reject the whole notification flow on transient DB failures; wrap the
admin preload in a try/catch so failures are handled locally: when type is
MEDIA_APPROVED or MEDIA_DECLINED or payload.notifyAdmin, attempt to load admin
users via userRepository.find() inside a try block, on success assign
payload.adminUsers, on error log the error (use logger with label
'Notifications' and context) and set payload.adminUsers to an empty array (or
leave unchanged) so execution continues; then proceed to iterate
this.activeAgents.forEach(...) calling agent.shouldSend() and agent.send(...) so
other notification paths still run even if the admin lookup failed. Include
references to sendNotification, userRepository.find(),
Notification.MEDIA_APPROVED / MEDIA_DECLINED, this.activeAgents,
agent.shouldSend and agent.send in the change.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cb67f42c-e31e-43be-8ff1-bb1181cfbeab

📥 Commits

Reviewing files that changed from the base of the PR and between 891265f and 5aa1263.

📒 Files selected for processing (8)
  • server/lib/notifications/agents/agent.ts
  • server/lib/notifications/agents/discord.ts
  • server/lib/notifications/agents/email.ts
  • server/lib/notifications/agents/pushbullet.ts
  • server/lib/notifications/agents/pushover.ts
  • server/lib/notifications/agents/telegram.ts
  • server/lib/notifications/agents/webpush.ts
  • server/lib/notifications/index.ts

Comment thread server/lib/notifications/agents/webpush.ts Outdated
Wrap the admin user preload in try/catch so a transient DB failure
does not kill the entire notification flow. Keep sendNotification()
synchronous and delegate to a private async dispatchNotification()
to avoid unhandled promise rejections from fire-and-forget callers.

Only run the pending request count query for notification types that
actually use it (MEDIA_PENDING, MEDIA_APPROVED, MEDIA_DECLINED)
instead of on every webpush notification.
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