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
Kaminari.paginate_array(children)
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
@subgroup_count ||= if defined?(@children)
children.count { |child| child.is_a?(Group) }
......@@ -38,7 +32,39 @@ class GroupDescendantsFinder
private
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
def direct_child_groups
......@@ -48,11 +74,19 @@ class GroupDescendantsFinder
end
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
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
def subgroups
......@@ -62,20 +96,23 @@ class GroupDescendantsFinder
# When filtering subgroups, we want to find all matches withing the tree of
# descendants to show to the user
groups = if params[:filter]
subgroups_matching_filter
ancestors_for_groups(subgroups_matching_filter)
else
direct_child_groups
end
groups.sort(params[:sort])
end
def projects_for_user
Project.public_or_visible_to_user(current_user).non_archived
end
def direct_child_projects
GroupProjectsFinder.new(group: parent_group, params: params, current_user: current_user).execute
projects_for_user.where(namespace: parent_group)
end
def projects_matching_filter
ProjectsFinder.new(current_user: current_user, params: params).execute
.search(params[:filter])
projects_for_user.search(params[:filter])
.where(namespace: all_descendant_groups)
end
......
module GroupDescendant
def hierarchy(hierarchy_top = nil)
expand_hierarchy_for_child(self, self, hierarchy_top)
def hierarchy(hierarchy_top = nil, preloaded = [])
expand_hierarchy_for_child(self, self, hierarchy_top, preloaded)
end
def expand_hierarchy_for_child(child, hierarchy, hierarchy_top)
if child.parent.nil? && hierarchy_top.present?
def expand_hierarchy_for_child(child, hierarchy, hierarchy_top, preloaded = [])
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')
end
if child.parent && child.parent != hierarchy_top
expand_hierarchy_for_child(child.parent,
{ child.parent => hierarchy },
if parent && parent != hierarchy_top
expand_hierarchy_for_child(parent,
{ parent => hierarchy },
hierarchy_top)
else
hierarchy
......@@ -30,10 +33,10 @@ module GroupDescendant
end
first_descendant, *other_descendants = descendants
merged = first_descendant.hierarchy(hierarchy_top)
merged = first_descendant.hierarchy(hierarchy_top, descendants)
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)
end
......
......@@ -1525,6 +1525,10 @@ class Project < ActiveRecord::Base
namespace
end
def parent_id
namespace_id
end
def parent_changed?
namespace_id_changed?
end
......
......@@ -60,15 +60,23 @@ class GroupChildEntity < Grape::Entity
end
def children_count
@children_count ||= children_finder.total_count
@children_count ||= project_count + subgroup_count
end
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
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
def leave_path
......@@ -88,6 +96,11 @@ class GroupChildEntity < Grape::Entity
end
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
......@@ -303,10 +303,12 @@ describe GroupsController do
end
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
# 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 }
def get_list
......@@ -329,13 +331,9 @@ describe GroupsController do
end
context 'when rendering hierarchies' do
# Extra queries per group when rendering a hierarchy:
# The route and the namespace are `included` for all matched elements
# But the parent's above those are not, so there's 2 extra queries per
# 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 }
# When loading hierarchies we load the all the ancestors for matched projects
# in 1 separate query
let(:extra_queries_for_hierarchies) { 1 }
def get_filtered_list
get :children, id: group.to_param, filter: 'filter', format: :json
......@@ -348,7 +346,7 @@ describe GroupsController do
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
it 'queries the expected amount when a new group match is added' do
......@@ -357,8 +355,9 @@ describe GroupsController do
control = ActiveRecord::QueryRecorder.new { get_filtered_list }
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
it 'queries the expected amount when nested rows are increased for a project' do
......@@ -368,18 +367,7 @@ describe GroupsController do
matched_project.update!(namespace: public_subgroup)
expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_per_nested_level)
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)
expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies)
end
end
end
......
......@@ -46,6 +46,18 @@ describe GroupDescendantsFinder do
expect(finder.execute).to contain_exactly(subgroup, project)
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
let(:params) { { filter: 'test' } }
......@@ -57,16 +69,16 @@ describe GroupDescendantsFinder do
end
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)
expect(finder.execute).to contain_exactly(matching_subgroup)
expect(finder.execute).to contain_exactly(subgroup, matching_subgroup)
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')
expect(finder.execute).to contain_exactly(matching_project)
expect(finder.execute).to contain_exactly(subgroup, matching_project)
end
it 'does not include the parent itself' do
......@@ -77,23 +89,5 @@ describe GroupDescendantsFinder do
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
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