Database CPU utilization was spiking due to expensive notification COUNT
queries. Analysis revealed two critical issues:
1. Missing database index: Notification count queries were performing
table scans without proper indexing
2. Duplicate WHERE clauses: SQL queries contained redundant read_at IS
NULL conditions, causing unnecessary query complexity
### Root Cause Analysis
The expensive queries were:
```
-- 41.61 calls/sec with duplicate condition
SELECT COUNT(*) FROM "notifications"
WHERE "notifications"."user_id" = $1
AND "notifications"."account_id" = $2
AND "notifications"."snoozed_until" IS NULL
AND "notifications"."read_at" IS NULL
AND "notifications"."read_at" IS NULL -- Duplicate!
```
This was caused by a logic error in NotificationFinder#unread_count
introduced in commit cd06b2b33 (PR #8907). The method assumed
@notifications contained all notifications, but @notifications was
already filtered to unread notifications in most cases.
### The Default Query Flow:
1. Frontend calls: NotificationsAPI.getUnreadCount() →
/notifications/unread_count
2. No parameters sent, so params = {}
3. NotificationFinder setup:
- find_all_notifications: WHERE user_id = ? AND account_id = ?
- filter_snoozed_notifications: WHERE snoozed_until IS NULL
- filter_read_notifications: WHERE read_at IS NULL (because
type_included?('read') is false)
4. unread_count called: Adds another WHERE read_at IS NULL
----
### Solution
1. Added Missing Database Index
- Index: (user_id, account_id, snoozed_until, read_at)
2. Fixed Duplicate WHERE Clause Logic
63 lines
1.4 KiB
Ruby
63 lines
1.4 KiB
Ruby
class NotificationFinder
|
|
attr_reader :current_user, :current_account, :params
|
|
|
|
RESULTS_PER_PAGE = 15
|
|
|
|
def initialize(current_user, current_account, params = {})
|
|
@current_user = current_user
|
|
@current_account = current_account
|
|
@params = params
|
|
set_up
|
|
end
|
|
|
|
def notifications
|
|
@notifications.page(current_page).per(RESULTS_PER_PAGE).order(last_activity_at: sort_order)
|
|
end
|
|
|
|
def unread_count
|
|
if type_included?('read')
|
|
# If we're including read notifications, filter to unread
|
|
@notifications.where(read_at: nil).count
|
|
else
|
|
# Already filtered to unread notifications, just count
|
|
@notifications.count
|
|
end
|
|
end
|
|
|
|
def count
|
|
@notifications.count
|
|
end
|
|
|
|
private
|
|
|
|
def set_up
|
|
find_all_notifications
|
|
filter_snoozed_notifications
|
|
filter_read_notifications
|
|
end
|
|
|
|
def find_all_notifications
|
|
@notifications = current_user.notifications.where(account_id: @current_account.id)
|
|
end
|
|
|
|
def filter_snoozed_notifications
|
|
@notifications = @notifications.where(snoozed_until: nil) unless type_included?('snoozed')
|
|
end
|
|
|
|
def filter_read_notifications
|
|
@notifications = @notifications.where(read_at: nil) unless type_included?('read')
|
|
end
|
|
|
|
def type_included?(type)
|
|
(params[:includes] || []).include?(type)
|
|
end
|
|
|
|
def current_page
|
|
params[:page] || 1
|
|
end
|
|
|
|
def sort_order
|
|
params[:sort_order] || :desc
|
|
end
|
|
end
|