discourse/plugins/chat/app/models/chat_message.rb
Alan Guo Xiang Tan 80d289eb8b
PERF: Add index for chat unread counts query (#19516)
This commit adds an index for the query which the chat plugin executes
multiple times when preloading user data in `Chat::ChatChannelFetcher.unread_counts`.

Sample query plan from a query I grabbed from one of our production
instance.

Before:

```
                                                                                        QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate  (cost=10.77..696.67 rows=7 width=16) (actual time=7.735..7.736 rows=0 loops=1)
   Group Key: cc.id
   ->  Nested Loop  (cost=10.77..696.54 rows=12 width=8) (actual time=7.734..7.735 rows=0 loops=1)
         Join Filter: (cc.id = cm.chat_channel_id)
         ->  Nested Loop  (cost=0.56..76.44 rows=1 width=16) (actual time=0.011..0.037 rows=7 loops=1)
               ->  Index Only Scan using chat_channels_pkey on chat_channels cc  (cost=0.28..22.08 rows=7 width=8) (actual time=0.004..0.014 rows=7 loops=1)
                     Index Cond: (id = ANY ('{192,300,228,727,8,612,1633}'::bigint[]))
                     Heap Fetches: 0
               ->  Index Scan using user_chat_channel_unique_memberships on user_chat_channel_memberships uccm  (cost=0.28..7.73 rows=1 width=8) (actual time=0.003..0.003 rows=1 loops=7)
                     Index Cond: ((user_id = 1338) AND (chat_channel_id = cc.id))
         ->  Bitmap Heap Scan on chat_messages cm  (cost=10.21..618.98 rows=89 width=12) (actual time=1.096..1.097 rows=0 loops=7)
               Recheck Cond: (chat_channel_id = uccm.chat_channel_id)
               Filter: ((deleted_at IS NULL) AND (user_id <> 1338) AND (id > COALESCE(uccm.last_read_message_id, 0)))
               Rows Removed by Filter: 2085
               Heap Blocks: exact=7106
               ->  Bitmap Index Scan on index_chat_messages_on_chat_channel_id_and_created_at  (cost=0.00..10.19 rows=270 width=0) (actual time=0.114..0.114 rows=2085 loops=7)
                     Index Cond: (chat_channel_id = uccm.chat_channel_id)
 Planning Time: 0.408 ms
 Execution Time: 7.762 ms
(19 rows)
```

After:

```
                                                                                        QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate  (cost=5.84..367.39 rows=7 width=16) (actual time=0.130..0.131 rows=0 loops=1)
   Group Key: cc.id
   ->  Nested Loop  (cost=5.84..367.26 rows=12 width=8) (actual time=0.129..0.130 rows=0 loops=1)
         Join Filter: (cc.id = cm.chat_channel_id)
         ->  Nested Loop  (cost=0.56..76.44 rows=1 width=16) (actual time=0.038..0.069 rows=7 loops=1)
               ->  Index Only Scan using chat_channels_pkey on chat_channels cc  (cost=0.28..22.08 rows=7 width=8) (actual time=0.011..0.022 rows=7 loops=1)
                     Index Cond: (id = ANY ('{192,300,228,727,8,612,1633}'::bigint[]))
                     Heap Fetches: 0
               ->  Index Scan using user_chat_channel_unique_memberships on user_chat_channel_memberships uccm  (cost=0.28..7.73 rows=1 width=8) (actual time=0.006..0.006 rows=1 loops=7)
                     Index Cond: ((user_id = 1338) AND (chat_channel_id = cc.id))
         ->  Bitmap Heap Scan on chat_messages cm  (cost=5.28..289.71 rows=89 width=12) (actual time=0.008..0.008 rows=0 loops=7)
               Recheck Cond: ((chat_channel_id = uccm.chat_channel_id) AND (id > COALESCE(uccm.last_read_message_id, 0)) AND (deleted_at IS NULL))
               Filter: (user_id <> 1338)
               ->  Bitmap Index Scan on index_chat_messages_on_chat_channel_id_and_id  (cost=0.00..5.26 rows=90 width=0) (actual time=0.008..0.008 rows=0 loops=7)
                     Index Cond: ((chat_channel_id = uccm.chat_channel_id) AND (id > COALESCE(uccm.last_read_message_id, 0)))
 Planning Time: 1.217 ms
 Execution Time: 0.188 ms
(17 rows)
```
2022-12-20 05:10:53 +08:00

258 lines
6.8 KiB
Ruby

# frozen_string_literal: true
class ChatMessage < ActiveRecord::Base
include Trashable
attribute :has_oneboxes, default: false
BAKED_VERSION = 2
belongs_to :chat_channel
belongs_to :user
belongs_to :in_reply_to, class_name: "ChatMessage"
belongs_to :last_editor, class_name: "User"
has_many :replies, class_name: "ChatMessage", foreign_key: "in_reply_to_id", dependent: :nullify
has_many :revisions, class_name: "ChatMessageRevision", dependent: :destroy
has_many :reactions, class_name: "ChatMessageReaction", dependent: :destroy
has_many :bookmarks, as: :bookmarkable, dependent: :destroy
has_many :chat_uploads, dependent: :destroy
has_many :uploads, through: :chat_uploads
has_one :chat_webhook_event, dependent: :destroy
has_one :chat_mention, dependent: :destroy
scope :in_public_channel,
-> {
joins(:chat_channel).where(
chat_channel: {
chatable_type: ChatChannel.public_channel_chatable_types,
},
)
}
scope :in_dm_channel,
-> { joins(:chat_channel).where(chat_channel: { chatable_type: "DirectMessage" }) }
scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) }
before_save { ensure_last_editor_id }
def validate_message(has_uploads:)
WatchedWordsValidator.new(attributes: [:message]).validate(self)
if self.new_record? || self.changed.include?("message")
Chat::DuplicateMessageValidator.new(self).validate
end
if !has_uploads && message_too_short?
self.errors.add(
:base,
I18n.t(
"chat.errors.minimum_length_not_met",
minimum: SiteSetting.chat_minimum_message_length,
),
)
end
if message_too_long?
self.errors.add(
:base,
I18n.t("chat.errors.message_too_long", maximum: SiteSetting.chat_maximum_message_length),
)
end
end
def attach_uploads(uploads)
return if uploads.blank?
now = Time.now
record_attrs =
uploads.map do |upload|
{ upload_id: upload.id, chat_message_id: self.id, created_at: now, updated_at: now }
end
ChatUpload.insert_all!(record_attrs)
end
def excerpt
# just show the URL if the whole message is a URL, because we cannot excerpt oneboxes
return message if UrlHelper.relaxed_parse(message).is_a?(URI)
# upload-only messages are better represented as the filename
return uploads.first.original_filename if cooked.blank? && uploads.present?
# this may return blank for some complex things like quotes, that is acceptable
PrettyText.excerpt(cooked, 50, {})
end
def cooked_for_excerpt
(cooked.blank? && uploads.present?) ? "<p>#{uploads.first.original_filename}</p>" : cooked
end
def push_notification_excerpt
Emoji.gsub_emoji_to_unicode(message).truncate(400)
end
def to_markdown
markdown = []
if self.message.present?
msg = self.message
self.chat_uploads.any? ? markdown << msg + "\n" : markdown << msg
end
self
.chat_uploads
.order(:created_at)
.each { |chat_upload| markdown << UploadMarkdown.new(chat_upload.upload).to_markdown }
markdown.reject(&:empty?).join("\n")
end
def cook
ensure_last_editor_id
self.cooked = self.class.cook(self.message, user_id: self.last_editor_id)
self.cooked_version = BAKED_VERSION
end
def rebake!(invalidate_oneboxes: false, priority: nil)
ensure_last_editor_id
previous_cooked = self.cooked
new_cooked =
self.class.cook(
message,
invalidate_oneboxes: invalidate_oneboxes,
user_id: self.last_editor_id,
)
update_columns(cooked: new_cooked, cooked_version: BAKED_VERSION)
args = { chat_message_id: self.id }
args[:queue] = priority.to_s if priority && priority != :normal
args[:is_dirty] = true if previous_cooked != new_cooked
Jobs.enqueue(:process_chat_message, args)
end
def self.uncooked
where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION)
end
MARKDOWN_FEATURES = %w[
anchor
bbcode-block
bbcode-inline
code
category-hashtag
censored
chat-transcript
discourse-local-dates
emoji
emojiShortcuts
inlineEmoji
html-img
hashtag-autocomplete
mentions
unicodeUsernames
onebox
quotes
spoiler-alert
table
text-post-process
upload-protocol
watched-words
]
MARKDOWN_IT_RULES = %w[
autolink
list
backticks
newline
code
fence
image
table
linkify
link
strikethrough
blockquote
emphasis
]
def self.cook(message, opts = {})
# A rule in our Markdown pipeline may have Guardian checks that require a
# user to be present. The last editing user of the message will be more
# generally up to date than the creating user. For example, we use
# this when cooking #hashtags to determine whether we should render
# the found hashtag based on whether the user can access the channel it
# is referencing.
cooked =
PrettyText.cook(
message,
features_override: MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a,
markdown_it_rules: MARKDOWN_IT_RULES,
force_quote_link: true,
user_id: opts[:user_id],
hashtag_context: "chat-composer",
)
result =
Oneboxer.apply(cooked) do |url|
if opts[:invalidate_oneboxes]
Oneboxer.invalidate(url)
InlineOneboxer.invalidate(url)
end
onebox = Oneboxer.cached_onebox(url)
onebox
end
cooked = result.to_html if result.changed?
cooked
end
def full_url
"#{Discourse.base_url}#{url}"
end
def url
"/chat/message/#{self.id}"
end
private
def message_too_short?
message.length < SiteSetting.chat_minimum_message_length
end
def message_too_long?
message.length > SiteSetting.chat_maximum_message_length
end
def ensure_last_editor_id
self.last_editor_id ||= self.user_id
end
end
# == Schema Information
#
# Table name: chat_messages
#
# id :bigint not null, primary key
# chat_channel_id :integer not null
# user_id :integer
# created_at :datetime not null
# updated_at :datetime not null
# deleted_at :datetime
# deleted_by_id :integer
# in_reply_to_id :integer
# message :text
# cooked :text
# cooked_version :integer
# last_editor_id :integer not null
#
# Indexes
#
# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL)
# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at)
# index_chat_messages_on_chat_channel_id_and_id (chat_channel_id,id) WHERE (deleted_at IS NULL)
# index_chat_messages_on_last_editor_id (last_editor_id)
#