1# frozen_string_literal: true
2
3# The usage of the ReactiveCaching module is documented here:
4# https://docs.gitlab.com/ee/development/reactive_caching.html
5#
6module ReactiveCaching
7  extend ActiveSupport::Concern
8
9  InvalidateReactiveCache = Class.new(StandardError)
10  ExceededReactiveCacheLimit = Class.new(StandardError)
11
12  WORK_TYPE = {
13    no_dependency: ReactiveCachingWorker,
14    external_dependency: ExternalServiceReactiveCachingWorker
15  }.freeze
16
17  included do
18    extend ActiveModel::Naming
19
20    class_attribute :reactive_cache_key
21    class_attribute :reactive_cache_lease_timeout
22    class_attribute :reactive_cache_refresh_interval
23    class_attribute :reactive_cache_lifetime
24    class_attribute :reactive_cache_hard_limit
25    class_attribute :reactive_cache_work_type
26    class_attribute :reactive_cache_worker_finder
27
28    # defaults
29    self.reactive_cache_key = -> (record) { [model_name.singular, record.id] }
30    self.reactive_cache_lease_timeout = 2.minutes
31    self.reactive_cache_refresh_interval = 1.minute
32    self.reactive_cache_lifetime = 10.minutes
33    self.reactive_cache_hard_limit = nil # this value should be set in megabytes. E.g: 1.megabyte
34    self.reactive_cache_worker_finder = ->(id, *_args) do
35      find_by(primary_key => id)
36    end
37
38    def calculate_reactive_cache(*args)
39      raise NotImplementedError
40    end
41
42    def reactive_cache_updated(*args)
43    end
44
45    def with_reactive_cache(*args, &blk)
46      unless within_reactive_cache_lifetime?(*args)
47        refresh_reactive_cache!(*args)
48        return
49      end
50
51      keep_alive_reactive_cache!(*args)
52
53      begin
54        data = Rails.cache.read(full_reactive_cache_key(*args))
55        yield data unless data.nil?
56      rescue InvalidateReactiveCache
57        refresh_reactive_cache!(*args)
58        nil
59      end
60    end
61
62    def with_reactive_cache_set(resource, opts, &blk)
63      data = with_reactive_cache(resource, opts, &blk)
64      save_keys_in_set(resource, opts) if data
65
66      data
67    end
68
69    # This method is used for debugging purposes and should not be used otherwise.
70    def without_reactive_cache(*args, &blk)
71      return with_reactive_cache(*args, &blk) unless Rails.env.development?
72
73      data = self.class.reactive_cache_worker_finder.call(id, *args).calculate_reactive_cache(*args)
74      yield data
75    end
76
77    def clear_reactive_cache!(*args)
78      Rails.cache.delete(full_reactive_cache_key(*args))
79      Rails.cache.delete(alive_reactive_cache_key(*args))
80    end
81
82    def clear_reactive_cache_set!(*args)
83      cache_key = full_reactive_cache_key(args)
84
85      reactive_set_cache.clear_cache!(cache_key)
86    end
87
88    def exclusively_update_reactive_cache!(*args)
89      locking_reactive_cache(*args) do
90        key = full_reactive_cache_key(*args)
91
92        if within_reactive_cache_lifetime?(*args)
93          enqueuing_update(*args) do
94            new_value = calculate_reactive_cache(*args)
95            check_exceeded_reactive_cache_limit!(new_value)
96
97            old_value = Rails.cache.read(key)
98            Rails.cache.write(key, new_value)
99            reactive_cache_updated(*args) if new_value != old_value
100          end
101        else
102          Rails.cache.delete(key)
103        end
104      end
105    end
106
107    private
108
109    def save_keys_in_set(resource, opts)
110      cache_key = full_reactive_cache_key(resource)
111
112      reactive_set_cache.write(cache_key, "#{cache_key}:#{opts}")
113    end
114
115    def reactive_set_cache
116      Gitlab::ReactiveCacheSetCache.new(expires_in: reactive_cache_lifetime)
117    end
118
119    def refresh_reactive_cache!(*args)
120      clear_reactive_cache!(*args)
121      keep_alive_reactive_cache!(*args)
122      worker_class.perform_async(self.class, id, *args)
123    end
124
125    def keep_alive_reactive_cache!(*args)
126      Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime)
127    end
128
129    def full_reactive_cache_key(*qualifiers)
130      prefix = self.class.reactive_cache_key
131      prefix = prefix.call(self) if prefix.respond_to?(:call)
132
133      ([prefix].flatten + qualifiers).join(':')
134    end
135
136    def alive_reactive_cache_key(*qualifiers)
137      full_reactive_cache_key(*(qualifiers + ['alive']))
138    end
139
140    def locking_reactive_cache(*args)
141      lease = Gitlab::ExclusiveLease.new(full_reactive_cache_key(*args), timeout: reactive_cache_lease_timeout)
142      uuid = lease.try_obtain
143      yield if uuid
144    ensure
145      Gitlab::ExclusiveLease.cancel(full_reactive_cache_key(*args), uuid)
146    end
147
148    def within_reactive_cache_lifetime?(*args)
149      Rails.cache.exist?(alive_reactive_cache_key(*args))
150    end
151
152    def enqueuing_update(*args)
153      yield
154
155      worker_class.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args)
156    end
157
158    def worker_class
159      WORK_TYPE.fetch(self.class.reactive_cache_work_type.to_sym)
160    end
161
162    def reactive_cache_limit_enabled?
163      !!self.reactive_cache_hard_limit
164    end
165
166    def check_exceeded_reactive_cache_limit!(data)
167      return unless reactive_cache_limit_enabled?
168
169      data_deep_size = Gitlab::Utils::DeepSize.new(data, max_size: self.class.reactive_cache_hard_limit)
170
171      raise ExceededReactiveCacheLimit unless data_deep_size.valid?
172    end
173  end
174end
175