perf: reduce per-request work in conversation filter endpoint (#15321)

## Description

Every conversation filter request runs three separate unbounded COUNT
queries over the filtered set (mine, unassigned, all) and eager-loads
every message of every conversation on the page. On large accounts this
adds a fixed 1-2s of latency per request regardless of the filter.

This PR trims both:

- The three counts are now computed in a single pass using `COUNT(*)
FILTER (...)` aggregates. Response shape and count semantics are
unchanged.
- The `:messages` eager-load is removed from the filter base relation.
The list payload fetches messages through scoped queries (last message,
last non-activity message, unread messages), which never read the
preloaded collection, so it was loaded and discarded on every request.

Fixes https://linear.app/chatwoot/issue/CW-7830
This commit is contained in:
Vishnu Narayanan
2026-08-05 14:43:51 +05:30
committed by GitHub
parent d83135721b
commit bab2f99004
3 changed files with 72 additions and 6 deletions

View File

@@ -24,8 +24,10 @@ class Conversations::FilterService < FilterService
end
def base_relation
# :messages is deliberately not preloaded: the list payload fetches messages through
# scoped queries (last message, last_non_activity_message), which bypass the preload.
conversations = @account.conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :messages, :contact_inbox
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
Conversations::PermissionFilterService.new(

View File

@@ -107,12 +107,16 @@ class FilterService
lt_gt_filter_query(updated_query_hash, current_index)
end
# Computes mine/unassigned/all counts in one scan of the filtered set instead of
# three separate COUNT queries.
def set_count_for_all_conversations
[
@conversations.assigned_to(@user).count,
@conversations.unassigned.count,
@conversations.count
]
counts = @conversations.except(:includes, :order).pick(
Arel.sql(ActiveRecord::Base.sanitize_sql_array(['COUNT(*) FILTER (WHERE assignee_id = ?)', @user.id])),
Arel.sql('COUNT(*) FILTER (WHERE assignee_id IS NULL)'),
Arel.sql('COUNT(*)')
)
# pick short-circuits to nil on a none relation (e.g. permission scope with no access)
counts ? counts.map(&:to_i) : [0, 0, 0]
end
def tag_filter_query(query_hash, current_index)