From ae537ddf9bda445f20e2e7607eed4a62fa0f4e69 Mon Sep 17 00:00:00 2001 From: Vasilii Iakliushin <viakliushin@gitlab.com> Date: Tue, 22 Aug 2023 20:05:00 +0000 Subject: [PATCH] Optimize groups template page query Contributes to https://gitlab.com/gitlab-org/gitlab/-/issues/381077 **Problem** The query to fetch group templates is slow. **Solution** Optimize fetching logic to produce better execution plans. EE: true --- app/models/namespace.rb | 1 + app/models/plan.rb | 2 + .../optimize_group_template_query.yml | 8 ++ .../finders/groups_with_templates_finder.rb | 23 ++-- ee/app/models/ee/user.rb | 2 +- ee/app/models/gitlab_subscription.rb | 4 + .../groups_with_templates_finder_spec.rb | 115 +++++++++++------- ee/spec/models/ee/user_spec.rb | 31 +++-- ee/spec/models/gitlab_subscription_spec.rb | 19 ++- spec/models/namespace_spec.rb | 9 ++ spec/models/plan_spec.rb | 12 ++ 11 files changed, 166 insertions(+), 60 deletions(-) create mode 100644 config/feature_flags/development/optimize_group_template_query.yml diff --git a/app/models/namespace.rb b/app/models/namespace.rb index be39b894ef960..6ba2bf39bb6f3 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -166,6 +166,7 @@ class Namespace < ApplicationRecord scope :sort_by_type, -> { order(arel_table[:type].asc.nulls_first) } scope :include_route, -> { includes(:route) } scope :by_parent, -> (parent) { where(parent_id: parent) } + scope :by_root_id, -> (root_id) { where('traversal_ids[1] IN (?)', root_id) } scope :filter_by_path, -> (query) { where('lower(path) = :query', query: query.downcase) } scope :in_organization, -> (organization) { where(organization: organization) } diff --git a/app/models/plan.rb b/app/models/plan.rb index 22c1201421c80..9ab22bc045a7d 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -5,6 +5,8 @@ class Plan < MainClusterwide::ApplicationRecord has_one :limits, class_name: 'PlanLimits' + scope :by_name, ->(name) { where(name: name) } + ALL_PLANS = [DEFAULT].freeze DEFAULT_PLANS = [DEFAULT].freeze private_constant :ALL_PLANS, :DEFAULT_PLANS diff --git a/config/feature_flags/development/optimize_group_template_query.yml b/config/feature_flags/development/optimize_group_template_query.yml new file mode 100644 index 0000000000000..d5dc8b16476f3 --- /dev/null +++ b/config/feature_flags/development/optimize_group_template_query.yml @@ -0,0 +1,8 @@ +--- +name: optimize_group_template_query +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/129399 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/422390 +milestone: '16.4' +type: development +group: group::source code +default_enabled: false diff --git a/ee/app/finders/groups_with_templates_finder.rb b/ee/app/finders/groups_with_templates_finder.rb index 6a11b6443acdc..64f6462a8ebe5 100644 --- a/ee/app/finders/groups_with_templates_finder.rb +++ b/ee/app/finders/groups_with_templates_finder.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class GroupsWithTemplatesFinder - def initialize(group_id = nil) + def initialize(user, group_id = nil) + @user = user @group_id = group_id end @@ -15,14 +16,22 @@ def execute private - attr_reader :group_id + attr_reader :user, :group_id def extended_group_search - Group - .with_project_templates - .self_and_ancestors - .with_feature_available_in_plan(:group_project_templates) - .self_and_descendants + if Feature.enabled?(:optimize_group_template_query, user) && Feature.enabled?(:use_traversal_ids) + plan_ids = Plan + .by_name(GitlabSubscriptions::Features.saas_plans_with_feature(:group_project_templates)) + .pluck(:id) # rubocop: disable CodeReuse/ActiveRecord + + Group.by_root_id(GitlabSubscription.by_hosted_plan_ids(plan_ids).select(:namespace_id)) + else + Group + .with_project_templates + .self_and_ancestors + .with_feature_available_in_plan(:group_project_templates) + .self_and_descendants + end end def simple_group_search(groups) diff --git a/ee/app/models/ee/user.rb b/ee/app/models/ee/user.rb index 19cc1fef12650..88ff8cc9f566f 100644 --- a/ee/app/models/ee/user.rb +++ b/ee/app/models/ee/user.rb @@ -303,7 +303,7 @@ def search_membership_ancestry end def available_subgroups_with_custom_project_templates(group_id = nil) - found_groups = GroupsWithTemplatesFinder.new(group_id).execute + found_groups = GroupsWithTemplatesFinder.new(self, group_id).execute ::GroupsFinder.new(self, min_access_level: ::Gitlab::Access::DEVELOPER) .execute diff --git a/ee/app/models/gitlab_subscription.rb b/ee/app/models/gitlab_subscription.rb index cd487d2756499..0ae3b72c3649a 100644 --- a/ee/app/models/gitlab_subscription.rb +++ b/ee/app/models/gitlab_subscription.rb @@ -28,6 +28,10 @@ class GitlabSubscription < ApplicationRecord delegate :name, :title, to: :hosted_plan, prefix: :plan, allow_nil: true delegate :exclude_guests?, to: :namespace + scope :by_hosted_plan_ids, ->(plan_ids) do + where(hosted_plan_id: plan_ids) + end + scope :with_hosted_plan, -> (plan_name) do joins(:hosted_plan).where(trial: false, 'plans.name' => plan_name) .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422013') diff --git a/ee/spec/finders/groups_with_templates_finder_spec.rb b/ee/spec/finders/groups_with_templates_finder_spec.rb index 74740cc9a6af5..96a424065008e 100644 --- a/ee/spec/finders/groups_with_templates_finder_spec.rb +++ b/ee/spec/finders/groups_with_templates_finder_spec.rb @@ -2,7 +2,10 @@ require 'spec_helper' -RSpec.describe GroupsWithTemplatesFinder, :saas do +RSpec.describe GroupsWithTemplatesFinder, :saas, feature_category: :source_code_management do + subject { described_class.new(user, group_id).execute } + + let_it_be(:user) { create(:user) } let_it_be(:group_1, reload: true) { create(:group, name: 'group-1') } let_it_be(:group_2, reload: true) { create(:group, name: 'group-2') } let_it_be(:group_3, reload: true) { create(:group, name: 'group-3') } @@ -15,6 +18,8 @@ let_it_be(:subgroup_4, reload: true) { create(:group, parent: group_1, name: 'subgroup-4') } let_it_be(:subgroup_5) { create(:group, parent: subgroup_4, name: 'subgroup-5') } + let(:group_id) { nil } + before do group_1.update!(custom_project_templates_group_id: subgroup_1.id) group_2.update!(custom_project_templates_group_id: subgroup_2.id) @@ -26,56 +31,39 @@ create(:gitlab_subscription, :premium, namespace: group_2) end - describe 'without group id' do - it 'returns all groups' do - expect(described_class.new.execute).to contain_exactly(group_1, group_2, group_3) - end - - context 'when namespace checked' do - before do - stub_ee_application_setting(should_check_namespace_plan: true) + shared_examples 'groups_with_templates' do + describe 'without group id' do + it 'returns all groups' do + is_expected.to contain_exactly(group_1, group_2, group_3) end - it 'returns groups on ultimate/premium plan' do - expect(described_class.new.execute).to contain_exactly(group_1, group_2) - end - - context 'with subgroup with template' do + context 'when namespace checked' do before do - subgroup_4.update!(custom_project_templates_group_id: subgroup_5.id) - create(:project, namespace: subgroup_5) + stub_ee_application_setting(should_check_namespace_plan: true) end it 'returns groups on ultimate/premium plan' do - expect(described_class.new.execute).to contain_exactly(group_1, group_2, subgroup_4) + is_expected.to contain_exactly(group_1, group_2) end - end - end - end - - describe 'with group id' do - it 'returns given group with it descendants' do - expect(described_class.new(group_1.id).execute).to contain_exactly(group_1) - end - context 'with subgroup with template' do - before do - subgroup_4.update!(custom_project_templates_group_id: subgroup_5.id) - create(:project, namespace: subgroup_5) - end + context 'with subgroup with template' do + before do + subgroup_4.update!(custom_project_templates_group_id: subgroup_5.id) + create(:project, namespace: subgroup_5) + end - it 'returns only chosen group' do - expect(described_class.new(group_1.id).execute).to contain_exactly(group_1) + it 'returns groups on ultimate/premium plan' do + is_expected.to contain_exactly(group_1, group_2, subgroup_4) + end + end end end - context 'when namespace checked' do - before do - stub_ee_application_setting(should_check_namespace_plan: true) - end + describe 'with group id' do + let(:group_id) { group_1.id } - it 'does not return the group' do - expect(described_class.new(group_3.id).execute).to be_empty + it 'returns given group with it descendants' do + is_expected.to contain_exactly(group_1) end context 'with subgroup with template' do @@ -85,13 +73,58 @@ end it 'returns only chosen group' do - expect(described_class.new(group_1.id).execute).to contain_exactly(group_1) + is_expected.to contain_exactly(group_1) end + end + + context 'when namespace checked' do + before do + stub_ee_application_setting(should_check_namespace_plan: true) + end + + context 'when group does not have a license' do + let(:group_id) { group_3.id } - it 'returns only chosen subgroup' do - expect(described_class.new(subgroup_4.id).execute).to contain_exactly(group_1, subgroup_4) + it 'does not return the group' do + is_expected.to be_empty + end + end + + context 'with subgroup with template' do + before do + subgroup_4.update!(custom_project_templates_group_id: subgroup_5.id) + create(:project, namespace: subgroup_5) + end + + context 'when group is provided' do + let(:group_id) { group_1.id } + + it 'returns only chosen group' do + is_expected.to contain_exactly(group_1) + end + end + + context 'when subgroup is provided' do + let(:group_id) { subgroup_4.id } + + it 'returns only chosen subgroup' do + is_expected.to contain_exactly(group_1, subgroup_4) + end + end end end end end + + context 'when feature flag "optimize_group_template_query" is enabled' do + it_behaves_like 'groups_with_templates' + end + + context 'when feature flag "optimize_group_template_query" is disabled' do + before do + stub_feature_flags(optimize_group_template_query: false) + end + + it_behaves_like 'groups_with_templates' + end end diff --git a/ee/spec/models/ee/user_spec.rb b/ee/spec/models/ee/user_spec.rb index e3e9e615e37ec..827665de55a1c 100644 --- a/ee/spec/models/ee/user_spec.rb +++ b/ee/spec/models/ee/user_spec.rb @@ -645,18 +645,29 @@ let!(:group_1) { create(:group, name: 'group-1') } let!(:group_2) { create(:group, name: 'group-2') } let!(:group_3) { create(:group, name: 'group-3') } + let!(:group_4) { create(:group, name: 'group-4') } let!(:subgroup_1) { create(:group, parent: group_1, name: 'subgroup-1') } let!(:subgroup_2) { create(:group, parent: group_2, name: 'subgroup-2') } let!(:subgroup_3) { create(:group, parent: group_3, name: 'subgroup-3') } + let!(:subgroup_4) { create(:group, parent: group_4, name: 'subgroup-4') } + + let!(:subsubgroup_1) { create(:group, parent: subgroup_1, name: 'sub-subgroup-1') } + let!(:subsubgroup_4) { create(:group, parent: subgroup_4, name: 'sub-subgroup-4') } before do group_1.update!(custom_project_templates_group_id: subgroup_1.id) group_2.update!(custom_project_templates_group_id: subgroup_2.id) group_3.update!(custom_project_templates_group_id: subgroup_3.id) + subgroup_1.update!(custom_project_templates_group_id: subsubgroup_1.id) + subgroup_4.update!(custom_project_templates_group_id: subsubgroup_4.id) + create(:project, namespace: subgroup_1) create(:project, namespace: subgroup_2) + + create(:project, namespace: subsubgroup_1) + create(:project, namespace: subsubgroup_4) end context 'when the access level of the user is below the required one' do @@ -674,6 +685,7 @@ group_1.add_developer(user) group_2.add_maintainer(user) group_3.add_developer(user) + subgroup_4.add_developer(user) end context 'when a Group ID is passed' do @@ -689,8 +701,8 @@ it 'returns all available Groups' do groups = user.available_subgroups_with_custom_project_templates - expect(groups.to_a.size).to eq(2) - expect(groups.map(&:name)).to include('subgroup-1', 'subgroup-2') + expect(groups.to_a.size).to eq(4) + expect(groups.map(&:name)).to include('subgroup-1', 'subgroup-2', 'sub-subgroup-1', 'sub-subgroup-4') end it 'excludes Groups with the configured setting but without projects' do @@ -701,17 +713,22 @@ end context 'when namespace plan is checked', :saas do + let(:bronze_plan) { create(:bronze_plan) } + let(:ultimate_plan) { create(:ultimate_plan) } + before do - create(:gitlab_subscription, namespace: group_1, hosted_plan: create(:bronze_plan)) - create(:gitlab_subscription, namespace: group_2, hosted_plan: create(:ultimate_plan)) - allow(Gitlab::CurrentSettings).to receive(:should_check_namespace_plan?) { true } + stub_ee_application_setting(should_check_namespace_plan: true) + + create(:gitlab_subscription, namespace: group_1, hosted_plan: bronze_plan) + create(:gitlab_subscription, namespace: group_2, hosted_plan: ultimate_plan) + create(:gitlab_subscription, namespace: group_4, hosted_plan: ultimate_plan) end it 'returns groups on ultimate or premium plans' do groups = user.available_subgroups_with_custom_project_templates - expect(groups.to_a.size).to eq(1) - expect(groups.map(&:name)).to include('subgroup-2') + expect(groups.to_a.size).to eq(2) + expect(groups.map(&:name)).to match_array(%w[subgroup-2 sub-subgroup-4]) end end end diff --git a/ee/spec/models/gitlab_subscription_spec.rb b/ee/spec/models/gitlab_subscription_spec.rb index d6269ebbc04a1..b852cfbd1366e 100644 --- a/ee/spec/models/gitlab_subscription_spec.rb +++ b/ee/spec/models/gitlab_subscription_spec.rb @@ -34,10 +34,9 @@ describe 'scopes' do describe '.with_hosted_plan' do - let!(:ultimate_subscription) { create(:gitlab_subscription, hosted_plan: ultimate_plan) } - let!(:premium_subscription) { create(:gitlab_subscription, hosted_plan: premium_plan) } - - let!(:trial_subscription) { create(:gitlab_subscription, hosted_plan: ultimate_plan, trial: true) } + let_it_be(:ultimate_subscription) { create(:gitlab_subscription, hosted_plan: ultimate_plan) } + let_it_be(:premium_subscription) { create(:gitlab_subscription, hosted_plan: premium_plan) } + let_it_be(:trial_subscription) { create(:gitlab_subscription, hosted_plan: ultimate_plan, trial: true) } it 'scopes to the plan' do expect(described_class.with_hosted_plan('ultimate')).to contain_exactly(ultimate_subscription) @@ -46,6 +45,18 @@ end end + describe '.by_hosted_plan_ids' do + let_it_be(:ultimate_subscription) { create(:gitlab_subscription, hosted_plan: ultimate_plan) } + let_it_be(:premium_subscription) { create(:gitlab_subscription, hosted_plan: premium_plan) } + let_it_be(:trial_subscription) { create(:gitlab_subscription, hosted_plan: ultimate_plan, trial: true) } + + it 'returns subscriptions by their plan ids' do + expect(described_class.by_hosted_plan_ids(ultimate_plan.id)).to match_array([ultimate_subscription, trial_subscription]) + expect(described_class.by_hosted_plan_ids([ultimate_plan.id, premium_plan.id])).to match_array([ultimate_subscription, premium_subscription, trial_subscription]) + expect(described_class.by_hosted_plan_ids(non_existing_record_id)).to be_empty + end + end + describe '.max_seats_used_changed_between', :timecop do let(:from) { Time.current.beginning_of_day - 1.day } let(:to) { Time.current.beginning_of_day } diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 623c9c7e07c18..a03dac83113a8 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -443,6 +443,15 @@ end end + describe '.by_root_id' do + it 'returns correct namespaces' do + expect(described_class.by_root_id(namespace1.id)).to match_array([namespace1, namespace1sub]) + expect(described_class.by_root_id(namespace2.id)).to match_array([namespace2, namespace2sub]) + expect(described_class.by_root_id(namespace1sub.id)).to be_empty + expect(described_class.by_root_id(nil)).to be_empty + end + end + describe '.filter_by_path' do it 'includes correct namespaces' do expect(described_class.filter_by_path(namespace1.path)).to eq([namespace1]) diff --git a/spec/models/plan_spec.rb b/spec/models/plan_spec.rb index 73e88a17e2421..fe3365ca78f1a 100644 --- a/spec/models/plan_spec.rb +++ b/spec/models/plan_spec.rb @@ -3,6 +3,18 @@ require 'spec_helper' RSpec.describe Plan do + describe 'scopes', :aggregate_failures do + let_it_be(:default_plan) { create(:default_plan) } + + describe '.by_name' do + it 'returns plans by their name' do + expect(described_class.by_name('default')).to match_array([default_plan]) + expect(described_class.by_name(%w[default unknown])).to match_array([default_plan]) + expect(described_class.by_name(nil)).to be_empty + end + end + end + describe '#default?' do subject { plan.default? } -- GitLab