Commit 84cb8798 authored by Igor Drozdov's avatar Igor Drozdov

Merge branch '208738-improve-performance-of-branches-list-api-when-under-load' into 'master'

Use native Gitaly pagination for Branch list API

See merge request gitlab-org/gitlab!35819
parents da361a35 e596e619
...@@ -5,12 +5,16 @@ class BranchesFinder < GitRefsFinder ...@@ -5,12 +5,16 @@ class BranchesFinder < GitRefsFinder
super(repository, params) super(repository, params)
end end
def execute def execute(gitaly_pagination: false)
if gitaly_pagination && names.blank? && search.blank?
repository.branches_sorted_by(sort, pagination_params)
else
branches = repository.branches_sorted_by(sort) branches = repository.branches_sorted_by(sort)
branches = by_search(branches) branches = by_search(branches)
branches = by_names(branches) branches = by_names(branches)
branches branches
end end
end
private private
...@@ -18,6 +22,18 @@ class BranchesFinder < GitRefsFinder ...@@ -18,6 +22,18 @@ class BranchesFinder < GitRefsFinder
@params[:names].presence @params[:names].presence
end end
def per_page
@params[:per_page].presence
end
def page_token
"#{Gitlab::Git::BRANCH_REF_PREFIX}#{@params[:page_token]}" if @params[:page_token]
end
def pagination_params
{ limit: per_page, page_token: page_token }
end
def by_names(branches) def by_names(branches)
return branches unless names return branches unless names
......
...@@ -713,8 +713,8 @@ class Repository ...@@ -713,8 +713,8 @@ class Repository
"#{name}-#{highest_branch_id + 1}" "#{name}-#{highest_branch_id + 1}"
end end
def branches_sorted_by(value) def branches_sorted_by(sort_by, pagination_params = nil)
raw_repository.local_branches(sort_by: value) raw_repository.local_branches(sort_by: sort_by, pagination_params: pagination_params)
end end
def tags_sorted_by(value) def tags_sorted_by(value)
......
---
title: Use native Gitaly pagination for Branch list API
merge_request: 35819
author:
type: changed
...@@ -32,14 +32,21 @@ module API ...@@ -32,14 +32,21 @@ module API
params do params do
use :pagination use :pagination
use :filter_params use :filter_params
optional :page_token, type: String, desc: 'Name of branch to start the paginaition from'
end end
get ':id/repository/branches' do get ':id/repository/branches' do
user_project.preload_protected_branches user_project.preload_protected_branches
repository = user_project.repository repository = user_project.repository
if Feature.enabled?(:branch_list_keyset_pagination, user_project)
branches = BranchesFinder.new(repository, declared_params(include_missing: false)).execute(gitaly_pagination: true)
else
branches = BranchesFinder.new(repository, declared_params(include_missing: false)).execute branches = BranchesFinder.new(repository, declared_params(include_missing: false)).execute
branches = paginate(::Kaminari.paginate_array(branches)) branches = paginate(::Kaminari.paginate_array(branches))
end
merged_branch_names = repository.merged_branch_names(branches.map(&:name)) merged_branch_names = repository.merged_branch_names(branches.map(&:name))
present( present(
......
...@@ -127,9 +127,9 @@ module Gitlab ...@@ -127,9 +127,9 @@ module Gitlab
end end
end end
def local_branches(sort_by: nil) def local_branches(sort_by: nil, pagination_params: nil)
wrapped_gitaly_errors do wrapped_gitaly_errors do
gitaly_ref_client.local_branches(sort_by: sort_by) gitaly_ref_client.local_branches(sort_by: sort_by, pagination_params: pagination_params)
end end
end end
......
...@@ -110,8 +110,8 @@ module Gitlab ...@@ -110,8 +110,8 @@ module Gitlab
branch_names.count branch_names.count
end end
def local_branches(sort_by: nil) def local_branches(sort_by: nil, pagination_params: nil)
request = Gitaly::FindLocalBranchesRequest.new(repository: @gitaly_repo) request = Gitaly::FindLocalBranchesRequest.new(repository: @gitaly_repo, pagination_params: pagination_params)
request.sort_by = sort_by_param(sort_by) if sort_by request.sort_by = sort_by_param(sort_by) if sort_by
response = GitalyClient.call(@storage, :ref_service, :find_local_branches, request, timeout: GitalyClient.fast_timeout) response = GitalyClient.call(@storage, :ref_service, :find_local_branches, request, timeout: GitalyClient.fast_timeout)
consume_find_local_branches_response(response) consume_find_local_branches_response(response)
......
...@@ -7,20 +7,28 @@ RSpec.describe BranchesFinder do ...@@ -7,20 +7,28 @@ RSpec.describe BranchesFinder do
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
let(:repository) { project.repository } let(:repository) { project.repository }
let(:branch_finder) { described_class.new(repository, params) }
let(:params) { {} }
describe '#execute' do describe '#execute' do
subject { branch_finder.execute }
context 'sort only' do context 'sort only' do
it 'sorts by name' do context 'by name' do
branches_finder = described_class.new(repository, {}) let(:params) { {} }
result = branches_finder.execute it 'sorts' do
result = subject
expect(result.first.name).to eq("'test'") expect(result.first.name).to eq("'test'")
end end
end
it 'sorts by recently_updated' do context 'by recently_updated' do
branches_finder = described_class.new(repository, { sort: 'updated_desc' }) let(:params) { { sort: 'updated_desc' } }
result = branches_finder.execute it 'sorts' do
result = subject
recently_updated_branch = repository.branches.max do |a, b| recently_updated_branch = repository.branches.max do |a, b|
repository.commit(a.dereferenced_target).committed_date <=> repository.commit(b.dereferenced_target).committed_date repository.commit(a.dereferenced_target).committed_date <=> repository.commit(b.dereferenced_target).committed_date
...@@ -28,122 +36,227 @@ RSpec.describe BranchesFinder do ...@@ -28,122 +36,227 @@ RSpec.describe BranchesFinder do
expect(result.first.name).to eq(recently_updated_branch.name) expect(result.first.name).to eq(recently_updated_branch.name)
end end
end
it 'sorts by last_updated' do context 'by last_updated' do
branches_finder = described_class.new(repository, { sort: 'updated_asc' }) let(:params) { { sort: 'updated_asc' } }
result = branches_finder.execute it 'sorts' do
result = subject
expect(result.first.name).to eq('feature') expect(result.first.name).to eq('feature')
end end
end end
end
context 'filter only' do context 'filter only' do
it 'filters branches by name' do context 'by name' do
branches_finder = described_class.new(repository, { search: 'fix' }) let(:params) { { search: 'fix' } }
result = branches_finder.execute it 'filters branches' do
result = subject
expect(result.first.name).to eq('fix') expect(result.first.name).to eq('fix')
expect(result.count).to eq(1) expect(result.count).to eq(1)
end end
end
it 'filters branches by name ignoring letter case' do context 'by name ignoring letter case' do
branches_finder = described_class.new(repository, { search: 'FiX' }) let(:params) { { search: 'FiX' } }
result = branches_finder.execute it 'filters branches' do
result = subject
expect(result.first.name).to eq('fix') expect(result.first.name).to eq('fix')
expect(result.count).to eq(1) expect(result.count).to eq(1)
end end
end
it 'does not find any branch with that name' do context 'with an unknown name' do
branches_finder = described_class.new(repository, { search: 'random' }) let(:params) { { search: 'random' } }
result = branches_finder.execute it 'does not find any branch' do
result = subject
expect(result.count).to eq(0) expect(result.count).to eq(0)
end end
end
it 'filters branches by provided names' do context 'by provided names' do
branches_finder = described_class.new(repository, { names: %w[fix csv lfs does-not-exist] }) let(:params) { { names: %w[fix csv lfs does-not-exist] } }
result = branches_finder.execute it 'filters branches' do
result = subject
expect(result.count).to eq(3) expect(result.count).to eq(3)
expect(result.map(&:name)).to eq(%w{csv fix lfs}) expect(result.map(&:name)).to eq(%w{csv fix lfs})
end end
end
it 'filters branches by name that begins with' do context 'by name that begins with' do
params = { search: '^feature_' } let(:params) { { search: '^feature_' } }
branches_finder = described_class.new(repository, params)
result = branches_finder.execute it 'filters branches' do
result = subject
expect(result.first.name).to eq('feature_conflict') expect(result.first.name).to eq('feature_conflict')
expect(result.count).to eq(1) expect(result.count).to eq(1)
end end
end
it 'filters branches by name that ends with' do context 'by name that ends with' do
params = { search: 'feature$' } let(:params) { { search: 'feature$' } }
branches_finder = described_class.new(repository, params)
result = branches_finder.execute it 'filters branches' do
result = subject
expect(result.first.name).to eq('feature') expect(result.first.name).to eq('feature')
expect(result.count).to eq(1) expect(result.count).to eq(1)
end end
end
it 'filters branches by nonexistent name that begins with' do context 'by nonexistent name that begins with' do
params = { search: '^nope' } let(:params) { { search: '^nope' } }
branches_finder = described_class.new(repository, params)
result = branches_finder.execute it 'filters branches' do
result = subject
expect(result.count).to eq(0) expect(result.count).to eq(0)
end end
end
it 'filters branches by nonexistent name that ends with' do context 'by nonexistent name that ends with' do
params = { search: 'nope$' } let(:params) { { search: 'nope$' } }
branches_finder = described_class.new(repository, params)
result = branches_finder.execute it 'filters branches' do
result = subject
expect(result.count).to eq(0) expect(result.count).to eq(0)
end end
end end
end
context 'filter and sort' do context 'filter and sort' do
it 'filters branches by name and sorts by recently_updated' do context 'by name and sorts by recently_updated' do
params = { sort: 'updated_desc', search: 'feat' } let(:params) { { sort: 'updated_desc', search: 'feat' } }
branches_finder = described_class.new(repository, params)
result = branches_finder.execute it 'filters branches' do
result = subject
expect(result.first.name).to eq('feature_conflict') expect(result.first.name).to eq('feature_conflict')
expect(result.count).to eq(2) expect(result.count).to eq(2)
end end
end
it 'filters branches by name and sorts by recently_updated, with exact matches first' do context 'by name and sorts by recently_updated, with exact matches first' do
params = { sort: 'updated_desc', search: 'feature' } let(:params) { { sort: 'updated_desc', search: 'feature' } }
branches_finder = described_class.new(repository, params)
result = branches_finder.execute it 'filters branches' do
result = subject
expect(result.first.name).to eq('feature') expect(result.first.name).to eq('feature')
expect(result.second.name).to eq('feature_conflict') expect(result.second.name).to eq('feature_conflict')
expect(result.count).to eq(2) expect(result.count).to eq(2)
end end
end
it 'filters branches by name and sorts by last_updated' do context 'by name and sorts by last_updated' do
params = { sort: 'updated_asc', search: 'feature' } let(:params) { { sort: 'updated_asc', search: 'feature' } }
branches_finder = described_class.new(repository, params)
result = branches_finder.execute it 'filters branches' do
result = subject
expect(result.first.name).to eq('feature') expect(result.first.name).to eq('feature')
expect(result.count).to eq(2) expect(result.count).to eq(2)
end end
end end
end end
context 'with gitaly pagination' do
subject { branch_finder.execute(gitaly_pagination: true) }
context 'by page_token and per_page' do
let(:params) { { page_token: 'feature', per_page: 2 } }
it 'filters branches' do
result = subject
expect(result.map(&:name)).to eq(%w(feature_conflict fix))
end
end
context 'by next page_token and per_page' do
let(:params) { { page_token: 'fix', per_page: 2 } }
it 'filters branches' do
result = subject
expect(result.map(&:name)).to eq(%w(flatten-dir gitattributes))
end
end
context 'by per_page only' do
let(:params) { { per_page: 2 } }
it 'filters branches' do
result = subject
expect(result.map(&:name)).to eq(["'test'", '2-mb-file'])
end
end
context 'by page_token only' do
let(:params) { { page_token: 'feature' } }
it 'returns nothing' do
result = subject
expect(result.count).to eq(0)
end
end
context 'pagination and sort' do
context 'by per_page' do
let(:params) { { sort: 'updated_asc', per_page: 5 } }
it 'filters branches' do
result = subject
expect(result.map(&:name)).to eq(%w(feature improve/awesome merge-test markdown feature_conflict))
end
end
context 'by page_token and per_page' do
let(:params) { { sort: 'updated_asc', page_token: 'improve/awesome', per_page: 2 } }
it 'filters branches' do
result = subject
expect(result.map(&:name)).to eq(%w(merge-test markdown))
end
end
end
context 'pagination and names' do
let(:params) { { page_token: 'fix', per_page: 2, names: %w[fix csv lfs does-not-exist] } }
it 'falls back to default execute and ignore paginations' do
result = subject
expect(result.count).to eq(3)
expect(result.map(&:name)).to eq(%w{csv fix lfs})
end
end
context 'pagination and search' do
let(:params) { { page_token: 'feature', per_page: 2, search: '^f' } }
it 'falls back to default execute and ignore paginations' do
result = subject
expect(result.map(&:name)).to eq(%w(feature feature_conflict fix flatten-dir))
end
end
end
end
end end
...@@ -17,6 +17,7 @@ RSpec.describe API::Branches do ...@@ -17,6 +17,7 @@ RSpec.describe API::Branches do
before do before do
project.add_maintainer(user) project.add_maintainer(user)
project.repository.add_branch(user, 'ends-with.txt', branch_sha) project.repository.add_branch(user, 'ends-with.txt', branch_sha)
stub_feature_flags(branch_list_keyset_pagination: false)
end end
describe "GET /projects/:id/repository/branches" do describe "GET /projects/:id/repository/branches" do
...@@ -29,6 +30,16 @@ RSpec.describe API::Branches do ...@@ -29,6 +30,16 @@ RSpec.describe API::Branches do
end end
end end
def check_merge_status(json_response)
merged, unmerged = json_response.partition { |branch| branch['merged'] }
merged_branches = merged.map { |branch| branch['name'] }
unmerged_branches = unmerged.map { |branch| branch['name'] }
expect(Set.new(merged_branches)).to eq(project.repository.merged_branch_names(merged_branches + unmerged_branches))
expect(project.repository.merged_branch_names(unmerged_branches)).to be_empty
end
context 'with branch_list_keyset_pagination feature off' do
context 'with legacy pagination params' do
it 'returns the repository branches' do it 'returns the repository branches' do
get api(route, current_user), params: { per_page: 100 } get api(route, current_user), params: { per_page: 100 }
...@@ -39,12 +50,58 @@ RSpec.describe API::Branches do ...@@ -39,12 +50,58 @@ RSpec.describe API::Branches do
expect(branch_names).to match_array(project.repository.branch_names) expect(branch_names).to match_array(project.repository.branch_names)
end end
def check_merge_status(json_response) it 'determines only a limited number of merged branch names' do
merged, unmerged = json_response.partition { |branch| branch['merged'] } expect(API::Entities::Branch).to receive(:represent).with(anything, has_up_to_merged_branch_names_count(2)).and_call_original
merged_branches = merged.map { |branch| branch['name'] }
unmerged_branches = unmerged.map { |branch| branch['name'] } get api(route, current_user), params: { per_page: 2 }
expect(Set.new(merged_branches)).to eq(project.repository.merged_branch_names(merged_branches + unmerged_branches))
expect(project.repository.merged_branch_names(unmerged_branches)).to be_empty expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq 2
check_merge_status(json_response)
end
it 'merge status matches reality on paginated input' do
expected_first_branch_name = project.repository.branches_sorted_by('name')[20].name
get api(route, current_user), params: { per_page: 20, page: 2 }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq 20
expect(json_response.first['name']).to eq(expected_first_branch_name)
check_merge_status(json_response)
end
end
context 'with gitaly pagination params ' do
it 'merge status matches reality on paginated input' do
expected_first_branch_name = project.repository.branches_sorted_by('name').first.name
get api(route, current_user), params: { per_page: 20, page_token: 'feature' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq 20
expect(json_response.first['name']).to eq(expected_first_branch_name)
check_merge_status(json_response)
end
end
end
context 'with branch_list_keyset_pagination feature on' do
before do
stub_feature_flags(branch_list_keyset_pagination: true)
end
context 'with gitaly pagination params ' do
it 'returns the repository branches' do
get api(route, current_user), params: { per_page: 100 }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/branches')
branch_names = json_response.map { |x| x['name'] }
expect(branch_names).to match_array(project.repository.branch_names)
end end
it 'determines only a limited number of merged branch names' do it 'determines only a limited number of merged branch names' do
...@@ -53,17 +110,36 @@ RSpec.describe API::Branches do ...@@ -53,17 +110,36 @@ RSpec.describe API::Branches do
get api(route, current_user), params: { per_page: 2 } get api(route, current_user), params: { per_page: 2 }
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq 2
check_merge_status(json_response) check_merge_status(json_response)
end end
it 'merge status matches reality on paginated input' do it 'merge status matches reality on paginated input' do
expected_first_branch_name = project.repository.branches_sorted_by('name').drop_while { |b| b.name <= 'feature' }.first.name
get api(route, current_user), params: { per_page: 20, page_token: 'feature' }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.count).to eq 20
expect(json_response.first['name']).to eq(expected_first_branch_name)
check_merge_status(json_response)
end
end
context 'with legacy pagination params' do
it 'ignores legacy pagination params' do
expected_first_branch_name = project.repository.branches_sorted_by('name').first.name
get api(route, current_user), params: { per_page: 20, page: 2 } get api(route, current_user), params: { per_page: 20, page: 2 }
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response.first['name']).to eq(expected_first_branch_name)
check_merge_status(json_response) check_merge_status(json_response)
end end
end
end
context 'when repository is disabled' do context 'when repository is disabled' do
include_context 'disabled repository' include_context 'disabled repository'
......
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