From 383dafdf31adaded392664cba9ba8b7262505dc6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me>
Date: Mon, 26 Sep 2016 19:12:16 +0200
Subject: [PATCH] Cache the issuable counters for 2 minutes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Signed-off-by: Rémy Coutable <remy@rymai.me>
---
 app/helpers/application_helper.rb        | 23 ------
 app/helpers/issuables_helper.rb          | 36 +++++++++
 app/views/shared/issuable/_nav.html.haml | 10 +--
 spec/helpers/issuables_helper_spec.rb    | 97 +++++++++++++++++++++++-
 4 files changed, 136 insertions(+), 30 deletions(-)

diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index bbc037288db..ebd78bf9888 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -280,29 +280,6 @@ module ApplicationHelper
     end
   end
 
-  def issuables_state_counter_text(state, issuables)
-    titles = {
-      opened: "Open"
-    }
-
-    state_title = titles[state] || state.to_s.humanize
-
-    count =
-      if @issues || @merge_requests
-        issuables_finder = @issues ? issues_finder : merge_requests_finder
-        issuables_finder.params[:state] = state
-        issuables_finder.execute.page(1).total_count
-      end
-
-    html = content_tag(:span, state_title)
-
-    if count.present?
-      html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
-    end
-
-    html.html_safe
-  end
-
   def truncate_first_line(message, length = 50)
     truncate(message.each_line.first.chomp, length: length) if message
   end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 5c04bba323f..8aa1ece017c 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -94,6 +94,24 @@ module IssuablesHelper
     label_names.join(', ')
   end
 
+  def issuables_state_counter_text(issuable_type, state)
+    titles = {
+      opened: "Open"
+    }
+
+    state_title = titles[state] || state.to_s.humanize
+
+    count =
+      Rails.cache.fetch(issuables_state_counter_cache_key(issuable_type, state), expires_in: 2.minutes) do
+        issuables_count_for_state(issuable_type, state)
+      end
+
+    html = content_tag(:span, state_title)
+    html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
+
+    html.html_safe
+  end
+
   private
 
   def sidebar_gutter_collapsed?
@@ -111,4 +129,22 @@ module IssuablesHelper
       issuable.open? ? :opened : :closed
     end
   end
+
+  def issuables_count_for_state(issuable_type, state)
+    issuables_finder = public_send("#{issuable_type}_finder")
+    issuables_finder.params[:state] = state
+
+    issuables_finder.execute.page(1).total_count
+  end
+
+  IRRELEVANT_PARAMS_FOR_CACHE_KEY = %w[utf8 sort page]
+  private_constant :IRRELEVANT_PARAMS_FOR_CACHE_KEY
+
+  def issuables_state_counter_cache_key(issuable_type, state)
+    opts = params.dup
+    opts['state'] = state
+    opts.delete_if { |k, v| IRRELEVANT_PARAMS_FOR_CACHE_KEY.include?(k) }
+
+    hexdigest(['issuables_count', issuable_type, opts.sort].flatten.join('-'))
+  end
 end
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index 475b99a2a07..5527a2f889a 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -5,21 +5,21 @@
 %ul.nav-links.issues-state-filters
   %li{class: ("active" if params[:state] == 'opened')}
     = link_to page_filter_path(state: 'opened', label: true), title: "Filter by #{page_context_word} that are currently opened." do
-      #{issuables_state_counter_text(:opened, issuables)}
+      #{issuables_state_counter_text(type, :opened)}
 
   - if type == :merge_requests
     %li{class: ("active" if params[:state] == 'merged')}
       = link_to page_filter_path(state: 'merged', label: true), title: 'Filter by merge requests that are currently merged.' do
-        #{issuables_state_counter_text(:merged, issuables)}
+        #{issuables_state_counter_text(type, :merged)}
 
     %li{class: ("active" if params[:state] == 'closed')}
       = link_to page_filter_path(state: 'closed', label: true), title: 'Filter by merge requests that are currently closed and unmerged.' do
-        #{issuables_state_counter_text(:closed, issuables)}
+        #{issuables_state_counter_text(type, :closed)}
   - else
     %li{class: ("active" if params[:state] == 'closed')}
       = link_to page_filter_path(state: 'closed', label: true), title: 'Filter by issues that are currently closed.' do
-        #{issuables_state_counter_text(:closed, issuables)}
+        #{issuables_state_counter_text(type, :closed)}
 
   %li{class: ("active" if params[:state] == 'all')}
     = link_to page_filter_path(state: 'all', label: true), title: "Show all #{page_context_word}." do
