repository_cache_adapter.rb 5.78 KB
Newer Older
1 2 3
module Gitlab
  module RepositoryCacheAdapter
    extend ActiveSupport::Concern
4
    include Gitlab::Utils::StrongMemoize
5 6

    class_methods do
7
      # Caches and strongly memoizes the method.
8 9
      #
      # This only works for methods that do not take any arguments.
10 11 12 13 14
      #
      # name     - The name of the method to be cached.
      # fallback - A value to fall back to if the repository does not exist, or
      #            in case of a Git error. Defaults to nil.
      def cache_method(name, fallback: nil)
15 16 17 18 19 20 21
        uncached_name = alias_uncached_method(name)

        define_method(name) do
          cache_method_output(name, fallback: fallback) do
            __send__(uncached_name) # rubocop:disable GitlabSecurity/PublicSend
          end
        end
22 23
      end

24 25 26 27 28 29 30 31 32 33 34
      # Caches truthy values from the method. All values are strongly memoized,
      # and cached in RequestStore.
      #
      # Currently only used to cache `exists?` since stale false values are
      # particularly troublesome. This can occur, for example, when an NFS mount
      # is temporarily down.
      #
      # This only works for methods that do not take any arguments.
      #
      # name - The name of the method to be cached.
      def cache_method_asymmetrically(name)
35 36 37 38 39 40 41
        uncached_name = alias_uncached_method(name)

        define_method(name) do
          cache_method_output_asymmetrically(name) do
            __send__(uncached_name) # rubocop:disable GitlabSecurity/PublicSend
          end
        end
42 43
      end

44 45 46 47 48 49 50 51 52
      # Strongly memoizes the method.
      #
      # This only works for methods that do not take any arguments.
      #
      # name     - The name of the method to be memoized.
      # fallback - A value to fall back to if the repository does not exist, or
      #            in case of a Git error. Defaults to nil. The fallback value
      #            is not memoized.
      def memoize_method(name, fallback: nil)
53
        uncached_name = alias_uncached_method(name)
54 55

        define_method(name) do
56 57
          memoize_method_output(name, fallback: fallback) do
            __send__(uncached_name) # rubocop:disable GitlabSecurity/PublicSend
58 59 60
          end
        end
      end
61 62 63 64 65 66 67 68 69 70 71

      # Prepends "_uncached_" to the target method name
      #
      # Returns the uncached method name
      def alias_uncached_method(name)
        uncached_name = :"_uncached_#{name}"

        alias_method(uncached_name, name)

        uncached_name
      end
72 73
    end

74 75 76 77 78 79
    # RequestStore-backed RepositoryCache to be used. Should be overridden by
    # the including class
    def request_store_cache
      raise NotImplementedError
    end

80 81 82 83 84
    # RepositoryCache to be used. Should be overridden by the including class
    def cache
      raise NotImplementedError
    end

85 86 87 88 89
    # List of cached methods. Should be overridden by the including class
    def cached_methods
      raise NotImplementedError
    end

90
    # Caches and strongly memoizes the supplied block.
91
    #
92 93 94 95 96 97 98 99 100
    # name     - The name of the method to be cached.
    # fallback - A value to fall back to if the repository does not exist, or
    #            in case of a Git error. Defaults to nil.
    def cache_method_output(name, fallback: nil, &block)
      memoize_method_output(name, fallback: fallback) do
        cache.fetch(name, &block)
      end
    end

101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
    # Caches truthy values from the supplied block. All values are strongly
    # memoized, and cached in RequestStore.
    #
    # Currently only used to cache `exists?` since stale false values are
    # particularly troublesome. This can occur, for example, when an NFS mount
    # is temporarily down.
    #
    # name - The name of the method to be cached.
    def cache_method_output_asymmetrically(name, &block)
      memoize_method_output(name) do
        request_store_cache.fetch(name) do
          cache.fetch_without_caching_false(name, &block)
        end
      end
    end

117
    # Strongly memoizes the supplied block.
118
    #
119 120 121 122 123 124 125
    # name     - The name of the method to be memoized.
    # fallback - A value to fall back to if the repository does not exist, or
    #            in case of a Git error. Defaults to nil. The fallback value is
    #            not memoized.
    def memoize_method_output(name, fallback: nil, &block)
      no_repository_fallback(name, fallback: fallback) do
        strong_memoize(memoizable_name(name), &block)
126 127 128
      end
    end

129 130 131 132 133 134 135 136 137 138 139 140 141
    # Returns the fallback value if the repository does not exist
    def no_repository_fallback(name, fallback: nil, &block)
      # Avoid unnecessary gRPC invocations
      return fallback if fallback && fallback_early?(name)

      yield
    rescue Gitlab::Git::Repository::NoRepository
      # Even if the `#exists?` check in `fallback_early?` passes, these errors
      # might still occur (for example because of a non-existing HEAD). We
      # want to gracefully handle this and not memoize anything.
      fallback
    end

142 143
    # Expires the caches of a specific set of methods
    def expire_method_caches(methods)
144 145 146
      methods.each do |name|
        unless cached_methods.include?(name.to_sym)
          Rails.logger.error "Requested to expire non-existent method '#{name}' for Repository"
147 148 149
          next
        end

150
        cache.expire(name)
151

152
        clear_memoization(memoizable_name(name))
153
      end
154 155

      expire_request_store_method_caches(methods)
156 157 158 159
    end

    private

160 161 162 163
    def memoizable_name(name)
      "#{name.to_s.tr('?!', '')}"
    end

164 165 166 167 168 169
    def expire_request_store_method_caches(methods)
      methods.each do |name|
        request_store_cache.expire(name)
      end
    end

170 171 172 173 174 175 176
    # All cached repository methods depend on the existence of a Git repository,
    # so if the repository doesn't exist, we already know not to call it.
    def fallback_early?(method_name)
      # Avoid infinite loop
      return false if method_name == :exists?

      !exists?
177 178 179
    end
  end
end