Commit ac0b104a authored by Bob Van Landuyt's avatar Bob Van Landuyt

Minimize the number of queries by preloading counts and ancestors

By preloading the count of members, projects and subgroups of a group,
we don't need to query them later.

We also preload the entire hierarchy for a search result and include
the counts so we don't need to query for them again
parent cd85c22f
...@@ -13,12 +13,6 @@ class GroupDescendantsFinder ...@@ -13,12 +13,6 @@ class GroupDescendantsFinder
Kaminari.paginate_array(children) Kaminari.paginate_array(children)
end end
# This allows us to fetch only the count without loading the objects. Unless
# the objects were already loaded.
def total_count
@total_count ||= subgroup_count + project_count
end
def subgroup_count def subgroup_count
@subgroup_count ||= if defined?(@children) @subgroup_count ||= if defined?(@children)
children.count { |child| child.is_a?(Group) } children.count { |child| child.is_a?(Group) }
...@@ -38,7 +32,39 @@ class GroupDescendantsFinder ...@@ -38,7 +32,39 @@ class GroupDescendantsFinder
private private
def children def children
@children ||= subgroups.with_route.includes(parent: [:route, :parent]) + projects.with_route.includes(namespace: [:route, :parent]) return @children if @children
projects_count = <<~PROJECTCOUNT
(SELECT COUNT(projects.id) AS preloaded_project_count
FROM projects WHERE projects.namespace_id = namespaces.id)
PROJECTCOUNT
subgroup_count = <<~SUBGROUPCOUNT
(SELECT COUNT(children.id) AS preloaded_subgroup_count
FROM namespaces children
WHERE children.parent_id = namespaces.id)
SUBGROUPCOUNT
member_count = <<~MEMBERCOUNT
(SELECT COUNT(members.user_id) AS preloaded_member_count
FROM members
WHERE members.source_type = 'Namespace'
AND members.source_id = namespaces.id
AND members.requested_at IS NULL)
MEMBERCOUNT
group_selects = [
'namespaces.*',
projects_count,
subgroup_count,
member_count
]
subgroups_with_counts = subgroups.with_route.select(group_selects)
if params[:filter]
ancestors_for_project_search = ancestors_for_groups(Group.where(id: projects_matching_filter.select(:namespace_id)))
subgroups_with_counts = ancestors_for_project_search.with_route.select(group_selects) | subgroups_with_counts
end
@children = subgroups_with_counts + projects.preload(:route)
end end
def direct_child_groups def direct_child_groups
...@@ -48,11 +74,19 @@ class GroupDescendantsFinder ...@@ -48,11 +74,19 @@ class GroupDescendantsFinder
end end
def all_descendant_groups def all_descendant_groups
Gitlab::GroupHierarchy.new(Group.where(id: parent_group)).base_and_descendants Gitlab::GroupHierarchy.new(Group.where(id: parent_group))
.base_and_descendants
end end
def subgroups_matching_filter def subgroups_matching_filter
all_descendant_groups.where.not(id: parent_group).search(params[:filter]) all_descendant_groups
.where.not(id: parent_group)
.search(params[:filter])
end
def ancestors_for_groups(base_for_ancestors)
Gitlab::GroupHierarchy.new(base_for_ancestors)
.base_and_ancestors.where.not(id: parent_group)
end end
def subgroups def subgroups
...@@ -62,20 +96,23 @@ class GroupDescendantsFinder ...@@ -62,20 +96,23 @@ class GroupDescendantsFinder
# When filtering subgroups, we want to find all matches withing the tree of # When filtering subgroups, we want to find all matches withing the tree of
# descendants to show to the user # descendants to show to the user
groups = if params[:filter] groups = if params[:filter]
subgroups_matching_filter ancestors_for_groups(subgroups_matching_filter)
else else
direct_child_groups direct_child_groups
end end
groups.sort(params[:sort]) groups.sort(params[:sort])
end end
def projects_for_user
Project.public_or_visible_to_user(current_user).non_archived
end
def direct_child_projects def direct_child_projects
GroupProjectsFinder.new(group: parent_group, params: params, current_user: current_user).execute projects_for_user.where(namespace: parent_group)
end end
def projects_matching_filter def projects_matching_filter
ProjectsFinder.new(current_user: current_user, params: params).execute projects_for_user.search(params[:filter])
.search(params[:filter])
.where(namespace: all_descendant_groups) .where(namespace: all_descendant_groups)
end end
......
module GroupDescendant module GroupDescendant
def hierarchy(hierarchy_top = nil) def hierarchy(hierarchy_top = nil, preloaded = [])
expand_hierarchy_for_child(self, self, hierarchy_top) expand_hierarchy_for_child(self, self, hierarchy_top, preloaded)
end end
def expand_hierarchy_for_child(child, hierarchy, hierarchy_top) def expand_hierarchy_for_child(child, hierarchy, hierarchy_top, preloaded = [])
if child.parent.nil? && hierarchy_top.present? parent = preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id }
parent ||= child.parent
if parent.nil? && hierarchy_top.present?
raise ArgumentError.new('specified base is not part of the tree') raise ArgumentError.new('specified base is not part of the tree')
end end
if child.parent && child.parent != hierarchy_top if parent && parent != hierarchy_top
expand_hierarchy_for_child(child.parent, expand_hierarchy_for_child(parent,
{ child.parent => hierarchy }, { parent => hierarchy },
hierarchy_top) hierarchy_top)
else else
hierarchy hierarchy
...@@ -30,10 +33,10 @@ module GroupDescendant ...@@ -30,10 +33,10 @@ module GroupDescendant
end end
first_descendant, *other_descendants = descendants first_descendant, *other_descendants = descendants
merged = first_descendant.hierarchy(hierarchy_top) merged = first_descendant.hierarchy(hierarchy_top, descendants)
other_descendants.each do |descendant| other_descendants.each do |descendant|
next_descendant = descendant.hierarchy(hierarchy_top) next_descendant = descendant.hierarchy(hierarchy_top, descendants)
merged = merge_hash_tree(merged, next_descendant) merged = merge_hash_tree(merged, next_descendant)
end end
......
...@@ -1525,6 +1525,10 @@ class Project < ActiveRecord::Base ...@@ -1525,6 +1525,10 @@ class Project < ActiveRecord::Base
namespace namespace
end end
def parent_id
namespace_id
end
def parent_changed? def parent_changed?
namespace_id_changed? namespace_id_changed?
end end
......
...@@ -60,15 +60,23 @@ class GroupChildEntity < Grape::Entity ...@@ -60,15 +60,23 @@ class GroupChildEntity < Grape::Entity
end end
def children_count def children_count
@children_count ||= children_finder.total_count @children_count ||= project_count + subgroup_count
end end
def project_count def project_count
@project_count ||= children_finder.project_count @project_count ||= if object.respond_to?(:preloaded_project_count)
object.preloaded_project_count
else
children_finder.project_count
end
end end
def subgroup_count def subgroup_count
@subgroup_count ||= children_finder.subgroup_count @subgroup_count ||= if object.respond_to?(:preloaded_subgroup_count)
object.preloaded_subgroup_count
else
children_finder.subgroup_count
end
end end
def leave_path def leave_path
...@@ -88,6 +96,11 @@ class GroupChildEntity < Grape::Entity ...@@ -88,6 +96,11 @@ class GroupChildEntity < Grape::Entity
end end
def number_users_with_delimiter def number_users_with_delimiter
number_with_delimiter(object.users.count) member_count = if object.respond_to?(:preloaded_member_count)
object.preloaded_member_count
else
object.users.count
end
number_with_delimiter(member_count)
end end
end end
...@@ -303,10 +303,12 @@ describe GroupsController do ...@@ -303,10 +303,12 @@ describe GroupsController do
end end
context 'queries per rendered element', :request_store do context 'queries per rendered element', :request_store do
# The expected extra queries for the rendered group are: # We need to make sure the following counts are preloaded
# otherwise they will cause an extra query
# 1. Count of visible projects in the element # 1. Count of visible projects in the element
# 2. Count of visible subgroups in the element # 2. Count of visible subgroups in the element
let(:expected_queries_per_group) { 2 } # 3. Count of members of a group
let(:expected_queries_per_group) { 0 }
let(:expected_queries_per_project) { 0 } let(:expected_queries_per_project) { 0 }
def get_list def get_list
...@@ -329,13 +331,9 @@ describe GroupsController do ...@@ -329,13 +331,9 @@ describe GroupsController do
end end
context 'when rendering hierarchies' do context 'when rendering hierarchies' do
# Extra queries per group when rendering a hierarchy: # When loading hierarchies we load the all the ancestors for matched projects
# The route and the namespace are `included` for all matched elements # in 1 separate query
# But the parent's above those are not, so there's 2 extra queries per let(:extra_queries_for_hierarchies) { 1 }
# nested level:
# 1. Loading the parent that wasn't loaded yet
# 2. Loading the route for that parent.
let(:extra_queries_per_nested_level) { expected_queries_per_group + 2 }
def get_filtered_list def get_filtered_list
get :children, id: group.to_param, filter: 'filter', format: :json get :children, id: group.to_param, filter: 'filter', format: :json
...@@ -348,7 +346,7 @@ describe GroupsController do ...@@ -348,7 +346,7 @@ describe GroupsController do
matched_group.update!(parent: public_subgroup) matched_group.update!(parent: public_subgroup)
expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_per_nested_level) expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
end end
it 'queries the expected amount when a new group match is added' do it 'queries the expected amount when a new group match is added' do
...@@ -357,8 +355,9 @@ describe GroupsController do ...@@ -357,8 +355,9 @@ describe GroupsController do
control = ActiveRecord::QueryRecorder.new { get_filtered_list } control = ActiveRecord::QueryRecorder.new { get_filtered_list }
create(:group, :public, parent: public_subgroup, name: 'filterme2') create(:group, :public, parent: public_subgroup, name: 'filterme2')
create(:group, :public, parent: public_subgroup, name: 'filterme3')
expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_per_nested_level) expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
end end
it 'queries the expected amount when nested rows are increased for a project' do it 'queries the expected amount when nested rows are increased for a project' do
...@@ -368,18 +367,7 @@ describe GroupsController do ...@@ -368,18 +367,7 @@ describe GroupsController do
matched_project.update!(namespace: public_subgroup) matched_project.update!(namespace: public_subgroup)
expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_per_nested_level) expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
end
it 'queries the expected amount when a new project match is added' do
create(:project, :public, namespace: public_subgroup, name: 'filterme')
control = ActiveRecord::QueryRecorder.new { get_filtered_list }
nested_group = create(:group, :public, parent: group)
create(:project, :public, namespace: nested_group, name: 'filterme2')
expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_per_nested_level)
end end
end end
end end
......
...@@ -46,6 +46,18 @@ describe GroupDescendantsFinder do ...@@ -46,6 +46,18 @@ describe GroupDescendantsFinder do
expect(finder.execute).to contain_exactly(subgroup, project) expect(finder.execute).to contain_exactly(subgroup, project)
end end
it 'includes the preloaded counts for groups' do
create(:group, parent: subgroup)
create(:project, namespace: subgroup)
subgroup.add_developer(create(:user))
found_group = finder.execute.detect { |child| child.is_a?(Group) }
expect(found_group.preloaded_project_count).to eq(1)
expect(found_group.preloaded_subgroup_count).to eq(1)
expect(found_group.preloaded_member_count).to eq(1)
end
context 'with a filter' do context 'with a filter' do
let(:params) { { filter: 'test' } } let(:params) { { filter: 'test' } }
...@@ -57,16 +69,16 @@ describe GroupDescendantsFinder do ...@@ -57,16 +69,16 @@ describe GroupDescendantsFinder do
end end
context 'with matching children' do context 'with matching children' do
it 'includes a group that has a subgroup matching the query' do it 'includes a group that has a subgroup matching the query and its parent' do
matching_subgroup = create(:group, name: 'testgroup', parent: subgroup) matching_subgroup = create(:group, name: 'testgroup', parent: subgroup)
expect(finder.execute).to contain_exactly(matching_subgroup) expect(finder.execute).to contain_exactly(subgroup, matching_subgroup)
end end
it 'includes a group that has a project matching the query' do it 'includes the parent of a matching project' do
matching_project = create(:project, namespace: subgroup, name: 'Testproject') matching_project = create(:project, namespace: subgroup, name: 'Testproject')
expect(finder.execute).to contain_exactly(matching_project) expect(finder.execute).to contain_exactly(subgroup, matching_project)
end end
it 'does not include the parent itself' do it 'does not include the parent itself' do
...@@ -77,23 +89,5 @@ describe GroupDescendantsFinder do ...@@ -77,23 +89,5 @@ describe GroupDescendantsFinder do
end end
end end
end end
describe '#total_count' do
it 'counts the array children were already loaded' do
finder.instance_variable_set(:@children, [build(:project)])
expect(finder).not_to receive(:subgroups)
expect(finder).not_to receive(:projects)
expect(finder.total_count).to eq(1)
end
it 'performs a count without loading children when they are not loaded yet' do
expect(finder).to receive(:subgroups).and_call_original
expect(finder).to receive(:projects).and_call_original
expect(finder.total_count).to eq(2)
end
end
end end
end end
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