Commit b6125f70 authored by Stan Hu's avatar Stan Hu

Fix fast admin counters not working when PostgreSQL has secondaries

This commit does a number of things:

1. Reduces the number of queries needed by perform a single query to get all
the tuples for the relevant rows.

2. Uses a transaction to query the tuple counts to ensure that the data
is retrieved from the primary.

Closes #46742
parent 50c8ed2b
class Admin::DashboardController < Admin::ApplicationController class Admin::DashboardController < Admin::ApplicationController
include CountHelper include CountHelper
COUNTED_ITEMS = [Project, User, Group, ForkedProjectLink, Issue, MergeRequest,
Note, Snippet, Key, Milestone].freeze
def index def index
@counts = Gitlab::Database::Count.approximate_counts(COUNTED_ITEMS)
@projects = Project.order_id_desc.without_deleted.with_route.limit(10) @projects = Project.order_id_desc.without_deleted.with_route.limit(10)
@users = User.order_id_desc.limit(10) @users = User.order_id_desc.limit(10)
@groups = Group.order_id_desc.with_route.limit(10) @groups = Group.order_id_desc.with_route.limit(10)
......
module CountHelper module CountHelper
def approximate_count_with_delimiters(model) def approximate_count_with_delimiters(count_data, model)
number_with_delimiter(Gitlab::Database::Count.approximate_count(model)) count = count_data[model]
raise "Missing model #{model} from count data" unless count
number_with_delimiter(count)
end end
end end
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
= link_to admin_projects_path do = link_to admin_projects_path do
%h3.text-center %h3.text-center
Projects: Projects:
= approximate_count_with_delimiters(Project) = approximate_count_with_delimiters(@counts, Project)
%hr %hr
= link_to('New project', new_project_path, class: "btn btn-new") = link_to('New project', new_project_path, class: "btn btn-new")
.col-sm-4 .col-sm-4
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
= link_to admin_users_path do = link_to admin_users_path do
%h3.text-center %h3.text-center
Users: Users:
= approximate_count_with_delimiters(User) = approximate_count_with_delimiters(@counts, User)
= render_if_exists 'users_statistics' = render_if_exists 'users_statistics'
%hr %hr
= link_to 'New user', new_admin_user_path, class: "btn btn-new" = link_to 'New user', new_admin_user_path, class: "btn btn-new"
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
= link_to admin_groups_path do = link_to admin_groups_path do
%h3.text-center %h3.text-center
Groups: Groups:
= approximate_count_with_delimiters(Group) = approximate_count_with_delimiters(@counts, Group)
%hr %hr
= link_to 'New group', new_admin_group_path, class: "btn btn-new" = link_to 'New group', new_admin_group_path, class: "btn btn-new"
.row .row
...@@ -42,31 +42,31 @@ ...@@ -42,31 +42,31 @@
%p %p
Forks Forks
%span.light.float-right %span.light.float-right
= approximate_count_with_delimiters(ForkedProjectLink) = approximate_count_with_delimiters(@counts, ForkedProjectLink)
%p %p
Issues Issues
%span.light.float-right %span.light.float-right
= approximate_count_with_delimiters(Issue) = approximate_count_with_delimiters(@counts, Issue)
%p %p
Merge Requests Merge Requests
%span.light.float-right %span.light.float-right
= approximate_count_with_delimiters(MergeRequest) = approximate_count_with_delimiters(@counts, MergeRequest)
%p %p
Notes Notes
%span.light.float-right %span.light.float-right
= approximate_count_with_delimiters(Note) = approximate_count_with_delimiters(@counts, Note)
%p %p
Snippets Snippets
%span.light.float-right %span.light.float-right
= approximate_count_with_delimiters(Snippet) = approximate_count_with_delimiters(@counts, Snippet)
%p %p
SSH Keys SSH Keys
%span.light.float-right %span.light.float-right
= approximate_count_with_delimiters(Key) = approximate_count_with_delimiters(@counts, Key)
%p %p
Milestones Milestones
%span.light.float-right %span.light.float-right
= approximate_count_with_delimiters(Milestone) = approximate_count_with_delimiters(@counts, Milestone)
%p %p
Active Users Active Users
%span.light.float-right %span.light.float-right
......
---
title: Fix admin counters not working when PostgreSQL has secondaries
merge_request:
author:
type: fixed
...@@ -17,31 +17,69 @@ module Gitlab ...@@ -17,31 +17,69 @@ module Gitlab
].freeze ].freeze
end end
def self.approximate_count(model) # Takes in an array of models and returns a Hash for the approximate
return model.count unless Gitlab::Database.postgresql? # counts for them. If the model's table has not been vacuumed or
# analyzed recently, simply run the Model.count to get the data.
#
# @param [Array]
# @return [Hash] of Model -> count mapping
def self.approximate_counts(models)
table_to_model_map = models.each_with_object({}) do |model, hash|
hash[model.table_name] = model
end
table_names = table_to_model_map.keys
counts_by_table_name = Gitlab::Database.postgresql? ? reltuples_from_recently_updated(table_names) : {}
execute_estimate_if_updated_recently(model) || model.count # Convert table -> count to Model -> count
counts_by_model = counts_by_table_name.each_with_object({}) do |pair, hash|
model = table_to_model_map[pair.first]
hash[model] = pair.second
end end
def self.execute_estimate_if_updated_recently(model) missing_tables = table_names - counts_by_table_name.keys
ActiveRecord::Base.connection.select_value(postgresql_estimate_query(model)).to_i if reltuples_updated_recently?(model)
rescue *CONNECTION_ERRORS missing_tables.each do |table|
model = table_to_model_map[table]
counts_by_model[model] = model.count
end end
def self.reltuples_updated_recently?(model) counts_by_model
time = "to_timestamp(#{1.hour.ago.to_i})" end
query = <<~SQL
SELECT 1 FROM pg_stat_user_tables WHERE relname = '#{model.table_name}' AND # Returns a hash of the table names that have recently updated tuples.
(last_vacuum > #{time} OR last_autovacuum > #{time} OR last_analyze > #{time} OR last_autoanalyze > #{time}) #
SQL # @param [Array] table names
# @returns [Hash] Table name to count mapping (e.g. { 'projects' => 5, 'users' => 100 })
def self.reltuples_from_recently_updated(table_names)
query = postgresql_estimate_query(table_names)
rows = []
# Querying tuple stats only works on the primary. Due to load
# balancing, we need to ensure this query hits the load balancer. The
# easiest way to do this is to start a transaction.
ActiveRecord::Base.transaction do
rows = ActiveRecord::Base.connection.select_all(query)
end
ActiveRecord::Base.connection.select_all(query).count > 0 rows.each_with_object({}) { |row, data| data[row['table_name']] = row['estimate'].to_i }
rescue *CONNECTION_ERRORS rescue *CONNECTION_ERRORS
false {}
end end
def self.postgresql_estimate_query(model) # Generates the PostgreSQL query to return the tuples for tables
"SELECT reltuples::bigint AS estimate FROM pg_class where relname = '#{model.table_name}'" # that have been vacuumed or analyzed in the last hour.
#
# @param [Array] table names
# @returns [Hash] Table name to count mapping (e.g. { 'projects' => 5, 'users' => 100 })
def self.postgresql_estimate_query(table_names)
time = "to_timestamp(#{1.hour.ago.to_i})"
<<~SQL
SELECT pg_class.relname AS table_name, reltuples::bigint AS estimate FROM pg_class
LEFT JOIN pg_stat_user_tables ON pg_class.relname = pg_stat_user_tables.relname
WHERE pg_class.relname IN (#{table_names.map { |table| "'#{table}'" }.join(',')})
AND (last_vacuum > #{time} OR last_autovacuum > #{time} OR last_analyze > #{time} OR last_autoanalyze > #{time})
SQL
end end
end end
end end
......
...@@ -3,59 +3,68 @@ require 'spec_helper' ...@@ -3,59 +3,68 @@ require 'spec_helper'
describe Gitlab::Database::Count do describe Gitlab::Database::Count do
before do before do
create_list(:project, 3) create_list(:project, 3)
create(:identity)
end end
describe '.execute_estimate_if_updated_recently', :postgresql do let(:models) { [Project, Identity] }
context 'when reltuples have not been updated' do
before do
expect(described_class).to receive(:reltuples_updated_recently?).and_return(false)
end
it 'returns nil' do describe '.approximate_counts' do
expect(described_class.execute_estimate_if_updated_recently(Project)).to be nil context 'with MySQL' do
end context 'when reltuples have not been updated' do
end it 'counts all models the normal way' do
expect(Gitlab::Database).to receive(:postgresql?).and_return(false)
context 'when reltuples have been updated' do expect(Project).to receive(:count).and_call_original
before do expect(Identity).to receive(:count).and_call_original
ActiveRecord::Base.connection.execute('ANALYZE projects')
end
it 'calls postgresql_estimate_query' do expect(described_class.approximate_counts(models)).to eq({ Project => 3, Identity => 1 })
expect(described_class).to receive(:postgresql_estimate_query).with(Project).and_call_original
expect(described_class.execute_estimate_if_updated_recently(Project)).to eq(3)
end end
end end
end end
describe '.approximate_count' do context 'with PostgreSQL', :postgresql do
context 'when reltuples have not been updated' do describe 'when reltuples have not been updated' do
it 'counts all projects the normal way' do it 'counts all models the normal way' do
allow(described_class).to receive(:reltuples_updated_recently?).and_return(false) expect(described_class).to receive(:reltuples_from_recently_updated).with(%w(projects identities)).and_return({})
expect(Project).to receive(:count).and_call_original expect(Project).to receive(:count).and_call_original
expect(described_class.approximate_count(Project)).to eq(3) expect(Identity).to receive(:count).and_call_original
expect(described_class.approximate_counts(models)).to eq({ Project => 3, Identity => 1 })
end end
end end
context 'no permission' do describe 'no permission' do
it 'falls back to standard query' do it 'falls back to standard query' do
allow(described_class).to receive(:reltuples_updated_recently?).and_raise(PG::InsufficientPrivilege) allow(described_class).to receive(:postgresql_estimate_query).and_raise(PG::InsufficientPrivilege)
expect(Project).to receive(:count).and_call_original expect(Project).to receive(:count).and_call_original
expect(described_class.approximate_count(Project)).to eq(3) expect(Identity).to receive(:count).and_call_original
expect(described_class.approximate_counts(models)).to eq({ Project => 3, Identity => 1 })
end
end
describe 'when some reltuples have been updated' do
it 'counts projects in the fast way' do
expect(described_class).to receive(:reltuples_from_recently_updated).with(%w(projects identities)).and_return({ 'projects' => 3 })
expect(Project).not_to receive(:count).and_call_original
expect(Identity).to receive(:count).and_call_original
expect(described_class.approximate_counts(models)).to eq({ Project => 3, Identity => 1 })
end end
end end
describe 'when reltuples have been updated', :postgresql do describe 'when all reltuples have been updated' do
before do before do
ActiveRecord::Base.connection.execute('ANALYZE projects') ActiveRecord::Base.connection.execute('ANALYZE projects')
ActiveRecord::Base.connection.execute('ANALYZE identities')
end end
it 'counts all projects in the fast way' do it 'counts models with the standard way' do
expect(described_class).to receive(:postgresql_estimate_query).with(Project).and_call_original expect(Project).not_to receive(:count)
expect(Identity).not_to receive(:count)
expect(described_class.approximate_count(Project)).to eq(3) expect(described_class.approximate_counts(models)).to eq({ Project => 3, Identity => 1 })
end
end end
end end
end end
......
...@@ -4,6 +4,11 @@ describe 'admin/dashboard/index.html.haml' do ...@@ -4,6 +4,11 @@ describe 'admin/dashboard/index.html.haml' do
include Devise::Test::ControllerHelpers include Devise::Test::ControllerHelpers
before do before do
counts = Admin::DashboardController::COUNTED_ITEMS.each_with_object({}) do |item, hash|
hash[item] = 100
end
assign(:counts, counts)
assign(:projects, create_list(:project, 1)) assign(:projects, create_list(:project, 1))
assign(:users, create_list(:user, 1)) assign(:users, create_list(:user, 1))
assign(:groups, create_list(:group, 1)) assign(:groups, create_list(:group, 1))
......
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