summaryrefslogtreecommitdiff
path: root/lib/gitlab/issuables_count_for_state.rb
blob: f11dd520d2da8ba01ae2e9ee3fb305a85c2d76a4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# frozen_string_literal: true

module Gitlab
  # Class for counting and caching the number of issuables per state.
  class IssuablesCountForState
    # The name of the Gitlab::SafeRequestStore cache key.
    CACHE_KEY = :issuables_count_for_state
    # The expiration time for the Rails cache.
    CACHE_EXPIRES_IN = 1.hour
    THRESHOLD = 1000

    # The state values that can be safely casted to a Symbol.
    STATES = %w[opened closed merged all].freeze

    attr_reader :project, :finder

    def self.declarative_policy_class
      'IssuablePolicy'
    end

    # finder - The finder class to use for retrieving the issuables.
    # fast_fail - restrict counting to a shorter period, degrading gracefully on
    # failure
    def initialize(finder, project = nil, fast_fail: false, store_in_redis_cache: false)
      @finder = finder
      @project = project
      @fast_fail = fast_fail
      @cache = Gitlab::SafeRequestStore[CACHE_KEY] ||= initialize_cache
      @store_in_redis_cache = store_in_redis_cache
    end

    def for_state_or_opened(state = nil)
      self[state || :opened]
    end

    def fast_fail?
      !!@fast_fail
    end

    # Define method for each state
    STATES.each do |state|
      define_method(state) { self[state] }
    end

    # Returns the count for the given state.
    #
    # state - The name of the state as either a String or a Symbol.
    #
    # Returns an Integer.
    def [](state)
      state = state.to_sym if cast_state_to_symbol?(state)

      cache_for_finder[state] || 0
    end

    private

    def cache_for_finder
      cached_counts = Rails.cache.read(redis_cache_key, cache_options) if cache_issues_count?

      cached_counts ||= @cache[finder]
      return cached_counts if cached_counts.empty?

      if cache_issues_count? && cached_counts.values.all? { |count| count >= THRESHOLD }
        Rails.cache.write(redis_cache_key, cached_counts, cache_options)
      end

      cached_counts
    end

    def cast_state_to_symbol?(state)
      state.is_a?(String) && STATES.include?(state)
    end

    def initialize_cache
      Hash.new { |hash, finder| hash[finder] = perform_count(finder) }
    end

    def perform_count(finder)
      return finder.count_by_state unless fast_fail?

      fast_count_by_state_attempt!

      # Determining counts when referring to issuable titles or descriptions can
      # be very expensive, and involve the database reading gigabytes of data
      # for a relatively minor piece of functionality. This may slow index pages
      # by seconds in the best case, or lead to a statement timeout in the worst
      # case.
      #
      # In time, we may be able to use elasticsearch or postgresql tsv columns
      # to perform the calculation more efficiently. Until then, use a shorter
      # timeout and return -1 as a sentinel value if it is triggered
      begin
        ApplicationRecord.with_fast_read_statement_timeout do
          finder.count_by_state
        end
      rescue ActiveRecord::QueryCanceled => err
        fast_count_by_state_failure!

        Gitlab::ErrorTracking.track_exception(
          err,
          params: finder.params,
          current_user_id: finder.current_user&.id,
          issue_url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/249180'
        )

        Hash.new(-1)
      end
    end

    def fast_count_by_state_attempt!
      Gitlab::Metrics.counter(
        :gitlab_issuable_fast_count_by_state_total,
        "Count of total calls to IssuableFinder#count_by_state with fast failure"
      ).increment
    end

    def fast_count_by_state_failure!
      Gitlab::Metrics.counter(
        :gitlab_issuable_fast_count_by_state_failures_total,
        "Count of failed calls to IssuableFinder#count_by_state with fast failure"
      ).increment
    end

    def cache_issues_count?
      @store_in_redis_cache &&
        finder.instance_of?(IssuesFinder) &&
        parent_group.present? &&
        !params_include_filters?
    end

    def parent_group
      finder.params.group
    end

    def redis_cache_key
      ['group', parent_group&.id, 'issues']
    end

    def cache_options
      { expires_in: CACHE_EXPIRES_IN }
    end

    def params_include_filters?
      non_filtering_params = %i[
        scope state sort group_id include_subgroups
        attempt_group_search_optimizations non_archived issue_types
      ]

      finder.params.except(*non_filtering_params).values.any?
    end
  end
end