From 170c3af03a0868516310ab8cdcf822247a6e1c01 Mon Sep 17 00:00:00 2001 From: Adam Hegyi <ahegyi@gitlab.com> Date: Fri, 29 Nov 2019 14:35:05 +0100 Subject: [PATCH] Reduce N+1 queries on deploy keys index page - Add test to prevent further N+1 queries --- app/models/deploy_key.rb | 10 ++- app/models/user.rb | 2 +- app/policies/deploy_key_policy.rb | 5 +- .../settings/deploy_keys_presenter.rb | 62 ++++++++++++++----- app/serializers/deploy_key_entity.rb | 11 +++- .../21059-optimize-deploy-keys-index-page.yml | 5 ++ ...ndex_to_projects_deploy_keys_deploy_key.rb | 17 +++++ db/schema.rb | 1 + .../projects/deploy_keys_controller_spec.rb | 26 +++++--- .../settings/deploy_keys_presenter_spec.rb | 61 ++++++++++++++++-- 10 files changed, 161 insertions(+), 39 deletions(-) create mode 100644 changelogs/unreleased/21059-optimize-deploy-keys-index-page.yml create mode 100644 db/migrate/20191205145647_add_index_to_projects_deploy_keys_deploy_key.rb diff --git a/app/models/deploy_key.rb b/app/models/deploy_key.rb index 19216281e48de..793ea3c29c3a1 100644 --- a/app/models/deploy_key.rb +++ b/app/models/deploy_key.rb @@ -9,7 +9,7 @@ class DeployKey < Key scope :in_projects, ->(projects) { joins(:deploy_keys_projects).where('deploy_keys_projects.project_id in (?)', projects) } scope :are_public, -> { where(public: true) } - scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, :namespace] }) } + scope :with_projects, -> { includes(deploy_keys_projects: { project: [:route, namespace: :route] }) } ignore_column :can_push, remove_after: '2019-12-15', remove_with: '12.6' @@ -24,7 +24,7 @@ def orphaned? end def almost_orphaned? - self.deploy_keys_projects.count == 1 + self.deploy_keys_projects.size == 1 end def destroyed_when_orphaned? @@ -44,7 +44,11 @@ def can_push_to?(project) end def deploy_keys_project_for(project) - deploy_keys_projects.find_by(project: project) + if association(:deploy_keys_projects).loaded? + deploy_keys_projects.find { |dkp| dkp.project_id.eql?(project&.id) } + else + deploy_keys_projects.find_by(project: project) + end end def projects_with_write_access diff --git a/app/models/user.rb b/app/models/user.rb index fd02db865828b..7b6caa9070e9f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -997,7 +997,7 @@ def ldap_identity end def project_deploy_keys - DeployKey.in_projects(authorized_projects.select(:id)).distinct(:id) + @project_deploy_keys ||= DeployKey.in_projects(authorized_projects.select(:id)).distinct(:id) end def highest_role diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb index 7f0ec011e792f..b117bb5792198 100644 --- a/app/policies/deploy_key_policy.rb +++ b/app/policies/deploy_key_policy.rb @@ -3,10 +3,7 @@ class DeployKeyPolicy < BasePolicy with_options scope: :subject, score: 0 condition(:private_deploy_key) { @subject.private? } - - # rubocop: disable CodeReuse/ActiveRecord - condition(:has_deploy_key) { @user.project_deploy_keys.exists?(id: @subject.id) } - # rubocop: enable CodeReuse/ActiveRecord + condition(:has_deploy_key) { @user.project_deploy_keys.any? { |pdk| pdk.id.eql?(@subject.id) } } rule { anonymous }.prevent_all diff --git a/app/presenters/projects/settings/deploy_keys_presenter.rb b/app/presenters/projects/settings/deploy_keys_presenter.rb index 9bb7fe13593b7..66211d0269675 100644 --- a/app/presenters/projects/settings/deploy_keys_presenter.rb +++ b/app/presenters/projects/settings/deploy_keys_presenter.rb @@ -3,6 +3,8 @@ module Projects module Settings class DeployKeysPresenter < Gitlab::View::Presenter::Simple + include Gitlab::Utils::StrongMemoize + presents :project delegate :size, to: :enabled_keys, prefix: true delegate :size, to: :available_project_keys, prefix: true @@ -13,37 +15,45 @@ def new_key end def enabled_keys - project.deploy_keys + strong_memoize(:enabled_keys) do + project.deploy_keys.with_projects + end end def available_keys - current_user - .accessible_deploy_keys - .id_not_in(enabled_keys.select(:id)) - .with_projects + strong_memoize(:available_keys) do + current_user + .accessible_deploy_keys + .id_not_in(enabled_keys.select(:id)) + .with_projects + end end def available_project_keys - current_user - .project_deploy_keys - .id_not_in(enabled_keys.select(:id)) - .with_projects + strong_memoize(:available_project_keys) do + current_user + .project_deploy_keys + .id_not_in(enabled_keys.select(:id)) + .with_projects + end end def available_public_keys - DeployKey - .are_public - .id_not_in(enabled_keys.select(:id)) - .id_not_in(available_project_keys.select(:id)) - .with_projects + strong_memoize(:available_public_keys) do + DeployKey + .are_public + .id_not_in(enabled_keys.select(:id)) + .id_not_in(available_project_keys.select(:id)) + .with_projects + end end def as_json serializer = DeployKeySerializer.new # rubocop: disable CodeReuse/Serializer - opts = { user: current_user, project: project } + opts = { user: current_user, project: project, readable_project_ids: readable_project_ids } { - enabled_keys: serializer.represent(enabled_keys.with_projects, opts), + enabled_keys: serializer.represent(enabled_keys, opts), available_project_keys: serializer.represent(available_project_keys, opts), public_keys: serializer.represent(available_public_keys, opts) } @@ -56,6 +66,26 @@ def to_partial_path def form_partial_path 'projects/deploy_keys/form' end + + private + + # Caching all readable project ids for the user that are associated with the queried deploy keys + def readable_project_ids + strong_memoize(:readable_projects_by_id) do + Set.new(user_readable_project_ids) + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def user_readable_project_ids + project_ids = (available_keys + available_project_keys + available_public_keys) + .flat_map { |deploy_key| deploy_key.deploy_keys_projects.map(&:project_id) } + .compact + .uniq + + current_user.authorized_projects(Gitlab::Access::GUEST).id_in(project_ids).pluck(:id) + end + # rubocop: enable CodeReuse/ActiveRecord end end end diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb index 9a558d12bec41..2682a47fbaaee 100644 --- a/app/serializers/deploy_key_entity.rb +++ b/app/serializers/deploy_key_entity.rb @@ -11,8 +11,7 @@ class DeployKeyEntity < Grape::Entity expose :updated_at expose :deploy_keys_projects, using: DeployKeysProjectEntity do |deploy_key| deploy_key.deploy_keys_projects.select do |deploy_key_project| - !deploy_key_project.project&.pending_delete? && - Ability.allowed?(options[:user], :read_project, deploy_key_project.project) + !deploy_key_project.project&.pending_delete? && (allowed_to_read_project?(deploy_key_project.project) || options[:user].admin?) end end expose :can_edit @@ -23,4 +22,12 @@ def can_edit Ability.allowed?(options[:user], :update_deploy_key, object) || Ability.allowed?(options[:user], :update_deploy_keys_project, object.deploy_keys_project_for(options[:project])) end + + def allowed_to_read_project?(project) + if options[:readable_project_ids] + options[:readable_project_ids].include?(project.id) + else + Ability.allowed?(options[:user], :read_project, project) + end + end end diff --git a/changelogs/unreleased/21059-optimize-deploy-keys-index-page.yml b/changelogs/unreleased/21059-optimize-deploy-keys-index-page.yml new file mode 100644 index 0000000000000..18a3b275641f1 --- /dev/null +++ b/changelogs/unreleased/21059-optimize-deploy-keys-index-page.yml @@ -0,0 +1,5 @@ +--- +title: Optimize loading the repository deploy keys page +merge_request: 20970 +author: +type: performance diff --git a/db/migrate/20191205145647_add_index_to_projects_deploy_keys_deploy_key.rb b/db/migrate/20191205145647_add_index_to_projects_deploy_keys_deploy_key.rb new file mode 100644 index 0000000000000..f9cdc226e4ded --- /dev/null +++ b/db/migrate/20191205145647_add_index_to_projects_deploy_keys_deploy_key.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexToProjectsDeployKeysDeployKey < ActiveRecord::Migration[5.2] + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + DOWNTIME = false + + def up + add_concurrent_index :deploy_keys_projects, :deploy_key_id + end + + def down + remove_concurrent_index :deploy_keys_projects, :deploy_key_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 42420e653aa90..84b9cce3c98d0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1293,6 +1293,7 @@ t.datetime "created_at" t.datetime "updated_at" t.boolean "can_push", default: false, null: false + t.index ["deploy_key_id"], name: "index_deploy_keys_projects_on_deploy_key_id" t.index ["project_id"], name: "index_deploy_keys_projects_on_project_id" end diff --git a/spec/controllers/projects/deploy_keys_controller_spec.rb b/spec/controllers/projects/deploy_keys_controller_spec.rb index 8b1ca2efab2ae..2c7c99eabf67b 100644 --- a/spec/controllers/projects/deploy_keys_controller_spec.rb +++ b/spec/controllers/projects/deploy_keys_controller_spec.rb @@ -46,17 +46,27 @@ create(:deploy_keys_project, project: project_private, deploy_key: create(:another_deploy_key)) end - before do - project2.add_developer(user) + context 'when user has access to all projects where deploy keys are used' do + before do + project2.add_developer(user) + end + + it 'returns json in a correct format' do + get :index, params: params.merge(format: :json) + + expect(json_response.keys).to match_array(%w(enabled_keys available_project_keys public_keys)) + expect(json_response['enabled_keys'].count).to eq(1) + expect(json_response['available_project_keys'].count).to eq(1) + expect(json_response['public_keys'].count).to eq(1) + end end - it 'returns json in a correct format' do - get :index, params: params.merge(format: :json) + context 'when user has no access to all projects where deploy keys are used' do + it 'returns json in a correct format' do + get :index, params: params.merge(format: :json) - expect(json_response.keys).to match_array(%w(enabled_keys available_project_keys public_keys)) - expect(json_response['enabled_keys'].count).to eq(1) - expect(json_response['available_project_keys'].count).to eq(1) - expect(json_response['public_keys'].count).to eq(1) + expect(json_response['available_project_keys'].count).to eq(0) + end end end end diff --git a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb index de58733c8ea15..b9cb60e414f45 100644 --- a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb +++ b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb @@ -5,11 +5,6 @@ describe Projects::Settings::DeployKeysPresenter do let(:project) { create(:project) } let(:user) { create(:user) } - let(:deploy_key) { create(:deploy_key, public: true) } - - let!(:deploy_keys_project) do - create(:deploy_keys_project, project: project, deploy_key: deploy_key) - end subject(:presenter) do described_class.new(project, current_user: user) @@ -20,6 +15,12 @@ end describe '#enabled_keys' do + let!(:deploy_key) { create(:deploy_key, public: true) } + + let!(:deploy_keys_project) do + create(:deploy_keys_project, project: project, deploy_key: deploy_key) + end + it 'returns currently enabled keys' do expect(presenter.enabled_keys).to eq [deploy_keys_project.deploy_key] end @@ -53,4 +54,54 @@ expect(presenter.available_project_keys_size).to eq(1) end end + + context 'prevent N + 1 queries' do + before do + create_records + + project.add_maintainer(user) + end + + def create_records + other_project = create(:project) + other_project.add_maintainer(user) + + create(:deploy_keys_project, project: project, deploy_key: create(:deploy_key)) + create(:deploy_keys_project, project: other_project, deploy_key: create(:deploy_key)) + create(:deploy_key, public: true) + end + + def execute_with_query_count + ActiveRecord::QueryRecorder.new { execute_presenter }.count + end + + def execute_presenter + described_class.new(project, current_user: user).as_json + end + + it 'returns correct counts' do + result = execute_presenter + + expect(result[:enabled_keys].size).to eq(1) + expect(result[:available_project_keys].size).to eq(1) + expect(result[:public_keys].size).to eq(1) + end + + it 'does not increase the query count' do + execute_presenter # make sure everything is cached + + count_before = execute_with_query_count + + 3.times { create_records } + + count_after = execute_with_query_count + + expect(count_after).to eq(count_before) + + result = execute_presenter + expect(result[:enabled_keys].size).to eq(4) + expect(result[:available_project_keys].size).to eq(4) + expect(result[:public_keys].size).to eq(4) + end + end end -- GitLab