Commit 8044440d authored by Stan Hu's avatar Stan Hu

Eliminate many Gitaly calls in discussions API

Previously, the API to retrieve discussions from merge requests often
generated hundreds of Gitaly calls to determine whether a system note
should be shown to the user. It did this by:

1. Rendering the Markdown
2. Extracting cross-references from the Markdown
3. For cross-references that were commits, a Gitaly FindCommit RPC
   would be issued to validate that the commit exists.

The last step is unnecessary because we don't need to display a commit
if the user doesn't have access to the project in the first place.

`RendersNotes#prepare_notes_for_rendering` is already used in
`MergeRequestsController`, which is why we don't see N+1 Gitaly calls
there. We use it here to optimize the note redaction process.

Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/65957
parent 4c4bd2c4
---
title: Eliminate many Gitaly calls in discussions API
merge_request: 31834
author:
type: performance
......@@ -4,6 +4,7 @@ module API
class Discussions < Grape::API
include PaginationParams
helpers ::API::Helpers::NotesHelpers
helpers ::RendersNotes
before { authenticate! }
......@@ -23,21 +24,15 @@ module API
requires :noteable_id, types: [Integer, String], desc: 'The ID of the noteable'
use :pagination
end
# rubocop: disable CodeReuse/ActiveRecord
get ":id/#{noteables_path}/:noteable_id/discussions" do
noteable = find_noteable(parent_type, params[:id], noteable_type, params[:noteable_id])
notes = noteable.notes
.inc_relations_for_view
.includes(:noteable)
.fresh
notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
notes = readable_discussion_notes(noteable)
discussions = Kaminari.paginate_array(Discussion.build_collection(notes, noteable))
present paginate(discussions), with: Entities::Discussion
end
# rubocop: enable CodeReuse/ActiveRecord
desc "Get a single #{noteable_type.to_s.downcase} discussion" do
success Entities::Discussion
......@@ -226,13 +221,24 @@ module API
helpers do
# rubocop: disable CodeReuse/ActiveRecord
def readable_discussion_notes(noteable, discussion_id)
def readable_discussion_notes(noteable, discussion_id = nil)
notes = noteable.notes
.where(discussion_id: discussion_id)
notes = notes.where(discussion_id: discussion_id) if discussion_id
notes = notes
.inc_relations_for_view
.includes(:noteable)
.fresh
# Without RendersActions#prepare_notes_for_rendering,
# Note#cross_reference_not_visible_for? will attempt to render
# Markdown references mentioned in the note to see whether they
# should be redacted. For notes that reference a commit, this
# would also incur a Gitaly call to verify the commit exists.
#
# With prepare_notes_for_rendering, we can avoid Gitaly calls
# because notes are redacted if they point to projects that
# cannot be accessed by the user.
notes = prepare_notes_for_rendering(notes)
notes.reject { |n| n.cross_reference_not_visible_for?(current_user) }
end
# rubocop: enable CodeReuse/ActiveRecord
......
......@@ -9,6 +9,59 @@ describe API::Discussions do
project.add_developer(user)
end
context 'with cross-reference system notes', :request_store do
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.project }
let(:new_merge_request) { create(:merge_request) }
let(:commit) { new_merge_request.project.commit }
let!(:note) { create(:system_note, noteable: merge_request, project: project, note: cross_reference) }
let!(:note_metadata) { create(:system_note_metadata, note: note, action: 'cross_reference') }
let(:cross_reference) { "test commit #{commit.to_reference(project)}" }
let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/discussions" }
before do
project.add_developer(user)
new_merge_request.project.add_developer(user)
end
it 'returns only the note that the user should see' do
hidden_merge_request = create(:merge_request)
new_cross_reference = "test commit #{hidden_merge_request.project.commit}"
new_note = create(:system_note, noteable: merge_request, project: project, note: new_cross_reference)
create(:system_note_metadata, note: new_note, action: 'cross_reference')
get api(url, user)
expect(response).to have_gitlab_http_status(200)
expect(json_response.count).to eq(1)
expect(json_response.first['notes'].count).to eq(1)
parsed_note = json_response.first['notes'].first
expect(parsed_note['id']).to eq(note.id)
expect(parsed_note['body']).to eq(cross_reference)
expect(parsed_note['system']).to be true
end
it 'avoids Git calls and N+1 SQL queries' do
expect_any_instance_of(Repository).not_to receive(:find_commit).with(commit.id)
control = ActiveRecord::QueryRecorder.new do
get api(url, user)
end
expect(response).to have_gitlab_http_status(200)
RequestStore.clear!
new_note = create(:system_note, noteable: merge_request, project: project, note: cross_reference)
create(:system_note_metadata, note: new_note, action: 'cross_reference')
RequestStore.clear!
expect { get api(url, user) }.not_to exceed_query_limit(control)
expect(response).to have_gitlab_http_status(200)
end
end
context 'when noteable is an Issue' do
let!(:issue) { create(:issue, project: project, author: user) }
let!(:issue_note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: user) }
......
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