Commit fbb72aab authored by Vladimir Shushlin's avatar Vladimir Shushlin Committed by Mayra Cabrera

Cache milestone issue counts

Add milestone close issues count service
Add milestone issue count service
Use count issue services in milestoneish
Clear milestone issue counters cache on issue creation
Clear milestone closed issue counters cache
Clear milestone issue counters cache on updating issue
Clear milestone issue counters on project transfer
Add a changelog for milestones page improvement
Fix milestones issue count cache keys
parent f0bac84d
...@@ -151,18 +151,20 @@ module MilestonesHelper ...@@ -151,18 +151,20 @@ module MilestonesHelper
end end
def milestone_issues_tooltip_text(milestone) def milestone_issues_tooltip_text(milestone)
issues = milestone.count_issues_by_state(current_user) total = milestone.total_issues_count(current_user)
opened = milestone.opened_issues_count(current_user)
closed = milestone.closed_issues_count(current_user)
return _("Issues") if issues.empty? return _("Issues") if total.zero?
content = [] content = []
if issues["opened"] if opened > 0
content << n_("1 open issue", "%{issues} open issues", issues["opened"]) % { issues: issues["opened"] } content << n_("1 open issue", "%{issues} open issues", opened) % { issues: opened }
end end
if issues["closed"] if closed > 0
content << n_("1 closed issue", "%{issues} closed issues", issues["closed"]) % { issues: issues["closed"] } content << n_("1 closed issue", "%{issues} closed issues", closed) % { issues: closed }
end end
content.join('<br />').html_safe content.join('<br />').html_safe
......
...@@ -2,14 +2,28 @@ ...@@ -2,14 +2,28 @@
module Milestoneish module Milestoneish
def total_issues_count(user) def total_issues_count(user)
@total_issues_count ||=
if Feature.enabled?(:cached_milestone_issue_counters)
Milestones::IssuesCountService.new(self).count
else
count_issues_by_state(user).values.sum count_issues_by_state(user).values.sum
end end
end
def closed_issues_count(user) def closed_issues_count(user)
@close_issues_count ||=
if Feature.enabled?(:cached_milestone_issue_counters)
Milestones::ClosedIssuesCountService.new(self).count
else
closed_state_id = Issue.available_states[:closed] closed_state_id = Issue.available_states[:closed]
count_issues_by_state(user)[closed_state_id].to_i count_issues_by_state(user)[closed_state_id].to_i
end end
end
def opened_issues_count(user)
total_issues_count(user) - closed_issues_count(user)
end
def complete?(user) def complete?(user)
total_issues_count(user) > 0 && total_issues_count(user) == closed_issues_count(user) total_issues_count(user) > 0 && total_issues_count(user) == closed_issues_count(user)
......
...@@ -34,6 +34,18 @@ module Issues ...@@ -34,6 +34,18 @@ module Issues
def update_project_counter_caches?(issue) def update_project_counter_caches?(issue)
super || issue.confidential_changed? super || issue.confidential_changed?
end end
def delete_milestone_closed_issue_counter_cache(milestone)
return unless milestone
Milestones::ClosedIssuesCountService.new(milestone).delete_cache
end
def delete_milestone_total_issue_counter_cache(milestone)
return unless milestone
Milestones::IssuesCountService.new(milestone).delete_cache
end
end end
end end
......
...@@ -38,6 +38,8 @@ module Issues ...@@ -38,6 +38,8 @@ module Issues
issue.update_project_counter_caches issue.update_project_counter_caches
store_first_mentioned_in_commit_at(issue, closed_via) if closed_via.is_a?(MergeRequest) store_first_mentioned_in_commit_at(issue, closed_via) if closed_via.is_a?(MergeRequest)
delete_milestone_closed_issue_counter_cache(issue.milestone)
end end
issue issue
......
...@@ -29,6 +29,7 @@ module Issues ...@@ -29,6 +29,7 @@ module Issues
todo_service.new_issue(issuable, current_user) todo_service.new_issue(issuable, current_user)
user_agent_detail_service.create user_agent_detail_service.create
resolve_discussions_with_issue(issuable) resolve_discussions_with_issue(issuable)
delete_milestone_total_issue_counter_cache(issuable.milestone)
super super
end end
......
...@@ -12,6 +12,7 @@ module Issues ...@@ -12,6 +12,7 @@ module Issues
execute_hooks(issue, 'reopen') execute_hooks(issue, 'reopen')
invalidate_cache_counts(issue, users: issue.assignees) invalidate_cache_counts(issue, users: issue.assignees)
issue.update_project_counter_caches issue.update_project_counter_caches
delete_milestone_closed_issue_counter_cache(issue.milestone)
end end
issue issue
......
...@@ -115,10 +115,26 @@ module Issues ...@@ -115,10 +115,26 @@ module Issues
end end
def handle_milestone_change(issue) def handle_milestone_change(issue)
return if skip_milestone_email
return unless issue.previous_changes.include?('milestone_id') return unless issue.previous_changes.include?('milestone_id')
invalidate_milestone_issue_counters(issue)
send_milestone_change_notification(issue)
end
def invalidate_milestone_issue_counters(issue)
issue.previous_changes['milestone_id'].each do |milestone_id|
next unless milestone_id
milestone = Milestone.find_by_id(milestone_id)
delete_milestone_closed_issue_counter_cache(milestone)
delete_milestone_total_issue_counter_cache(milestone)
end
end
def send_milestone_change_notification(issue)
return if skip_milestone_email
if issue.milestone.nil? if issue.milestone.nil?
notification_service.async.removed_milestone_issue(issue, current_user) notification_service.async.removed_milestone_issue(issue, current_user)
else else
......
# frozen_string_literal: true
module Milestones
class ClosedIssuesCountService < BaseCountService
def initialize(milestone)
@milestone = milestone
end
def cache_key
"milestone_closed_issues_count_#{@milestone.milestoneish_id}"
end
def relation_for_count
@milestone.issues.closed
end
end
end
# frozen_string_literal: true
module Milestones
class IssuesCountService < BaseCountService
def initialize(milestone)
@milestone = milestone
end
def cache_key
"milestone_total_issues_count_#{@milestone.milestoneish_id}"
end
def relation_for_count
@milestone.issues
end
end
end
...@@ -22,7 +22,7 @@ module Milestones ...@@ -22,7 +22,7 @@ module Milestones
milestones_to_transfer.find_each do |milestone| milestones_to_transfer.find_each do |milestone|
new_milestone = find_or_create_milestone(milestone) new_milestone = find_or_create_milestone(milestone)
update_issues_milestone(milestone.id, new_milestone&.id) update_issues_milestone(milestone, new_milestone)
update_merge_requests_milestone(milestone.id, new_milestone&.id) update_merge_requests_milestone(milestone.id, new_milestone&.id)
end end
end end
...@@ -68,9 +68,12 @@ module Milestones ...@@ -68,9 +68,12 @@ module Milestones
end end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def update_issues_milestone(old_milestone_id, new_milestone_id) def update_issues_milestone(old_milestone, new_milestone)
Issue.where(project: project, milestone_id: old_milestone_id) Issue.where(project: project, milestone_id: old_milestone.id)
.update_all(milestone_id: new_milestone_id) .update_all(milestone_id: new_milestone&.id)
delete_milestone_issues_caches(old_milestone)
delete_milestone_issues_caches(new_milestone)
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
...@@ -80,5 +83,12 @@ module Milestones ...@@ -80,5 +83,12 @@ module Milestones
.update_all(milestone_id: new_milestone_id) .update_all(milestone_id: new_milestone_id)
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
def delete_milestone_issues_caches(milestone)
return unless milestone
Milestones::IssuesCountService.new(milestone).delete_cache
Milestones::ClosedIssuesCountService.new(milestone).delete_cache
end
end end
end end
...@@ -211,20 +211,21 @@ describe Milestone, 'Milestoneish' do ...@@ -211,20 +211,21 @@ describe Milestone, 'Milestoneish' do
end end
end end
describe '#complete?' do describe '#complete?', :use_clean_rails_memory_store_caching do
it 'returns false when has items opened' do it 'returns false when has items opened' do
expect(milestone.complete?(non_member)).to eq false expect(milestone.complete?(non_member)).to eq false
end end
it 'returns true when all items are closed' do it 'returns true when all items are closed' do
issue.close issue.close
merge_request.close security_issue_1.close
security_issue_2.close
expect(milestone.complete?(non_member)).to eq true expect(milestone.complete?(non_member)).to eq true
end end
end end
describe '#percent_complete' do describe '#percent_complete', :use_clean_rails_memory_store_caching do
context 'division by zero' do context 'division by zero' do
let(:new_milestone) { build_stubbed(:milestone) } let(:new_milestone) { build_stubbed(:milestone) }
...@@ -233,6 +234,29 @@ describe Milestone, 'Milestoneish' do ...@@ -233,6 +234,29 @@ describe Milestone, 'Milestoneish' do
end end
describe '#count_issues_by_state' do describe '#count_issues_by_state' do
describe '#total_issues_count', :use_clean_rails_memory_store_caching do
it 'counts all issues including confidential' do
expect(milestone.total_issues_count(guest)).to eq 9
end
end
describe '#opened_issues_count', :use_clean_rails_memory_store_caching do
it 'counts all open issues including confidential' do
expect(milestone.opened_issues_count(guest)).to eq 3
end
end
describe '#closed_issues_count', :use_clean_rails_memory_store_caching do
it 'counts all closed issues including confidential' do
expect(milestone.closed_issues_count(guest)).to eq 6
end
end
context 'when cached_milestone_issue_counters are disabled' do
before do
stub_feature_flags(cached_milestone_issue_counters: false)
end
it 'does not count confidential issues for non project members' do it 'does not count confidential issues for non project members' do
expect(milestone.closed_issues_count(non_member)).to eq 2 expect(milestone.closed_issues_count(non_member)).to eq 2
expect(milestone.total_issues_count(non_member)).to eq 3 expect(milestone.total_issues_count(non_member)).to eq 3
...@@ -263,6 +287,7 @@ describe Milestone, 'Milestoneish' do ...@@ -263,6 +287,7 @@ describe Milestone, 'Milestoneish' do
expect(milestone.total_issues_count(admin)).to eq 9 expect(milestone.total_issues_count(admin)).to eq 9
end end
end end
end
describe '#remaining_days' do describe '#remaining_days' do
it 'shows 0 if no due date' do it 'shows 0 if no due date' do
......
...@@ -178,35 +178,55 @@ describe Issues::CloseService do ...@@ -178,35 +178,55 @@ describe Issues::CloseService do
end end
context "valid params" do context "valid params" do
before do def close_issue
perform_enqueued_jobs do perform_enqueued_jobs do
described_class.new(project, user).close_issue(issue) described_class.new(project, user).close_issue(issue)
end end
end end
it 'closes the issue' do it 'closes the issue' do
close_issue
expect(issue).to be_valid expect(issue).to be_valid
expect(issue).to be_closed expect(issue).to be_closed
end end
it 'records closed user' do it 'records closed user' do
close_issue
expect(issue.closed_by_id).to be(user.id) expect(issue.closed_by_id).to be(user.id)
end end
it 'sends email to user2 about assign of new issue', :sidekiq_might_not_need_inline do it 'sends email to user2 about assign of new issue', :sidekiq_might_not_need_inline do
close_issue
email = ActionMailer::Base.deliveries.last email = ActionMailer::Base.deliveries.last
expect(email.to.first).to eq(user2.email) expect(email.to.first).to eq(user2.email)
expect(email.subject).to include(issue.title) expect(email.subject).to include(issue.title)
end end
it 'creates system note about issue reassign' do it 'creates system note about issue reassign' do
close_issue
note = issue.notes.last note = issue.notes.last
expect(note.note).to include "closed" expect(note.note).to include "closed"
end end
it 'marks todos as done' do it 'marks todos as done' do
close_issue
expect(todo.reload).to be_done expect(todo.reload).to be_done
end end
it 'deletes milestone issue counters cache' do
issue.update(milestone: create(:milestone, project: project))
expect_next_instance_of(Milestones::ClosedIssuesCountService, issue.milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
close_issue
end
end end
context 'when issue is not confidential' do context 'when issue is not confidential' do
......
...@@ -196,6 +196,14 @@ describe Issues::CreateService do ...@@ -196,6 +196,14 @@ describe Issues::CreateService do
end end
end end
end end
it 'deletes milestone issues count cache' do
expect_next_instance_of(Milestones::IssuesCountService, milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
issue
end
end end
context 'issue create service' do context 'issue create service' do
......
...@@ -43,6 +43,16 @@ describe Issues::ReopenService do ...@@ -43,6 +43,16 @@ describe Issues::ReopenService do
.to change { project.open_issues_count }.from(0).to(1) .to change { project.open_issues_count }.from(0).to(1)
end end
it 'deletes milestone issue counters cache' do
issue.update(milestone: create(:milestone, project: project))
expect_next_instance_of(Milestones::ClosedIssuesCountService, issue.milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
described_class.new(project, user).execute(issue)
end
context 'when issue is not confidential' do context 'when issue is not confidential' do
it 'executes issue hooks' do it 'executes issue hooks' do
expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks) expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks)
......
...@@ -412,9 +412,24 @@ describe Issues::UpdateService, :mailer do ...@@ -412,9 +412,24 @@ describe Issues::UpdateService, :mailer do
should_email(subscriber) should_email(subscriber)
should_not_email(non_subscriber) should_not_email(non_subscriber)
end end
it 'clears milestone issue counters cache' do
issue.milestone = create(:milestone, project: project)
issue.save
expect_next_instance_of(Milestones::IssuesCountService, issue.milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
expect_next_instance_of(Milestones::ClosedIssuesCountService, issue.milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end end
context 'when the milestone is changed' do update_issue(milestone_id: "")
end
end
context 'when the milestone is assigned' do
before do before do
stub_feature_flags(track_resource_milestone_change_events: false) stub_feature_flags(track_resource_milestone_change_events: false)
end end
...@@ -444,6 +459,43 @@ describe Issues::UpdateService, :mailer do ...@@ -444,6 +459,43 @@ describe Issues::UpdateService, :mailer do
should_email(subscriber) should_email(subscriber)
should_not_email(non_subscriber) should_not_email(non_subscriber)
end end
it 'deletes issue counters cache for the milestone' do
milestone = create(:milestone, project: project)
expect_next_instance_of(Milestones::IssuesCountService, milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
expect_next_instance_of(Milestones::ClosedIssuesCountService, milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
update_issue(milestone: milestone)
end
end
context 'when the milestone is changed' do
it 'deletes issue counters cache for both milestones' do
old_milestone = create(:milestone, project: project)
new_milestone = create(:milestone, project: project)
issue.update!(milestone: old_milestone)
expect_next_instance_of(Milestones::IssuesCountService, old_milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
expect_next_instance_of(Milestones::ClosedIssuesCountService, old_milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
expect_next_instance_of(Milestones::IssuesCountService, new_milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
expect_next_instance_of(Milestones::ClosedIssuesCountService, new_milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
update_issue(milestone: new_milestone)
end
end end
context 'when the labels change' do context 'when the labels change' do
......
# frozen_string_literal: true
require 'spec_helper'
describe Milestones::ClosedIssuesCountService, :use_clean_rails_memory_store_caching do
let(:project) { create(:project) }
let(:milestone) { create(:milestone, project: project) }
before do
create(:issue, milestone: milestone, project: project)
create(:issue, :confidential, milestone: milestone, project: project)
create(:issue, :closed, milestone: milestone, project: project)
create(:issue, :closed, :confidential, milestone: milestone, project: project)
end
subject { described_class.new(milestone) }
it_behaves_like 'a counter caching service'
it 'counts closed issues including confidential' do
expect(subject.count).to eq(2)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Milestones::IssuesCountService, :use_clean_rails_memory_store_caching do
let(:project) { create(:project) }
let(:milestone) { create(:milestone, project: project) }
before do
create(:issue, milestone: milestone, project: project)
create(:issue, :confidential, milestone: milestone, project: project)
create(:issue, :closed, milestone: milestone, project: project)
create(:issue, :closed, milestone: milestone, project: project)
end
subject { described_class.new(milestone) }
it_behaves_like 'a counter caching service'
it 'counts all issues including confidential' do
expect(subject.count).to eq(4)
end
end
...@@ -40,6 +40,25 @@ describe Milestones::TransferService do ...@@ -40,6 +40,25 @@ describe Milestones::TransferService do
expect(new_milestone.project_milestone?).to be_truthy expect(new_milestone.project_milestone?).to be_truthy
end end
it 'deletes milestone issue counters cache for both milestones' do
new_milestone = create(:milestone, project: project, title: group_milestone.title)
expect_next_instance_of(Milestones::IssuesCountService, group_milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
expect_next_instance_of(Milestones::ClosedIssuesCountService, group_milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
expect_next_instance_of(Milestones::IssuesCountService, new_milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
expect_next_instance_of(Milestones::ClosedIssuesCountService, new_milestone) do |service|
expect(service).to receive(:delete_cache).and_call_original
end
service.execute
end
it 'does not apply new project milestone to issues with project milestone' do it 'does not apply new project milestone to issues with project milestone' do
service.execute service.execute
......
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