diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 9a4ae99c6002e50e243af2806e6baad9ab156c52..45944401c2d30b4eaf14db8f1517fbcb8486d958 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -70,10 +70,11 @@ module CacheMarkdownField def refresh_markdown_cache! updates = refresh_markdown_cache - if save_markdown(updates) - # save_markdown updates DB columns directly, so reload to let store_mentions! be able to parse mentions - # otherwise we end up in a loop - reset.store_mentions! if is_a?(Mentionable) && mentionable_attrs_changed?(updates.keys) + if updates.present? && save_markdown(updates) + # save_markdown updates DB columns directly, so compute and save mentions + # by calling store_mentions! or we end-up with missing mentions although those + # would appear in the notes, descriptions, etc in the UI + store_mentions! if mentionable_attributes_changed?(updates) end end @@ -110,14 +111,15 @@ module CacheMarkdownField return unless cached_markdown_fields.markdown_fields.include?(markdown_field) if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field)) - # * If html is invalid and there is a change in markdown field, then just refresh html for the - # corresponding markdown field. - # * Else if the the markdown did not changee it means it is freshly loaded from DB - # but its corresponding html field is invalid, so we refresh and update it in DB. - # * The change in html field will trigger also a mentions parsing as well as an update if needed. - if changed_attributes[markdown_field] + # Invalidated due to Markdown content change + # We should not persist the updated HTML here since this will depend on whether the + # Markdown content change will be persisted. Both will be persisted together when the model is saved. + if changed_attributes.key?(markdown_field) refresh_markdown_cache else + # Invalidated due to stale HTML cache + # This could happen when the Markdown cache version is bumped or when a model is imported and the HTML is empty. + # We persist the updated HTML here so that subsequent calls to this method do not have to regenerate the HTML again. refresh_markdown_cache! end end @@ -154,13 +156,43 @@ module CacheMarkdownField nil end - private + def store_mentions! + refs = all_references(self.author) - def mentionable_attrs_changed?(updated_fields) + references = {} + references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence + references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence + references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence + + # One retry is enough as next time `model_user_mention` should return the existing mention record, + # that threw the `ActiveRecord::RecordNotUnique` exception in first place. + self.class.safe_ensure_unique(retries: 1) do + user_mention = model_user_mention + + # this may happen due to notes polymorphism, so noteable_id may point to a record + # that no longer exists as we cannot have FK on noteable_id + break if user_mention.blank? + + user_mention.mentioned_users_ids = references[:mentioned_users_ids] + user_mention.mentioned_groups_ids = references[:mentioned_groups_ids] + user_mention.mentioned_projects_ids = references[:mentioned_projects_ids] + + if user_mention.has_mentions? + user_mention.save! + else + user_mention.destroy! + end + end + + true + end + + def mentionable_attributes_changed?(changes = saved_changes) return false unless is_a?(Mentionable) self.class.mentionable_attrs.any? do |attr| - updated_fields.include?(cached_markdown_fields.html_field(attr.first)) + changes.key?(cached_markdown_fields.html_field(attr.first)) && + changes.fetch(cached_markdown_fields.html_field(attr.first)).last.present? end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 7624a1a4e80cf20ab49bb52944d0c30c2a8b0807..137f6e51dad9f3a0c80ab34e88eb80698218309d 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -84,7 +84,6 @@ module Issuable validate :description_max_length_for_new_records_is_valid, on: :update before_validation :truncate_description_on_import! - after_save :store_mentions!, if: :any_mentionable_attributes_changed? scope :authored, ->(user) { where(author_id: user) } scope :recent, -> { reorder(id: :desc) } diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index b10e8547e8609276bba47f69eed040db160d3b0c..5db077c178de7f5d8da38ba8ed09b4da27a0b7d1 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -80,37 +80,6 @@ module Mentionable all_references(current_user).users end - def store_mentions! - refs = all_references(self.author) - - references = {} - references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence - references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence - references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence - - # One retry should be enough as next time `model_user_mention` should return the existing mention record, that - # threw the `ActiveRecord::RecordNotUnique` exception in first place. - self.class.safe_ensure_unique(retries: 1) do - user_mention = model_user_mention - - # this may happen due to notes polymorphism, so noteable_id may point to a record that no longer exists - # as we cannot have FK on noteable_id - break if user_mention.blank? - - user_mention.mentioned_users_ids = references[:mentioned_users_ids] - user_mention.mentioned_groups_ids = references[:mentioned_groups_ids] - user_mention.mentioned_projects_ids = references[:mentioned_projects_ids] - - if user_mention.has_mentions? - user_mention.save! - else - user_mention.destroy! - end - end - - true - end - def referenced_users User.where(id: user_mentions.select("unnest(mentioned_users_ids)")) end @@ -216,12 +185,6 @@ module Mentionable source.select { |key, val| mentionable.include?(key) } end - def any_mentionable_attributes_changed? - self.class.mentionable_attrs.any? do |attr| - saved_changes.key?(attr.first) - end - end - # Determine whether or not a cross-reference Note has already been created between this Mentionable and # the specified target. def cross_reference_exists?(target) @@ -237,12 +200,12 @@ module Mentionable end # User mention that is parsed from model description rather then its related notes. - # Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. + # Models that have a description attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. # Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have # a description attribute. # # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception - # in a multithreaded environment. Make sure to use it within a *safe_ensure_unique* block. + # in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block. def model_user_mention user_mentions.where(note_id: nil).first_or_initialize end diff --git a/app/models/note.rb b/app/models/note.rb index cfdac6c432fd0ca19359b31ae0923cd562250b09..77f7726079c2a73c25ad1255f445f8281ecdee96 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -145,7 +145,6 @@ class Note < ApplicationRecord after_save :expire_etag_cache, unless: :importing? after_save :touch_noteable, unless: :importing? after_destroy :expire_etag_cache - after_save :store_mentions!, if: :any_mentionable_attributes_changed? after_commit :notify_after_create, on: :create after_commit :notify_after_destroy, on: :destroy @@ -548,8 +547,8 @@ class Note < ApplicationRecord private - # Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception - # in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block. + # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception + # in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block. def model_user_mention return if user_mentions.is_a?(ActiveRecord::NullRelation) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 2e1a2e8e2b2c4f2a616271ab98b3d4db6df35101..1a985ad10678e800bc0e0a1ace89b24e49f2d5bd 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -69,7 +69,6 @@ class Snippet < ApplicationRecord validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values } - after_save :store_mentions!, if: :any_mentionable_attributes_changed? after_create :create_statistics # Scopes diff --git a/changelogs/unreleased/save-mentions-on-markdown-columns-change.yml b/changelogs/unreleased/save-mentions-on-markdown-columns-change.yml new file mode 100644 index 0000000000000000000000000000000000000000..7358528219e51d7efd15d902a45bfb921efc0f38 --- /dev/null +++ b/changelogs/unreleased/save-mentions-on-markdown-columns-change.yml @@ -0,0 +1,5 @@ +--- +title: Update user mentions when markdown columns are directly saved to DB +merge_request: 38034 +author: +type: fixed diff --git a/lib/gitlab/markdown_cache/active_record/extension.rb b/lib/gitlab/markdown_cache/active_record/extension.rb index 233d3bf1ac79f6e16d1f9251141e025d669204f9..1de890c84f9558a2491f4f76fc53229d11b71d74 100644 --- a/lib/gitlab/markdown_cache/active_record/extension.rb +++ b/lib/gitlab/markdown_cache/active_record/extension.rb @@ -10,6 +10,7 @@ module Gitlab # Using before_update here conflicts with elasticsearch-model somehow before_create :refresh_markdown_cache, if: :invalidated_markdown_cache? before_update :refresh_markdown_cache, if: :invalidated_markdown_cache? + after_save :store_mentions!, if: :mentionable_attributes_changed? end # Always exclude _html fields from attributes (including serialization). diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb index b203c6e606a138b7a9404650fe1e60aef6eaa1b3..6e62d4ef31b9be462359e2408e4428754ddc06b3 100644 --- a/spec/models/concerns/cache_markdown_field_spec.rb +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -145,7 +145,7 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do it 'saves the changes' do expect(thing) .to receive(:save_markdown) - .with("description_html" => updated_html, "title_html" => "", "cached_markdown_version" => cache_version) + .with("description_html" => updated_html, "title_html" => "", "cached_markdown_version" => cache_version) thing.refresh_markdown_cache! end @@ -233,7 +233,7 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do end it 'calls #refresh_markdown_cache!' do - expect(thing).to receive(:refresh_markdown_cache!) + expect(thing).to receive(:refresh_markdown_cache) expect(thing.updated_cached_html_for(:description)).to eq(html) end @@ -280,57 +280,89 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do end shared_examples 'a class with mentionable markdown fields' do - context 'when klass is a Mentionable' do + let(:mentionable) { klass.new(description: markdown, description_html: html, title: markdown, title_html: html, cached_markdown_version: cache_version) } + + context 'when klass is a Mentionable', :aggregate_failures do before do klass.send(:include, Mentionable) klass.send(:attr_mentionable, :description) end - describe '#updated_cached_html_for' do - let(:thing) { klass.new(description: markdown, description_html: html, title: markdown, title_html: html, cached_markdown_version: cache_version) } + describe '#mentionable_attributes_changed?' do + message = Struct.new(:text) + + let(:changes) do + msg = message.new('test') + + changes = {} + changes[msg] = ['', 'some message'] + changes[:random_sym_key] = ['', 'some message'] + changes["description"] = ['', 'some message'] + changes + end + + it 'returns true with key string' do + changes["description_html"] = ['', 'some message'] + + allow(mentionable).to receive(:saved_changes).and_return(changes) + + expect(mentionable.send(:mentionable_attributes_changed?)).to be true + end + + it 'returns false with key symbol' do + changes[:description_html] = ['', 'some message'] + allow(mentionable).to receive(:saved_changes).and_return(changes) + + expect(mentionable.send(:mentionable_attributes_changed?)).to be false + end + + it 'returns false when no attr_mentionable keys' do + allow(mentionable).to receive(:saved_changes).and_return(changes) + + expect(mentionable.send(:mentionable_attributes_changed?)).to be false + end + end + describe '#save' do context 'when cache is outdated' do before do thing.cached_markdown_version += 1 - allow(thing).to receive(:reset).and_return(thing) - allow(thing).to receive(:save_markdown).and_return(true) end context 'when the markdown field also a mentionable attribute' do + let(:thing) { klass.new(description: markdown, description_html: html, cached_markdown_version: cache_version) } + it 'calls #store_mentions!' do + expect(thing).to receive(:mentionable_attributes_changed?).and_return(true) expect(thing).to receive(:store_mentions!) - expect(thing.updated_cached_html_for(:description)).to eq(html) + thing.try(:save) + + expect(thing.description_html).to eq(html) end end context 'when the markdown field is not mentionable attribute' do + let(:thing) { klass.new(title: markdown, title_html: html, cached_markdown_version: cache_version) } + it 'does not call #store_mentions!' do expect(thing).not_to receive(:store_mentions!) - expect(thing).to receive(:refresh_markdown_cache!) + expect(thing).to receive(:refresh_markdown_cache) + + thing.try(:save) - thing.updated_cached_html_for(:title) + expect(thing.title_html).to eq(html) end end end context 'when the markdown field does not exist' do - it 'does not call #store_mentions!' do - expect(thing).not_to receive(:store_mentions!) - - thing.updated_cached_html_for(:something) - end - end - - context 'when the markdown cache is up to date' do - before do - thing.try(:save) - end + let(:thing) { klass.new(cached_markdown_version: cache_version) } it 'does not call #store_mentions!' do expect(thing).not_to receive(:store_mentions!) - thing.updated_cached_html_for(:description) + thing.try(:save) end end end @@ -396,6 +428,5 @@ RSpec.describe CacheMarkdownField, :clean_gitlab_redis_cache do let(:klass) { other_class } it_behaves_like 'a class with cached markdown fields' - it_behaves_like 'a class with mentionable markdown fields' end end diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb index 516c0fd75bcbf8a4cb2291aa2901218cb03188d7..3c095477ea9e40acf0405aee90960f9c1d856d95 100644 --- a/spec/models/concerns/mentionable_spec.rb +++ b/spec/models/concerns/mentionable_spec.rb @@ -29,42 +29,6 @@ RSpec.describe Mentionable do expect(mentionable.referenced_mentionables).to be_empty end end - - describe '#any_mentionable_attributes_changed?' do - message = Struct.new(:text) - - let(:mentionable) { Example.new } - let(:changes) do - msg = message.new('test') - - changes = {} - changes[msg] = ['', 'some message'] - changes[:random_sym_key] = ['', 'some message'] - changes["random_string_key"] = ['', 'some message'] - changes - end - - it 'returns true with key string' do - changes["message"] = ['', 'some message'] - - allow(mentionable).to receive(:saved_changes).and_return(changes) - - expect(mentionable.send(:any_mentionable_attributes_changed?)).to be true - end - - it 'returns false with key symbol' do - changes[:message] = ['', 'some message'] - allow(mentionable).to receive(:saved_changes).and_return(changes) - - expect(mentionable.send(:any_mentionable_attributes_changed?)).to be false - end - - it 'returns false when no attr_mentionable keys' do - allow(mentionable).to receive(:saved_changes).and_return(changes) - - expect(mentionable.send(:any_mentionable_attributes_changed?)).to be false - end - end end RSpec.describe Issue, "Mentionable" do diff --git a/spec/support/shared_examples/models/mentionable_shared_examples.rb b/spec/support/shared_examples/models/mentionable_shared_examples.rb index 0ee0b7e6d889ea89c9615a7bf2fd0e4dd948dfc0..2392658e5844d734196a0b8860ac2a7a6327a13a 100644 --- a/spec/support/shared_examples/models/mentionable_shared_examples.rb +++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb @@ -92,7 +92,7 @@ RSpec.shared_examples 'a mentionable' do end end - expect(subject).to receive(:cached_markdown_fields).at_least(:once).and_call_original + expect(subject).to receive(:cached_markdown_fields).at_least(1).and_call_original subject.all_references(author) end @@ -151,7 +151,7 @@ RSpec.shared_examples 'an editable mentionable' do end it 'persists the refreshed cache so that it does not have to be refreshed every time' do - expect(subject).to receive(:refresh_markdown_cache).once.and_call_original + expect(subject).to receive(:refresh_markdown_cache).at_least(1).and_call_original subject.all_references(author)