## Description Reverts [#14726](https://github.com/chatwoot/chatwoot/pull/14726) (\"feat: Add sidebar unread counts for filters (CW-7262)\"), which shipped in 4.15.0. After 4.15.0 rolled out to prod the unread-counts-for-filters code path caused a cascading incident: - `Counter#ensure_filters_cache!` fires on every `/unread_counts/index` and `update_last_seen` request. - On cache miss it calls `Builder#build_filters_for!`, which: - invokes `store.clear_user_filters!` -> `delete_matching` -> a Redis `SCAN_each` over a per-user pattern keyspace, and - runs 4 fresh SQL passes per user (mentions, participating, unattended, and per-folder `Conversations::FilterService` queries). - Threads blocked in the SCAN held their DB connections, the connection pool exhausted, Sidekiq jobs were discarded with `ActiveJob::DeserializationError: could not obtain a connection from the pool`, and the enqueued queue blew past 200K. Related: [CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
200 lines
7.2 KiB
Ruby
200 lines
7.2 KiB
Ruby
class Conversations::UnreadCounts::Store
|
|
extend ::Conversations::UnreadCounts::StoreKeys
|
|
|
|
class << self
|
|
def base_ready?(account_id)
|
|
Redis::Alfred.exists?(base_ready_key(account_id))
|
|
end
|
|
|
|
def assignment_ready?(account_id)
|
|
Redis::Alfred.exists?(assignment_ready_key(account_id))
|
|
end
|
|
|
|
def mark_base_ready!(account_id)
|
|
Redis::Alfred.set(base_ready_key(account_id), Time.current.to_i, ex: Conversations::UnreadCounts::READY_TTL)
|
|
end
|
|
|
|
def mark_assignment_ready!(account_id)
|
|
Redis::Alfred.set(assignment_ready_key(account_id), Time.current.to_i, ex: Conversations::UnreadCounts::READY_TTL)
|
|
end
|
|
|
|
def clear_account!(account_id)
|
|
delete_matching("#{account_prefix(account_id)}::*")
|
|
end
|
|
|
|
def clear_assignment!(account_id)
|
|
assignment_key_patterns(account_id).each { |pattern| delete_matching(pattern) }
|
|
end
|
|
|
|
def add_base_membership(account_id:, inbox_id:, label_ids:, conversation_id:, team_id: nil)
|
|
add_to_sets(base_keys(account_id, inbox_id, label_ids, team_id), conversation_id)
|
|
end
|
|
|
|
def remove_base_membership(account_id:, inbox_ids:, label_ids:, conversation_id:, team_ids: [])
|
|
keys = Array(inbox_ids).flat_map { |inbox_id| removable_base_keys(account_id, inbox_id, label_ids, team_ids) }
|
|
remove_from_sets(keys, conversation_id)
|
|
end
|
|
|
|
def add_assignment_membership(account_id:, conversation_id:, **membership)
|
|
add_to_sets(
|
|
assignment_keys(account_id, membership[:inbox_id], membership[:label_ids], membership[:assignee_id], membership[:team_id]),
|
|
conversation_id
|
|
)
|
|
end
|
|
|
|
def remove_assignment_membership(account_id:, conversation_id:, **membership)
|
|
keys = Array(membership[:inbox_ids]).flat_map do |inbox_id|
|
|
Array(membership[:assignee_ids]).flat_map do |assignee_id|
|
|
removable_assignment_keys(account_id, inbox_id, membership[:label_ids], assignee_id, membership[:team_ids])
|
|
end
|
|
end
|
|
remove_from_sets(keys, conversation_id)
|
|
end
|
|
|
|
def add_memberships(account_id:, memberships:, assignment: false)
|
|
return if memberships.blank?
|
|
|
|
Redis::Alfred.pipelined do |pipeline|
|
|
memberships.each do |membership|
|
|
keys = if assignment
|
|
assignment_keys(account_id, membership[:inbox_id], membership[:label_ids], membership[:assignee_id], membership[:team_id])
|
|
else
|
|
base_keys(account_id, membership[:inbox_id], membership[:label_ids], membership[:team_id])
|
|
end
|
|
|
|
keys.each do |key|
|
|
pipeline.sadd(key, membership[:conversation_id])
|
|
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def counts_for_keys(keys)
|
|
keys = keys.compact_blank
|
|
return {} if keys.blank?
|
|
|
|
counts = Redis::Alfred.pipelined do |pipeline|
|
|
keys.each { |key| pipeline.scard(key) }
|
|
end
|
|
keys.zip(counts).to_h
|
|
end
|
|
|
|
def memberships_for_keys(keys, conversation_id)
|
|
keys = keys.compact_blank
|
|
return {} if keys.blank?
|
|
|
|
memberships = Redis::Alfred.pipelined do |pipeline|
|
|
keys.each { |key| pipeline.sismember(key, conversation_id) }
|
|
end
|
|
keys.zip(memberships.map { |membership| membership == true || membership == 1 }).to_h
|
|
end
|
|
|
|
private
|
|
|
|
def base_ready_key(account_id)
|
|
format(Redis::Alfred::UNREAD_CONVERSATIONS_BASE_READY, account_id: account_id)
|
|
end
|
|
|
|
def assignment_ready_key(account_id)
|
|
format(Redis::Alfred::UNREAD_CONVERSATIONS_ASSIGNMENT_READY, account_id: account_id)
|
|
end
|
|
|
|
def account_prefix(account_id)
|
|
format(Redis::Alfred::UNREAD_CONVERSATIONS_ACCOUNT_PREFIX, account_id: account_id)
|
|
end
|
|
|
|
def base_keys(account_id, inbox_id, label_ids, team_id = nil)
|
|
keys = [inbox_key(account_id, inbox_id)] + Array(label_ids).map { |label_id| label_inbox_key(account_id, label_id, inbox_id) }
|
|
keys << team_inbox_key(account_id, team_id, inbox_id) if team_id.present?
|
|
keys
|
|
end
|
|
|
|
def removable_base_keys(account_id, inbox_id, label_ids, team_ids)
|
|
keys = base_keys(account_id, inbox_id, label_ids)
|
|
keys.concat(Array(team_ids).compact_blank.map { |team_id| team_inbox_key(account_id, team_id, inbox_id) })
|
|
end
|
|
|
|
def assignment_keys(account_id, inbox_id, label_ids, assignee_id, team_id = nil)
|
|
keys = assignment_keys_without_team(account_id, inbox_id, label_ids, assignee_id)
|
|
keys << team_assignment_key(account_id, team_id, inbox_id, assignee_id) if team_id.present?
|
|
keys
|
|
end
|
|
|
|
def removable_assignment_keys(account_id, inbox_id, label_ids, assignee_id, team_ids)
|
|
keys = assignment_keys_without_team(account_id, inbox_id, label_ids, assignee_id)
|
|
keys.concat(Array(team_ids).compact_blank.map { |team_id| team_assignment_key(account_id, team_id, inbox_id, assignee_id) })
|
|
end
|
|
|
|
def assignment_keys_without_team(account_id, inbox_id, label_ids, assignee_id)
|
|
return assignee_keys(account_id, inbox_id, label_ids, assignee_id) if assignee_id.present?
|
|
|
|
unassigned_keys(account_id, inbox_id, label_ids)
|
|
end
|
|
|
|
def assignee_keys(account_id, inbox_id, label_ids, assignee_id)
|
|
[inbox_assignee_key(account_id, inbox_id, assignee_id)] +
|
|
Array(label_ids).map { |label_id| label_inbox_assignee_key(account_id, label_id, inbox_id, assignee_id) }
|
|
end
|
|
|
|
def unassigned_keys(account_id, inbox_id, label_ids)
|
|
[inbox_unassigned_key(account_id, inbox_id)] +
|
|
Array(label_ids).map { |label_id| label_inbox_unassigned_key(account_id, label_id, inbox_id) }
|
|
end
|
|
|
|
def team_assignment_key(account_id, team_id, inbox_id, assignee_id)
|
|
return team_inbox_assignee_key(account_id, team_id, inbox_id, assignee_id) if assignee_id.present?
|
|
|
|
team_inbox_unassigned_key(account_id, team_id, inbox_id)
|
|
end
|
|
|
|
def add_to_sets(keys, conversation_id)
|
|
write_to_sets(keys) { |pipeline, key| pipeline.sadd(key, conversation_id) }
|
|
end
|
|
|
|
def remove_from_sets(keys, conversation_id)
|
|
keys = keys.compact_blank
|
|
return false if keys.blank?
|
|
|
|
results = Redis::Alfred.pipelined do |pipeline|
|
|
keys.each do |key|
|
|
pipeline.srem(key, conversation_id)
|
|
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
|
|
end
|
|
end
|
|
results.each_slice(2).any? { |removed, _| removed == true || (removed.respond_to?(:to_i) && removed.to_i.positive?) }
|
|
end
|
|
|
|
def write_to_sets(keys)
|
|
keys = keys.compact_blank
|
|
return if keys.blank?
|
|
|
|
Redis::Alfred.pipelined do |pipeline|
|
|
keys.each do |key|
|
|
yield(pipeline, key)
|
|
pipeline.expire(key, Conversations::UnreadCounts::SET_TTL)
|
|
end
|
|
end
|
|
end
|
|
|
|
def delete_matching(pattern)
|
|
Redis::Alfred.scan_each(match: pattern, count: 1000) do |key|
|
|
Redis::Alfred.delete(key)
|
|
end
|
|
end
|
|
|
|
def assignment_key_patterns(account_id)
|
|
prefix = account_prefix(account_id)
|
|
[
|
|
assignment_ready_key(account_id),
|
|
"#{prefix}::INBOX::*::UNASSIGNED",
|
|
"#{prefix}::INBOX::*::ASSIGNEE::*",
|
|
"#{prefix}::LABEL::*::INBOX::*::UNASSIGNED",
|
|
"#{prefix}::LABEL::*::INBOX::*::ASSIGNEE::*",
|
|
"#{prefix}::TEAM::*::INBOX::*::UNASSIGNED",
|
|
"#{prefix}::TEAM::*::INBOX::*::ASSIGNEE::*"
|
|
]
|
|
end
|
|
end
|
|
end
|