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
execute_estimate_if_updated_recently(model) || model.count table_names = table_to_model_map.keys
end counts_by_table_name = Gitlab::Database.postgresql? ? reltuples_from_recently_updated(table_names) : {}
def self.execute_estimate_if_updated_recently(model) # Convert table -> count to Model -> count
ActiveRecord::Base.connection.select_value(postgresql_estimate_query(model)).to_i if reltuples_updated_recently?(model) counts_by_model = counts_by_table_name.each_with_object({}) do |pair, hash|
rescue *CONNECTION_ERRORS model = table_to_model_map[pair.first]
hash[model] = pair.second
end
missing_tables = table_names - counts_by_table_name.keys
missing_tables.each do |table|
model = table_to_model_map[table]
counts_by_model[model] = model.count
end
counts_by_model
end end
def self.reltuples_updated_recently?(model) # Returns a hash of the table names that have recently updated tuples.
time = "to_timestamp(#{1.hour.ago.to_i})" #
query = <<~SQL # @param [Array] table names
SELECT 1 FROM pg_stat_user_tables WHERE relname = '#{model.table_name}' AND # @returns [Hash] Table name to count mapping (e.g. { 'projects' => 5, 'users' => 100 })
(last_vacuum > #{time} OR last_autovacuum > #{time} OR last_analyze > #{time} OR last_autoanalyze > #{time}) def self.reltuples_from_recently_updated(table_names)
SQL query = postgresql_estimate_query(table_names)
rows = []
ActiveRecord::Base.connection.select_all(query).count > 0 # 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
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 end
expect(described_class.execute_estimate_if_updated_recently(Project)).to eq(3)
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 end
end
describe 'when reltuples have been updated', :postgresql do describe 'when some reltuples have been updated' do
before do it 'counts projects in the fast way' do
ActiveRecord::Base.connection.execute('ANALYZE projects') 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
it 'counts all projects in the fast way' do describe 'when all reltuples have been updated' do
expect(described_class).to receive(:postgresql_estimate_query).with(Project).and_call_original before do
ActiveRecord::Base.connection.execute('ANALYZE projects')
ActiveRecord::Base.connection.execute('ANALYZE identities')
end
it 'counts models with the standard way' do
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