Commit f87fcda9 authored by Markus Koller's avatar Markus Koller Committed by Dmitry Gruzd

Avoid loading user objects when bulk updating todos

parent b53feea5
...@@ -149,8 +149,8 @@ class Todo < ApplicationRecord ...@@ -149,8 +149,8 @@ class Todo < ApplicationRecord
.order('todos.created_at') .order('todos.created_at')
end end
def pluck_user_id def distinct_user_ids
pluck(:user_id) distinct.pluck(:user_id)
end end
# Count todos grouped by user_id and state, using an UNION query # Count todos grouped by user_id and state, using an UNION query
......
...@@ -33,6 +33,8 @@ class User < ApplicationRecord ...@@ -33,6 +33,8 @@ class User < ApplicationRecord
BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval' BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'
COUNT_CACHE_VALIDITY_PERIOD = 24.hours
add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) } add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) }
add_authentication_token_field :feed_token add_authentication_token_field :feed_token
add_authentication_token_field :static_object_token add_authentication_token_field :static_object_token
...@@ -1621,36 +1623,32 @@ class User < ApplicationRecord ...@@ -1621,36 +1623,32 @@ class User < ApplicationRecord
@global_notification_setting @global_notification_setting
end end
def count_cache_validity_period
24.hours
end
def assigned_open_merge_requests_count(force: false) def assigned_open_merge_requests_count(force: false)
Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: count_cache_validity_period) do Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count
end end
end end
def review_requested_open_merge_requests_count(force: false) def review_requested_open_merge_requests_count(force: false)
Rails.cache.fetch(['users', id, 'review_requested_open_merge_requests_count'], force: force, expires_in: count_cache_validity_period) do Rails.cache.fetch(['users', id, 'review_requested_open_merge_requests_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
MergeRequestsFinder.new(self, reviewer_id: id, state: 'opened', non_archived: true).execute.count MergeRequestsFinder.new(self, reviewer_id: id, state: 'opened', non_archived: true).execute.count
end end
end end
def assigned_open_issues_count(force: false) def assigned_open_issues_count(force: false)
Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: count_cache_validity_period) do Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count
end end
end end
def todos_done_count(force: false) def todos_done_count(force: false)
Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: count_cache_validity_period) do Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
TodosFinder.new(self, state: :done).execute.count TodosFinder.new(self, state: :done).execute.count
end end
end end
def todos_pending_count(force: false) def todos_pending_count(force: false)
Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: count_cache_validity_period) do Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
TodosFinder.new(self, state: :pending).execute.count TodosFinder.new(self, state: :pending).execute.count
end end
end end
......
...@@ -43,11 +43,11 @@ class TodoService ...@@ -43,11 +43,11 @@ class TodoService
# updates the todo counts for those users. # updates the todo counts for those users.
# #
def destroy_target(target) def destroy_target(target)
todo_users = User.for_todos(target.todos).to_a todo_user_ids = target.todos.distinct_user_ids
yield target yield target
Users::UpdateTodoCountCacheService.new(todo_users).execute if todo_users.present? Users::UpdateTodoCountCacheService.new(todo_user_ids).execute if todo_user_ids.present?
end end
# When we reassign an assignable object (issuable, alert) we should: # When we reassign an assignable object (issuable, alert) we should:
...@@ -224,7 +224,7 @@ class TodoService ...@@ -224,7 +224,7 @@ class TodoService
return if users.empty? return if users.empty?
users_with_pending_todos = pending_todos(users, attributes).pluck_user_id users_with_pending_todos = pending_todos(users, attributes).distinct_user_ids
users.reject! { |user| users_with_pending_todos.include?(user.id) && Feature.disabled?(:multiple_todos, user) } users.reject! { |user| users_with_pending_todos.include?(user.id) && Feature.disabled?(:multiple_todos, user) }
todos = users.map do |user| todos = users.map do |user|
...@@ -234,7 +234,7 @@ class TodoService ...@@ -234,7 +234,7 @@ class TodoService
Todo.create(attributes.merge(user_id: user.id)) Todo.create(attributes.merge(user_id: user.id))
end end
Users::UpdateTodoCountCacheService.new(users).execute Users::UpdateTodoCountCacheService.new(users.map(&:id)).execute
todos todos
end end
......
...@@ -4,31 +4,34 @@ module Users ...@@ -4,31 +4,34 @@ module Users
class UpdateTodoCountCacheService < BaseService class UpdateTodoCountCacheService < BaseService
QUERY_BATCH_SIZE = 10 QUERY_BATCH_SIZE = 10
attr_reader :users attr_reader :user_ids
# users - An array of User objects # user_ids - An array of User IDs
def initialize(users) def initialize(user_ids)
@users = users @user_ids = user_ids
end end
def execute def execute
users.each_slice(QUERY_BATCH_SIZE) do |users_batch| user_ids.each_slice(QUERY_BATCH_SIZE) do |user_ids_batch|
todo_counts = Todo.for_user(users_batch).count_grouped_by_user_id_and_state todo_counts = Todo.for_user(user_ids_batch).count_grouped_by_user_id_and_state
users_batch.each do |user| user_ids_batch.each do |user_id|
update_count_cache(user, todo_counts, :done) update_count_cache(user_id, todo_counts, :done)
update_count_cache(user, todo_counts, :pending) update_count_cache(user_id, todo_counts, :pending)
end end
end end
end end
private private
def update_count_cache(user, todo_counts, state) def update_count_cache(user_id, todo_counts, state)
count = todo_counts.fetch([user.id, state.to_s], 0) count = todo_counts.fetch([user_id, state.to_s], 0)
expiration_time = user.count_cache_validity_period
Rails.cache.write(['users', user.id, "todos_#{state}_count"], count, expires_in: expiration_time) Rails.cache.write(
['users', user_id, "todos_#{state}_count"],
count,
expires_in: User::COUNT_CACHE_VALIDITY_PERIOD
)
end end
end end
end end
---
title: Avoid loading user objects when bulk updating todos
merge_request: 59909
author:
type: performance
...@@ -452,11 +452,15 @@ RSpec.describe Todo do ...@@ -452,11 +452,15 @@ RSpec.describe Todo do
end end
end end
describe '.pluck_user_id' do describe '.distinct_user_ids' do
subject { described_class.pluck_user_id } subject { described_class.distinct_user_ids }
let_it_be(:todo) { create(:todo) } let_it_be(:user1) { create(:user) }
let_it_be(:user2) { create(:user) }
let_it_be(:todo) { create(:todo, user: user1) }
let_it_be(:todo) { create(:todo, user: user1) }
let_it_be(:todo) { create(:todo, user: user2) }
it { is_expected.to eq([todo.user_id]) } it { is_expected.to contain_exactly(user1.id, user2.id) }
end end
end end
...@@ -20,6 +20,10 @@ RSpec.describe User do ...@@ -20,6 +20,10 @@ RSpec.describe User do
it { is_expected.to include_module(AsyncDeviseEmail) } it { is_expected.to include_module(AsyncDeviseEmail) }
end end
describe 'constants' do
it { expect(described_class::COUNT_CACHE_VALIDITY_PERIOD).to be_a(Integer) }
end
describe 'delegations' do describe 'delegations' do
it { is_expected.to delegate_method(:path).to(:namespace).with_prefix } it { is_expected.to delegate_method(:path).to(:namespace).with_prefix }
......
...@@ -348,7 +348,7 @@ RSpec.describe TodoService do ...@@ -348,7 +348,7 @@ RSpec.describe TodoService do
create(:todo, state: :pending, target: issue, user: author, author: author, project: issue.project) create(:todo, state: :pending, target: issue, user: author, author: author, project: issue.project)
create(:todo, state: :done, target: issue, user: assignee, author: assignee, project: issue.project) create(:todo, state: :done, target: issue, user: assignee, author: assignee, project: issue.project)
expect_next(Users::UpdateTodoCountCacheService, [author, assignee]).to receive(:execute) expect_next(Users::UpdateTodoCountCacheService, [author.id, assignee.id]).to receive(:execute)
service.destroy_target(issue) { issue.destroy! } service.destroy_target(issue) { issue.destroy! }
end end
...@@ -1094,7 +1094,7 @@ RSpec.describe TodoService do ...@@ -1094,7 +1094,7 @@ RSpec.describe TodoService do
it 'updates cached counts when a todo is created' do it 'updates cached counts when a todo is created' do
issue = create(:issue, project: project, assignees: [john_doe], author: author) issue = create(:issue, project: project, assignees: [john_doe], author: author)
expect_next(Users::UpdateTodoCountCacheService, [john_doe]).to receive(:execute) expect_next(Users::UpdateTodoCountCacheService, [john_doe.id]).to receive(:execute)
service.new_issue(issue, author) service.new_issue(issue, author)
end end
......
...@@ -14,13 +14,21 @@ RSpec.describe Users::UpdateTodoCountCacheService do ...@@ -14,13 +14,21 @@ RSpec.describe Users::UpdateTodoCountCacheService do
let_it_be(:todo5) { create(:todo, user: user2, state: :pending) } let_it_be(:todo5) { create(:todo, user: user2, state: :pending) }
let_it_be(:todo6) { create(:todo, user: user2, state: :pending) } let_it_be(:todo6) { create(:todo, user: user2, state: :pending) }
def execute_all
described_class.new([user1.id, user2.id]).execute
end
def execute_single
described_class.new([user1.id]).execute
end
it 'updates the todos_counts for users', :use_clean_rails_memory_store_caching do it 'updates the todos_counts for users', :use_clean_rails_memory_store_caching do
Rails.cache.write(['users', user1.id, 'todos_done_count'], 0) Rails.cache.write(['users', user1.id, 'todos_done_count'], 0)
Rails.cache.write(['users', user1.id, 'todos_pending_count'], 0) Rails.cache.write(['users', user1.id, 'todos_pending_count'], 0)
Rails.cache.write(['users', user2.id, 'todos_done_count'], 0) Rails.cache.write(['users', user2.id, 'todos_done_count'], 0)
Rails.cache.write(['users', user2.id, 'todos_pending_count'], 0) Rails.cache.write(['users', user2.id, 'todos_pending_count'], 0)
expect { described_class.new([user1, user2]).execute } expect { execute_all }
.to change(user1, :todos_done_count).from(0).to(2) .to change(user1, :todos_done_count).from(0).to(2)
.and change(user1, :todos_pending_count).from(0).to(1) .and change(user1, :todos_pending_count).from(0).to(1)
.and change(user2, :todos_done_count).from(0).to(1) .and change(user2, :todos_done_count).from(0).to(1)
...@@ -28,7 +36,7 @@ RSpec.describe Users::UpdateTodoCountCacheService do ...@@ -28,7 +36,7 @@ RSpec.describe Users::UpdateTodoCountCacheService do
Todo.delete_all Todo.delete_all
expect { described_class.new([user1, user2]).execute } expect { execute_all }
.to change(user1, :todos_done_count).from(2).to(0) .to change(user1, :todos_done_count).from(2).to(0)
.and change(user1, :todos_pending_count).from(1).to(0) .and change(user1, :todos_pending_count).from(1).to(0)
.and change(user2, :todos_done_count).from(1).to(0) .and change(user2, :todos_done_count).from(1).to(0)
...@@ -36,26 +44,24 @@ RSpec.describe Users::UpdateTodoCountCacheService do ...@@ -36,26 +44,24 @@ RSpec.describe Users::UpdateTodoCountCacheService do
end end
it 'avoids N+1 queries' do it 'avoids N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new { described_class.new([user1]).execute }.count control_count = ActiveRecord::QueryRecorder.new { execute_single }.count
expect { described_class.new([user1, user2]).execute }.not_to exceed_query_limit(control_count) expect { execute_all }.not_to exceed_query_limit(control_count)
end end
it 'executes one query per batch of users' do it 'executes one query per batch of users' do
stub_const("#{described_class}::QUERY_BATCH_SIZE", 1) stub_const("#{described_class}::QUERY_BATCH_SIZE", 1)
expect(ActiveRecord::QueryRecorder.new { described_class.new([user1]).execute }.count).to eq(1) expect(ActiveRecord::QueryRecorder.new { execute_single }.count).to eq(1)
expect(ActiveRecord::QueryRecorder.new { described_class.new([user1, user2]).execute }.count).to eq(2) expect(ActiveRecord::QueryRecorder.new { execute_all }.count).to eq(2)
end end
it 'sets the cache expire time to the users count_cache_validity_period' do it 'sets the correct cache expire time' do
allow(user1).to receive(:count_cache_validity_period).and_return(1.minute) expect(Rails.cache).to receive(:write)
allow(user2).to receive(:count_cache_validity_period).and_return(1.hour) .with(['users', user1.id, anything], anything, expires_in: User::COUNT_CACHE_VALIDITY_PERIOD)
.twice
expect(Rails.cache).to receive(:write).with(['users', user1.id, anything], anything, expires_in: 1.minute).twice
expect(Rails.cache).to receive(:write).with(['users', user2.id, anything], anything, expires_in: 1.hour).twice
described_class.new([user1, user2]).execute execute_single
end end
end end
end end
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