Commit 92fac459 authored by Douwe Maan's avatar Douwe Maan

Merge branch 'banzai-avoid-redis-if-db-cache' into 'master'

Banzai - avoid redis if attr is in DB cache

See merge request gitlab-org/gitlab-ce!30334
parents e6f8e120 e5705f5c
...@@ -87,6 +87,16 @@ module CacheMarkdownField ...@@ -87,6 +87,16 @@ module CacheMarkdownField
__send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend __send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend
end end
# Updates the markdown cache if necessary, then returns the field
# Unlike `cached_html_for` it returns `nil` if the field does not exist
def updated_cached_html_for(markdown_field)
return unless cached_markdown_fields.markdown_fields.include?(markdown_field)
refresh_markdown_cache if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field))
cached_html_for(markdown_field)
end
def latest_cached_markdown_version def latest_cached_markdown_version
@latest_cached_markdown_version ||= (Gitlab::MarkdownCache::CACHE_COMMONMARK_VERSION << 16) | local_version @latest_cached_markdown_version ||= (Gitlab::MarkdownCache::CACHE_COMMONMARK_VERSION << 16) | local_version
end end
...@@ -139,8 +149,9 @@ module CacheMarkdownField ...@@ -139,8 +149,9 @@ module CacheMarkdownField
# The HTML becomes invalid if any dependent fields change. For now, assume # The HTML becomes invalid if any dependent fields change. For now, assume
# author and project invalidate the cache in all circumstances. # author and project invalidate the cache in all circumstances.
define_method(invalidation_method) do define_method(invalidation_method) do
invalidations = changed_markdown_fields & [markdown_field.to_s, *INVALIDATED_BY] changed_fields = changed_attributes.keys
invalidations.delete(markdown_field.to_s) if changed_markdown_fields.include?("#{markdown_field}_html") invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html")
!invalidations.empty? || !cached_html_up_to_date?(markdown_field) !invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end end
end end
......
...@@ -63,6 +63,9 @@ module Mentionable ...@@ -63,6 +63,9 @@ module Mentionable
skip_project_check: skip_project_check? skip_project_check: skip_project_check?
).merge(mentionable_params) ).merge(mentionable_params)
cached_html = self.try(:updated_cached_html_for, attr.to_sym)
options[:rendered] = cached_html if cached_html
extractor.analyze(text, options) extractor.analyze(text, options)
end end
......
...@@ -55,11 +55,16 @@ module Banzai ...@@ -55,11 +55,16 @@ module Banzai
# Perform multiple render from an Array of Markdown String into an # Perform multiple render from an Array of Markdown String into an
# Array of HTML-safe String of HTML. # Array of HTML-safe String of HTML.
# #
# As the rendered Markdown String can be already cached read all the data # The redis cache is completely obviated if we receive a `:rendered` key in the
# from the cache using Rails.cache.read_multi operation. If the Markdown String # context, as it is assumed the item has been pre-rendered somewhere else and there
# is not in the cache or it's not cacheable (no cache_key entry is provided in # is no need to cache it.
# the context) the Markdown String is rendered and stored in the cache so the #
# next render call gets the rendered HTML-safe String from the cache. # If no `:rendered` key is present in the context, as the rendered Markdown String
# can be already cached, read all the data from the cache using
# Rails.cache.read_multi operation. If the Markdown String is not in the cache
# or it's not cacheable (no cache_key entry is provided in the context) the
# Markdown String is rendered and stored in the cache so the next render call
# gets the rendered HTML-safe String from the cache.
# #
# For further explanation see #render method comments. # For further explanation see #render method comments.
# #
...@@ -76,19 +81,34 @@ module Banzai ...@@ -76,19 +81,34 @@ module Banzai
# => [{ text: '### Hello', # => [{ text: '### Hello',
# context: { cache_key: [note, :note] } }] # context: { cache_key: [note, :note] } }]
def self.cache_collection_render(texts_and_contexts) def self.cache_collection_render(texts_and_contexts)
items_collection = texts_and_contexts.each_with_index do |item, index| items_collection = texts_and_contexts.each do |item|
context = item[:context] context = item[:context]
cache_key = full_cache_multi_key(context.delete(:cache_key), context[:pipeline])
item[:cache_key] = cache_key if cache_key if context.key?(:rendered)
item[:rendered] = context.delete(:rendered)
else
# If the attribute didn't come in pre-rendered, let's prepare it for caching it in redis
cache_key = full_cache_multi_key(context.delete(:cache_key), context[:pipeline])
item[:cache_key] = cache_key if cache_key
end
end end
cacheable_items, non_cacheable_items = items_collection.partition { |item| item.key?(:cache_key) } cacheable_items, non_cacheable_items = items_collection.group_by do |item|
if item.key?(:rendered)
# We're not really doing anything here as these don't need any processing, but leaving it just in case
# as they could have a cache_key and we don't want them to be re-rendered
:rendered
elsif item.key?(:cache_key)
:cacheable
else
:non_cacheable
end
end.values_at(:cacheable, :non_cacheable)
items_in_cache = [] items_in_cache = []
items_not_in_cache = [] items_not_in_cache = []
unless cacheable_items.empty? if cacheable_items.present?
items_in_cache = Rails.cache.read_multi(*cacheable_items.map { |item| item[:cache_key] }) items_in_cache = Rails.cache.read_multi(*cacheable_items.map { |item| item[:cache_key] })
items_not_in_cache = cacheable_items.reject do |item| items_not_in_cache = cacheable_items.reject do |item|
item[:rendered] = items_in_cache[item[:cache_key]] item[:rendered] = items_in_cache[item[:cache_key]]
...@@ -96,7 +116,7 @@ module Banzai ...@@ -96,7 +116,7 @@ module Banzai
end end
end end
(items_not_in_cache + non_cacheable_items).each do |item| (items_not_in_cache + Array.wrap(non_cacheable_items)).each do |item|
item[:rendered] = render(item[:text], item[:context]) item[:rendered] = render(item[:text], item[:context])
Rails.cache.write(item[:cache_key], item[:rendered]) if item[:cache_key] Rails.cache.write(item[:cache_key], item[:rendered]) if item[:cache_key]
end end
......
...@@ -26,10 +26,6 @@ module Gitlab ...@@ -26,10 +26,6 @@ module Gitlab
attrs attrs
end end
def changed_markdown_fields
changed_attributes.keys.map(&:to_s) & cached_markdown_fields.markdown_fields.map(&:to_s)
end
def write_markdown_field(field_name, value) def write_markdown_field(field_name, value)
write_attribute(field_name, value) write_attribute(field_name, value)
end end
......
...@@ -36,8 +36,8 @@ module Gitlab ...@@ -36,8 +36,8 @@ module Gitlab
false false
end end
def changed_markdown_fields def changed_attributes
[] {}
end end
def cached_markdown def cached_markdown
......
...@@ -19,6 +19,24 @@ describe Banzai::Renderer do ...@@ -19,6 +19,24 @@ describe Banzai::Renderer do
object object
end end
describe '#cache_collection_render' do
let(:merge_request) { fake_object(fresh: true) }
let(:context) { { cache_key: [merge_request, 'field'], rendered: merge_request.field_html } }
context 'when an item has a rendered field' do
before do
allow(merge_request).to receive(:field).and_return('This is the field')
allow(merge_request).to receive(:field_html).and_return('This is the field')
end
it 'does not touch redis if the field is in the cache' do
expect(Rails).not_to receive(:cache)
described_class.cache_collection_render([{ text: merge_request.field, context: context }])
end
end
end
describe '#render_field' do describe '#render_field' do
let(:renderer) { described_class } let(:renderer) { described_class }
......
...@@ -9,12 +9,13 @@ describe Gitlab::MarkdownCache::ActiveRecord::Extension do ...@@ -9,12 +9,13 @@ describe Gitlab::MarkdownCache::ActiveRecord::Extension do
cache_markdown_field :title, whitelisted: true cache_markdown_field :title, whitelisted: true
cache_markdown_field :description, pipeline: :single_line cache_markdown_field :description, pipeline: :single_line
attr_accessor :author, :project attribute :author
attribute :project
end end
end end
let(:cache_version) { Gitlab::MarkdownCache::CACHE_COMMONMARK_VERSION << 16 } let(:cache_version) { Gitlab::MarkdownCache::CACHE_COMMONMARK_VERSION << 16 }
let(:thing) { klass.new(title: markdown, title_html: html, cached_markdown_version: cache_version) } let(:thing) { klass.create(title: markdown, title_html: html, cached_markdown_version: cache_version) }
let(:markdown) { '`Foo`' } let(:markdown) { '`Foo`' }
let(:html) { '<p data-sourcepos="1:1-1:5" dir="auto"><code>Foo</code></p>' } let(:html) { '<p data-sourcepos="1:1-1:5" dir="auto"><code>Foo</code></p>' }
...@@ -37,7 +38,7 @@ describe Gitlab::MarkdownCache::ActiveRecord::Extension do ...@@ -37,7 +38,7 @@ describe Gitlab::MarkdownCache::ActiveRecord::Extension do
end end
context 'a changed markdown field' do context 'a changed markdown field' do
let(:thing) { klass.new(title: markdown, title_html: html, cached_markdown_version: cache_version) } let(:thing) { klass.create(title: markdown, title_html: html, cached_markdown_version: cache_version) }
before do before do
thing.title = updated_markdown thing.title = updated_markdown
......
...@@ -139,8 +139,8 @@ describe CommitRange do ...@@ -139,8 +139,8 @@ describe CommitRange do
end end
describe '#has_been_reverted?' do describe '#has_been_reverted?' do
let(:issue) { create(:issue) } let(:user) { create(:user) }
let(:user) { issue.author } let(:issue) { create(:issue, author: user, project: project) }
it 'returns true if the commit has been reverted' do it 'returns true if the commit has been reverted' do
create(:note_on_issue, create(:note_on_issue,
...@@ -149,9 +149,11 @@ describe CommitRange do ...@@ -149,9 +149,11 @@ describe CommitRange do
note: commit1.revert_description(user), note: commit1.revert_description(user),
project: issue.project) project: issue.project)
expect_any_instance_of(Commit).to receive(:reverts_commit?) expect_next_instance_of(Commit) do |commit|
.with(commit1, user) expect(commit).to receive(:reverts_commit?)
.and_return(true) .with(commit1, user)
.and_return(true)
end
expect(commit1.has_been_reverted?(user, issue.notes_with_associations)).to eq(true) expect(commit1.has_been_reverted?(user, issue.notes_with_associations)).to eq(true)
end end
......
...@@ -198,6 +198,36 @@ describe CacheMarkdownField, :clean_gitlab_redis_cache do ...@@ -198,6 +198,36 @@ describe CacheMarkdownField, :clean_gitlab_redis_cache do
end end
end end
end end
describe '#updated_cached_html_for' do
let(:thing) { klass.new(description: markdown, description_html: html, cached_markdown_version: cache_version) }
context 'when the markdown cache is outdated' do
before do
thing.cached_markdown_version += 1
end
it 'calls #refresh_markdown_cache' do
expect(thing).to receive(:refresh_markdown_cache)
expect(thing.updated_cached_html_for(:description)).to eq(html)
end
end
context 'when the markdown field does not exist' do
it 'returns nil' do
expect(thing.updated_cached_html_for(:something)).to eq(nil)
end
end
context 'when the markdown cache is up to date' do
it 'does not call #refresh_markdown_cache' do
expect(thing).not_to receive(:refresh_markdown_cache)
expect(thing.updated_cached_html_for(:description)).to eq(html)
end
end
end
end end
context 'for Active record classes' do context 'for Active record classes' do
......
...@@ -177,6 +177,7 @@ describe Note do ...@@ -177,6 +177,7 @@ describe Note do
pipeline: :note, pipeline: :note,
cache_key: [note1, "note"], cache_key: [note1, "note"],
project: note1.project, project: note1.project,
rendered: note1.note_html,
author: note1.author author: note1.author
} }
}]).and_call_original }]).and_call_original
...@@ -189,6 +190,7 @@ describe Note do ...@@ -189,6 +190,7 @@ describe Note do
pipeline: :note, pipeline: :note,
cache_key: [note2, "note"], cache_key: [note2, "note"],
project: note2.project, project: note2.project,
rendered: note2.note_html,
author: note2.author author: note2.author
} }
}]).and_call_original }]).and_call_original
......
...@@ -215,13 +215,14 @@ describe NotificationService, :mailer do ...@@ -215,13 +215,14 @@ describe NotificationService, :mailer do
let(:project) { create(:project, :private) } let(:project) { create(:project, :private) }
let(:issue) { create(:issue, project: project, assignees: [assignee]) } let(:issue) { create(:issue, project: project, assignees: [assignee]) }
let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let(:mentioned_issue) { create(:issue, assignees: issue.assignees) }
let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') } let(:author) { create(:user) }
let(:note) { create(:note_on_issue, author: author, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') }
before do before do
build_team(note.project) build_team(project)
project.add_maintainer(issue.author) project.add_maintainer(issue.author)
project.add_maintainer(assignee) project.add_maintainer(assignee)
project.add_maintainer(note.author) project.add_maintainer(author)
@u_custom_off = create_user_with_notification(:custom, 'custom_off') @u_custom_off = create_user_with_notification(:custom, 'custom_off')
project.add_guest(@u_custom_off) project.add_guest(@u_custom_off)
...@@ -240,7 +241,8 @@ describe NotificationService, :mailer do ...@@ -240,7 +241,8 @@ describe NotificationService, :mailer do
describe '#new_note' do describe '#new_note' do
it do it do
add_users_with_subscription(note.project, issue) add_users(project)
add_user_subscriptions(issue)
reset_delivered_emails! reset_delivered_emails!
expect(SentNotification).to receive(:record).with(issue, any_args).exactly(10).times expect(SentNotification).to receive(:record).with(issue, any_args).exactly(10).times
...@@ -268,7 +270,8 @@ describe NotificationService, :mailer do ...@@ -268,7 +270,8 @@ describe NotificationService, :mailer do
end end
it "emails the note author if they've opted into notifications about their activity" do it "emails the note author if they've opted into notifications about their activity" do
add_users_with_subscription(note.project, issue) add_users(project)
add_user_subscriptions(issue)
reset_delivered_emails! reset_delivered_emails!
note.author.notified_of_own_activity = true note.author.notified_of_own_activity = true
...@@ -415,13 +418,15 @@ describe NotificationService, :mailer do ...@@ -415,13 +418,15 @@ describe NotificationService, :mailer do
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project, assignees: [assignee]) } let(:issue) { create(:issue, project: project, assignees: [assignee]) }
let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let(:mentioned_issue) { create(:issue, assignees: issue.assignees) }
let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@all mentioned') } let(:author) { create(:user) }
let(:note) { create(:note_on_issue, author: author, noteable: issue, project_id: issue.project_id, note: '@all mentioned') }
before do before do
build_team(note.project) build_team(project)
build_group(note.project) build_group(project)
note.project.add_maintainer(note.author) add_users(project)
add_users_with_subscription(note.project, issue) add_user_subscriptions(issue)
project.add_maintainer(author)
reset_delivered_emails! reset_delivered_emails!
end end
...@@ -473,17 +478,18 @@ describe NotificationService, :mailer do ...@@ -473,17 +478,18 @@ describe NotificationService, :mailer do
context 'project snippet note' do context 'project snippet note' do
let!(:project) { create(:project, :public) } let!(:project) { create(:project, :public) }
let(:snippet) { create(:project_snippet, project: project, author: create(:user)) } let(:snippet) { create(:project_snippet, project: project, author: create(:user)) }
let(:note) { create(:note_on_project_snippet, noteable: snippet, project_id: project.id, note: '@all mentioned') } let(:author) { create(:user) }
let(:note) { create(:note_on_project_snippet, author: author, noteable: snippet, project_id: project.id, note: '@all mentioned') }
before do before do
build_team(project) build_team(project)
build_group(project) build_group(project)
project.add_maintainer(author)
# make sure these users can read the project snippet! # make sure these users can read the project snippet!
project.add_guest(@u_guest_watcher) project.add_guest(@u_guest_watcher)
project.add_guest(@u_guest_custom) project.add_guest(@u_guest_custom)
add_member_for_parent_group(@pg_watcher, project) add_member_for_parent_group(@pg_watcher, project)
note.project.add_maintainer(note.author)
reset_delivered_emails! reset_delivered_emails!
end end
...@@ -708,10 +714,11 @@ describe NotificationService, :mailer do ...@@ -708,10 +714,11 @@ describe NotificationService, :mailer do
let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant @unsubscribed_mentioned' } let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant @unsubscribed_mentioned' }
before do before do
build_team(issue.project) build_team(project)
build_group(issue.project) build_group(project)
add_users_with_subscription(issue.project, issue) add_users(project)
add_user_subscriptions(issue)
reset_delivered_emails! reset_delivered_emails!
update_custom_notification(:new_issue, @u_guest_custom, resource: project) update_custom_notification(:new_issue, @u_guest_custom, resource: project)
update_custom_notification(:new_issue, @u_custom_global) update_custom_notification(:new_issue, @u_custom_global)
...@@ -1281,13 +1288,16 @@ describe NotificationService, :mailer do ...@@ -1281,13 +1288,16 @@ describe NotificationService, :mailer do
let(:project) { create(:project, :public, :repository, namespace: group) } let(:project) { create(:project, :public, :repository, namespace: group) }
let(:another_project) { create(:project, :public, namespace: group) } let(:another_project) { create(:project, :public, namespace: group) }
let(:assignee) { create(:user) } let(:assignee) { create(:user) }
let(:merge_request) { create :merge_request, source_project: project, assignees: [assignee], description: 'cc @participant' } let(:assignees) { Array.wrap(assignee) }
let(:author) { create(:user) }
let(:merge_request) { create :merge_request, author: author, source_project: project, assignees: assignees, description: 'cc @participant' }
before do before do
project.add_maintainer(merge_request.author) project.add_maintainer(author)
merge_request.assignees.each { |assignee| project.add_maintainer(assignee) } assignees.each { |assignee| project.add_maintainer(assignee) }
build_team(merge_request.target_project) build_team(project)
add_users_with_subscription(merge_request.target_project, merge_request) add_users(project)
add_user_subscriptions(merge_request)
update_custom_notification(:new_merge_request, @u_guest_custom, resource: project) update_custom_notification(:new_merge_request, @u_guest_custom, resource: project)
update_custom_notification(:new_merge_request, @u_custom_global) update_custom_notification(:new_merge_request, @u_custom_global)
reset_delivered_emails! reset_delivered_emails!
...@@ -2417,7 +2427,7 @@ describe NotificationService, :mailer do ...@@ -2417,7 +2427,7 @@ describe NotificationService, :mailer do
should_not_email(user, recipients: email_recipients) should_not_email(user, recipients: email_recipients)
end end
def add_users_with_subscription(project, issuable) def add_users(project)
@subscriber = create :user @subscriber = create :user
@unsubscriber = create :user @unsubscriber = create :user
@unsubscribed_mentioned = create :user, username: 'unsubscribed_mentioned' @unsubscribed_mentioned = create :user, username: 'unsubscribed_mentioned'
...@@ -2429,7 +2439,9 @@ describe NotificationService, :mailer do ...@@ -2429,7 +2439,9 @@ describe NotificationService, :mailer do
project.add_maintainer(@unsubscriber) project.add_maintainer(@unsubscriber)
project.add_maintainer(@watcher_and_subscriber) project.add_maintainer(@watcher_and_subscriber)
project.add_maintainer(@unsubscribed_mentioned) project.add_maintainer(@unsubscribed_mentioned)
end
def add_user_subscriptions(issuable)
issuable.subscriptions.create(user: @unsubscribed_mentioned, project: project, subscribed: false) issuable.subscriptions.create(user: @unsubscribed_mentioned, project: project, subscribed: false)
issuable.subscriptions.create(user: @subscriber, project: project, subscribed: true) issuable.subscriptions.create(user: @subscriber, project: project, subscribed: true)
issuable.subscriptions.create(user: @subscribed_participant, project: project, subscribed: true) issuable.subscriptions.create(user: @subscribed_participant, project: project, subscribed: true)
......
...@@ -76,6 +76,30 @@ shared_examples 'a mentionable' do ...@@ -76,6 +76,30 @@ shared_examples 'a mentionable' do
expect(refs).to include(ext_commit) expect(refs).to include(ext_commit)
end end
context 'when there are cached markdown fields' do
before do
if subject.is_a?(CacheMarkdownField)
subject.refresh_markdown_cache
end
end
it 'sends in cached markdown fields when appropriate' do
if subject.is_a?(CacheMarkdownField)
expect_next_instance_of(Gitlab::ReferenceExtractor) do |ext|
attrs = subject.class.mentionable_attrs.collect(&:first) & subject.cached_markdown_fields.markdown_fields
attrs.each do |field|
expect(ext).to receive(:analyze).with(subject.send(field), hash_including(rendered: anything))
end
end
expect(subject).not_to receive(:refresh_markdown_cache)
expect(subject).to receive(:cached_markdown_fields).at_least(:once).and_call_original
subject.all_references(author)
end
end
end
it 'creates cross-reference notes' do it 'creates cross-reference notes' do
mentioned_objects = [mentioned_issue, mentioned_mr, mentioned_commit, mentioned_objects = [mentioned_issue, mentioned_mr, mentioned_commit,
ext_issue, ext_mr, ext_commit] ext_issue, ext_mr, ext_commit]
...@@ -98,6 +122,33 @@ shared_examples 'an editable mentionable' do ...@@ -98,6 +122,33 @@ shared_examples 'an editable mentionable' do
[create(:issue, project: project), create(:issue, project: ext_proj)] [create(:issue, project: project), create(:issue, project: ext_proj)]
end end
context 'when there are cached markdown fields' do
before do
if subject.is_a?(CacheMarkdownField)
subject.refresh_markdown_cache
end
end
it 'refreshes markdown cache if necessary' do
subject.save!
set_mentionable_text.call('This is a text')
if subject.is_a?(CacheMarkdownField)
expect_next_instance_of(Gitlab::ReferenceExtractor) do |ext|
subject.cached_markdown_fields.markdown_fields.each do |field|
expect(ext).to receive(:analyze).with(subject.send(field), hash_including(rendered: anything))
end
end
expect(subject).to receive(:refresh_markdown_cache)
expect(subject).to receive(:cached_markdown_fields).at_least(:once).and_call_original
subject.all_references(author)
end
end
end
it 'creates new cross-reference notes when the mentionable text is edited' do it 'creates new cross-reference notes when the mentionable text is edited' do
subject.save subject.save
subject.create_cross_references! subject.create_cross_references!
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment