summaryrefslogtreecommitdiff
path: root/lib/gitlab/repository_cache_adapter.rb
blob: f7318f816134236e990e38b781b7a09a6b037f45 (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
module Gitlab
  module RepositoryCacheAdapter
    extend ActiveSupport::Concern
    include Gitlab::Utils::StrongMemoize

    class_methods do
      # Caches and strongly memoizes the method.
      #
      # This only works for methods that do not take any arguments.
      #
      # 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)
        wrap_method(name, :cache_method_output, fallback: fallback)
      end

      # 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)
        wrap_method(name, :memoize_method_output, fallback: fallback)
      end

      # Prepends "_uncached_" to the target method name, and redefines the method
      # but wraps it in the `wrapper` method.
      def wrap_method(name, wrapper, *options)
        original = :"_uncached_#{name}"

        alias_method(original, name)

        define_method(name) do
          __send__(wrapper, name, *options) do # rubocop:disable GitlabSecurity/PublicSend
            __send__(original) # rubocop:disable GitlabSecurity/PublicSend
          end
        end
      end
    end

    # RepositoryCache to be used. Should be overridden by the including class
    def cache
      raise NotImplementedError
    end

    # List of cached methods. Should be overridden by the including class
    def cached_methods
      raise NotImplementedError
    end

    # Caches and strongly memoizes the supplied block.
    #
    # 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

    # Strongly memoizes the supplied block.
    #
    # 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)
      end
    end

    # 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

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

        cache.expire(name)

        clear_memoization(memoizable_name(name))
      end
    end

    private

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

    # 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?
    end
  end
end