Commit ab126ec6 authored by Stan Hu's avatar Stan Hu Committed by Mayra Cabrera

Use CTE optimization fence for loading projects in dashboard

Certain users experienced Error 500s when loading projects from the
dashboard because the PostgreSQL query planner attempted to scan all
projects rather than just the user's authorized projects. This would
cause the query to hit a statement timeout.

To fix this, we add support for a CTE optimization fence to load
authorized projects first, which can be optionally used by
`ProjectsFinder` via the `use_cte` parameter. To be safe, we only enable
it for the finder call that loads the list of projects behind the
`use_cte_for_projects_finder` feature flag.

Closes https://gitlab.com/gitlab-org/gitlab/issues/198440
parent ec9a0616
...@@ -66,6 +66,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController ...@@ -66,6 +66,8 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
@total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute
@total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
finder_params[:use_cte] = Feature.enabled?(:use_cte_for_projects_finder, default_enabled: true)
projects = ProjectsFinder projects = ProjectsFinder
.new(params: finder_params, current_user: current_user) .new(params: finder_params, current_user: current_user)
.execute .execute
......
...@@ -44,6 +44,8 @@ class ProjectsFinder < UnionFinder ...@@ -44,6 +44,8 @@ class ProjectsFinder < UnionFinder
init_collection init_collection
end end
use_cte = params.delete(:use_cte)
collection = Project.wrap_authorized_projects_with_cte(collection) if use_cte
collection = filter_projects(collection) collection = filter_projects(collection)
sort(collection) sort(collection)
end end
...@@ -177,7 +179,7 @@ class ProjectsFinder < UnionFinder ...@@ -177,7 +179,7 @@ class ProjectsFinder < UnionFinder
end end
def sort(items) def sort(items)
params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.order_id_desc params[:sort].present? ? items.sort_by_attribute(params[:sort]) : items.projects_order_id_desc
end end
def by_archived(projects) def by_archived(projects)
......
...@@ -397,6 +397,8 @@ class Project < ApplicationRecord ...@@ -397,6 +397,8 @@ class Project < ApplicationRecord
scope :sorted_by_stars_desc, -> { reorder(star_count: :desc) } scope :sorted_by_stars_desc, -> { reorder(star_count: :desc) }
scope :sorted_by_stars_asc, -> { reorder(star_count: :asc) } scope :sorted_by_stars_asc, -> { reorder(star_count: :asc) }
scope :sorted_by_name_asc_limited, ->(limit) { reorder(name: :asc).limit(limit) } scope :sorted_by_name_asc_limited, ->(limit) { reorder(name: :asc).limit(limit) }
# Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name
scope :projects_order_id_desc, -> { reorder("#{table_name}.id DESC") }
scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) }
scope :personal, ->(user) { where(namespace_id: user.namespace_id) } scope :personal, ->(user) { where(namespace_id: user.namespace_id) }
...@@ -543,6 +545,11 @@ class Project < ApplicationRecord ...@@ -543,6 +545,11 @@ class Project < ApplicationRecord
) )
end end
def self.wrap_authorized_projects_with_cte(collection)
cte = Gitlab::SQL::CTE.new(:authorized_projects, collection)
Project.with(cte.to_arel).from(cte.alias_to(Project.arel_table))
end
scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') } scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') }
scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) } scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) }
......
---
title: Use CTE optimization fence for loading projects in dashboard
merge_request: 23754
author:
type: performance
...@@ -28,210 +28,227 @@ describe ProjectsFinder, :do_not_mock_admin_mode do ...@@ -28,210 +28,227 @@ describe ProjectsFinder, :do_not_mock_admin_mode do
let(:params) { {} } let(:params) { {} }
let(:current_user) { user } let(:current_user) { user }
let(:project_ids_relation) { nil } let(:project_ids_relation) { nil }
let(:finder) { described_class.new(params: params, current_user: current_user, project_ids_relation: project_ids_relation) } let(:use_cte) { true }
let(:finder) { described_class.new(params: params.merge(use_cte: use_cte), current_user: current_user, project_ids_relation: project_ids_relation) }
subject { finder.execute } subject { finder.execute }
describe 'without a user' do shared_examples 'ProjectFinder#execute examples' do
let(:current_user) { nil } describe 'without a user' do
let(:current_user) { nil }
it { is_expected.to eq([public_project]) } it { is_expected.to eq([public_project]) }
end
describe 'with a user' do
describe 'without private projects' do
it { is_expected.to match_array([public_project, internal_project]) }
end end
describe 'with private projects' do describe 'with a user' do
before do describe 'without private projects' do
private_project.add_maintainer(user) it { is_expected.to match_array([public_project, internal_project]) }
end end
it { is_expected.to match_array([public_project, internal_project, private_project]) } describe 'with private projects' do
before do
private_project.add_maintainer(user)
end
it { is_expected.to match_array([public_project, internal_project, private_project]) }
end
end end
end
describe 'with project_ids_relation' do describe 'with project_ids_relation' do
let(:project_ids_relation) { Project.where(id: internal_project.id) } let(:project_ids_relation) { Project.where(id: internal_project.id) }
it { is_expected.to eq([internal_project]) } it { is_expected.to eq([internal_project]) }
end end
describe 'with id_after' do describe 'with id_after' do
context 'only returns projects with a project id greater than given' do context 'only returns projects with a project id greater than given' do
let(:params) { { id_after: internal_project.id }} let(:params) { { id_after: internal_project.id }}
it { is_expected.to eq([public_project]) } it { is_expected.to eq([public_project]) }
end
end end
end
describe 'with id_before' do describe 'with id_before' do
context 'only returns projects with a project id less than given' do context 'only returns projects with a project id less than given' do
let(:params) { { id_before: public_project.id }} let(:params) { { id_before: public_project.id }}
it { is_expected.to eq([internal_project]) } it { is_expected.to eq([internal_project]) }
end
end end
end
describe 'with both id_before and id_after' do describe 'with both id_before and id_after' do
context 'only returns projects with a project id less than given' do context 'only returns projects with a project id less than given' do
let!(:projects) { create_list(:project, 5, :public) } let!(:projects) { create_list(:project, 5, :public) }
let(:params) { { id_after: projects.first.id, id_before: projects.last.id }} let(:params) { { id_after: projects.first.id, id_before: projects.last.id }}
it { is_expected.to contain_exactly(*projects[1..-2]) } it { is_expected.to contain_exactly(*projects[1..-2]) }
end
end end
end
describe 'filter by visibility_level' do describe 'filter by visibility_level' do
before do before do
private_project.add_maintainer(user) private_project.add_maintainer(user)
end end
context 'private' do context 'private' do
let(:params) { { visibility_level: Gitlab::VisibilityLevel::PRIVATE } } let(:params) { { visibility_level: Gitlab::VisibilityLevel::PRIVATE } }
it { is_expected.to eq([private_project]) } it { is_expected.to eq([private_project]) }
end end
context 'internal' do context 'internal' do
let(:params) { { visibility_level: Gitlab::VisibilityLevel::INTERNAL } } let(:params) { { visibility_level: Gitlab::VisibilityLevel::INTERNAL } }
it { is_expected.to eq([internal_project]) } it { is_expected.to eq([internal_project]) }
end
context 'public' do
let(:params) { { visibility_level: Gitlab::VisibilityLevel::PUBLIC } }
it { is_expected.to eq([public_project]) }
end
end end
context 'public' do describe 'filter by tags' do
let(:params) { { visibility_level: Gitlab::VisibilityLevel::PUBLIC } } before do
public_project.tag_list.add('foo')
public_project.save!
end
let(:params) { { tag: 'foo' } }
it { is_expected.to eq([public_project]) } it { is_expected.to eq([public_project]) }
end end
end
describe 'filter by tags' do describe 'filter by personal' do
before do let!(:personal_project) { create(:project, namespace: user.namespace) }
public_project.tag_list.add('foo') let(:params) { { personal: true } }
public_project.save!
it { is_expected.to eq([personal_project]) }
end end
let(:params) { { tag: 'foo' } } describe 'filter by search' do
let(:params) { { search: 'C' } }
it { is_expected.to eq([public_project]) } it { is_expected.to eq([public_project]) }
end end
describe 'filter by personal' do describe 'filter by name for backward compatibility' do
let!(:personal_project) { create(:project, namespace: user.namespace) } let(:params) { { name: 'C' } }
let(:params) { { personal: true } }
it { is_expected.to eq([personal_project]) } it { is_expected.to eq([public_project]) }
end end
describe 'filter by search' do describe 'filter by archived' do
let(:params) { { search: 'C' } } let!(:archived_project) { create(:project, :public, :archived, name: 'E', path: 'E') }
it { is_expected.to eq([public_project]) } context 'non_archived=true' do
end let(:params) { { non_archived: true } }
describe 'filter by name for backward compatibility' do it { is_expected.to match_array([public_project, internal_project]) }
let(:params) { { name: 'C' } } end
it { is_expected.to eq([public_project]) } context 'non_archived=false' do
end let(:params) { { non_archived: false } }
describe 'filter by archived' do it { is_expected.to match_array([public_project, internal_project, archived_project]) }
let!(:archived_project) { create(:project, :public, :archived, name: 'E', path: 'E') } end
context 'non_archived=true' do describe 'filter by archived only' do
let(:params) { { non_archived: true } } let(:params) { { archived: 'only' } }
it { is_expected.to match_array([public_project, internal_project]) } it { is_expected.to eq([archived_project]) }
end end
context 'non_archived=false' do describe 'filter by archived for backward compatibility' do
let(:params) { { non_archived: false } } let(:params) { { archived: false } }
it { is_expected.to match_array([public_project, internal_project, archived_project]) } it { is_expected.to match_array([public_project, internal_project]) }
end
end end
describe 'filter by archived only' do describe 'filter by trending' do
let(:params) { { archived: 'only' } } let!(:trending_project) { create(:trending_project, project: public_project) }
let(:params) { { trending: true } }
it { is_expected.to eq([archived_project]) } it { is_expected.to eq([public_project]) }
end end
describe 'filter by archived for backward compatibility' do describe 'filter by owned' do
let(:params) { { archived: false } } let(:params) { { owned: true } }
let!(:owned_project) { create(:project, :private, namespace: current_user.namespace) }
it { is_expected.to match_array([public_project, internal_project]) } it { is_expected.to eq([owned_project]) }
end end
end
describe 'filter by trending' do describe 'filter by non_public' do
let!(:trending_project) { create(:trending_project, project: public_project) } let(:params) { { non_public: true } }
let(:params) { { trending: true } }
it { is_expected.to eq([public_project]) }
end
describe 'filter by owned' do before do
let(:params) { { owned: true } } private_project.add_developer(current_user)
let!(:owned_project) { create(:project, :private, namespace: current_user.namespace) } end
it { is_expected.to eq([owned_project]) } it { is_expected.to eq([private_project]) }
end end
describe 'filter by non_public' do describe 'filter by starred' do
let(:params) { { non_public: true } } let(:params) { { starred: true } }
before do before do
private_project.add_developer(current_user) current_user.toggle_star(public_project)
end end
it { is_expected.to eq([private_project]) } it { is_expected.to eq([public_project]) }
end
describe 'filter by starred' do it 'returns only projects the user has access to' do
let(:params) { { starred: true } } current_user.toggle_star(private_project)
before do is_expected.to eq([public_project])
current_user.toggle_star(public_project) expect(subject.count).to eq(1)
expect(subject.limit(1000).count).to eq(1)
end
end end
it { is_expected.to eq([public_project]) } describe 'filter by without_deleted' do
let(:params) { { without_deleted: true } }
let!(:pending_delete_project) { create(:project, :public, pending_delete: true) }
it 'returns only projects the user has access to' do it { is_expected.to match_array([public_project, internal_project]) }
current_user.toggle_star(private_project) end
is_expected.to eq([public_project]) describe 'sorting' do
let(:params) { { sort: 'name_asc' } }
it { is_expected.to eq([internal_project, public_project]) }
end end
end
describe 'filter by without_deleted' do describe 'with admin user' do
let(:params) { { without_deleted: true } } let(:user) { create(:admin) }
let!(:pending_delete_project) { create(:project, :public, pending_delete: true) }
it { is_expected.to match_array([public_project, internal_project]) } context 'admin mode enabled' do
end before do
enable_admin_mode!(current_user)
end
describe 'sorting' do it { is_expected.to match_array([public_project, internal_project, private_project, shared_project]) }
let(:params) { { sort: 'name_asc' } } end
it { is_expected.to eq([internal_project, public_project]) } context 'admin mode disabled' do
it { is_expected.to match_array([public_project, internal_project]) }
end
end
end end
describe 'with admin user' do describe 'without CTE flag enabled' do
let(:user) { create(:admin) } let(:use_cte) { false }
context 'admin mode enabled' do it_behaves_like 'ProjectFinder#execute examples'
before do end
enable_admin_mode!(current_user)
end
it { is_expected.to match_array([public_project, internal_project, private_project, shared_project]) } describe 'with CTE flag enabled' do
end let(:use_cte) { true }
context 'admin mode disabled' do it_behaves_like 'ProjectFinder#execute examples'
it { is_expected.to match_array([public_project, internal_project]) }
end
end end
end end
end end
...@@ -3780,6 +3780,25 @@ describe Project do ...@@ -3780,6 +3780,25 @@ describe Project do
end end
end end
describe '.wrap_authorized_projects_with_cte' do
let!(:user) { create(:user) }
let!(:private_project) do
create(:project, :private, creator: user, namespace: user.namespace)
end
let!(:public_project) { create(:project, :public) }
let(:projects) { described_class.all.public_or_visible_to_user(user) }
subject { described_class.wrap_authorized_projects_with_cte(projects) }
it 'wrapped query matches original' do
expect(subject.to_sql).to match(/^WITH "authorized_projects" AS/)
expect(subject).to match_array(projects)
end
end
describe '#pages_available?' do describe '#pages_available?' do
let(:project) { create(:project, group: group) } let(:project) { create(:project, group: group) }
......
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