-      #{issuables_state_counter_text(:all, issuables)}
+      #{issuables_state_counter_text(type, :all)}
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index 2dd2eab0524..5effb7ea36c 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -1,10 +1,10 @@
 require 'spec_helper'
 
-describe IssuablesHelper do 
+describe IssuablesHelper do
   let(:label)  { build_stubbed(:label) }
   let(:label2) { build_stubbed(:label) }
 
-  context 'label tooltip' do
+  describe '#issuable_labels_tooltip' do
     it 'returns label text' do
       expect(issuable_labels_tooltip([label])).to eq(label.title)
     end
@@ -13,4 +13,97 @@ describe IssuablesHelper do
       expect(issuable_labels_tooltip([label, label2], limit: 1)).to eq("#{label.title}, and 1 more")
     end
   end
+
+  describe '#issuables_state_counter_text' do
+    let(:user) { create(:user) }
+
+    describe 'state text' do
+      before do
+        allow(helper).to receive(:issuables_count_for_state).and_return(42)
+      end
+
+      it 'returns "Open" when state is :opened' do
+        expect(helper.issuables_state_counter_text(:issues, :opened)).
+          to eq('<span>Open</span> <span class="badge">42</span>')
+      end
+
+      it 'returns "Closed" when state is :closed' do
+        expect(helper.issuables_state_counter_text(:issues, :closed)).
+          to eq('<span>Closed</span> <span class="badge">42</span>')
+      end
+
+      it 'returns "Merged" when state is :merged' do
+        expect(helper.issuables_state_counter_text(:merge_requests, :merged)).
+          to eq('<span>Merged</span> <span class="badge">42</span>')
+      end
+
+      it 'returns "All" when state is :all' do
+        expect(helper.issuables_state_counter_text(:merge_requests, :all)).
+          to eq('<span>All</span> <span class="badge">42</span>')
+      end
+    end
+
+    describe 'counter caching based on issuable type and params', :caching do
+      let(:params) do
+        {
+          'scope' => 'created-by-me',
+          'state' => 'opened',
+          'utf8' => '✓',
+          'author_id' => '11',
+          'assignee_id' => '18',
+          'label_name' => ['bug', 'discussion', 'documentation'],
+          'milestone_title' => 'v4.0',
+          'sort' => 'due_date_asc',
+          'namespace_id' => 'gitlab-org',
+          'project_id' => 'gitlab-ce',
+          'page' => 2
+        }
+      end
+
+      it 'returns the cached value when called for the same issuable type & with the same params' do
+        expect(helper).to receive(:params).twice.and_return(params)
+        expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42)
+
+        expect(helper.issuables_state_counter_text(:issues, :opened)).
+          to eq('<span>Open</span> <span class="badge">42</span>')
+
+        expect(helper).not_to receive(:issuables_count_for_state)
+
+        expect(helper.issuables_state_counter_text(:issues, :opened)).
+          to eq('<span>Open</span> <span class="badge">42</span>')
+      end
+
+      describe 'keys not taken in account in the cache key' do
+        %w[state sort utf8 page].each do |param|
+          it "does not take in account params['#{param}'] in the cache key" do
+            expect(helper).to receive(:params).and_return('author_id' => '11', param => 'foo')
+            expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42)
+
+            expect(helper.issuables_state_counter_text(:issues, :opened)).
+              to eq('<span>Open</span> <span class="badge">42</span>')
+
+            expect(helper).to receive(:params).and_return('author_id' => '11', param => 'bar')
+            expect(helper).not_to receive(:issuables_count_for_state)
+
+            expect(helper.issuables_state_counter_text(:issues, :opened)).
+              to eq('<span>Open</span> <span class="badge">42</span>')
+          end
+        end
+      end
+
+      it 'does not take params order in acount in the cache key' do
+        expect(helper).to receive(:params).and_return('author_id' => '11', 'state' => 'opened')
+        expect(helper).to receive(:issuables_count_for_state).with(:issues, :opened).and_return(42)
+
+        expect(helper.issuables_state_counter_text(:issues, :opened)).
+          to eq('<span>Open</span> <span class="badge">42</span>')
+
+        expect(helper).to receive(:params).and_return('state' => 'opened', 'author_id' => '11')
+        expect(helper).not_to receive(:issuables_count_for_state)
+
+        expect(helper.issuables_state_counter_text(:issues, :opened)).
+          to eq('<span>Open</span> <span class="badge">42</span>')
+      end
+    end
+  end
 end
-- 
GitLab