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
|
# frozen_string_literal: true
# The ReactiveCaching concern is used to fetch some data in the background and
# store it in the Rails cache, keeping it up-to-date for as long as it is being
# requested. If the data hasn't been requested for +reactive_cache_lifetime+,
# it stop being refreshed, and then be removed.
#
# Example of use:
#
# class Foo < ActiveRecord::Base
# include ReactiveCaching
#
# self.reactive_cache_key = ->(thing) { ["foo", thing.id] }
#
# after_save :clear_reactive_cache!
#
# def calculate_reactive_cache
# # Expensive operation here. The return value of this method is cached
# end
#
# def result
# with_reactive_cache do |data|
# # ...
# end
# end
# end
#
# In this example, the first time `#result` is called, it will return `nil`.
# However, it will enqueue a background worker to call `#calculate_reactive_cache`
# and set an initial cache lifetime of ten minutes.
#
# Each time the background job completes, it stores the return value of
# `#calculate_reactive_cache`. It is also re-enqueued to run again after
# `reactive_cache_refresh_interval`, so keeping the stored value up to date.
# Calculations are never run concurrently.
#
# Calling `#result` while a value is in the cache will call the block given to
# `#with_reactive_cache`, yielding the cached value. It will also extend the
# lifetime by `reactive_cache_lifetime`.
#
# Once the lifetime has expired, no more background jobs will be enqueued and
# calling `#result` will again return `nil` - starting the process all over
# again
module ReactiveCaching
extend ActiveSupport::Concern
InvalidateReactiveCache = Class.new(StandardError)
included do
class_attribute :reactive_cache_lease_timeout
class_attribute :reactive_cache_key
class_attribute :reactive_cache_lifetime
class_attribute :reactive_cache_refresh_interval
# defaults
self.reactive_cache_lease_timeout = 2.minutes
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
def calculate_reactive_cache(*args)
raise NotImplementedError
end
def reactive_cache_updated(*args)
end
def with_reactive_cache(*args, &blk)
unless within_reactive_cache_lifetime?(*args)
refresh_reactive_cache!(*args)
return nil
end
keep_alive_reactive_cache!(*args)
begin
data = Rails.cache.read(full_reactive_cache_key(*args))
yield data if data.present?
rescue InvalidateReactiveCache
refresh_reactive_cache!(*args)
nil
end
end
def clear_reactive_cache!(*args)
Rails.cache.delete(full_reactive_cache_key(*args))
Rails.cache.delete(alive_reactive_cache_key(*args))
end
def exclusively_update_reactive_cache!(*args)
locking_reactive_cache(*args) do
if within_reactive_cache_lifetime?(*args)
enqueuing_update(*args) do
key = full_reactive_cache_key(*args)
new_value = calculate_reactive_cache(*args)
old_value = Rails.cache.read(key)
Rails.cache.write(key, new_value)
reactive_cache_updated(*args) if new_value != old_value
end
end
end
end
private
def refresh_reactive_cache!(*args)
clear_reactive_cache!(*args)
keep_alive_reactive_cache!(*args)
ReactiveCachingWorker.perform_async(self.class, id, *args)
end
def keep_alive_reactive_cache!(*args)
Rails.cache.write(alive_reactive_cache_key(*args), true, expires_in: self.class.reactive_cache_lifetime)
end
def full_reactive_cache_key(*qualifiers)
prefix = self.class.reactive_cache_key
prefix = prefix.call(self) if prefix.respond_to?(:call)
([prefix].flatten + qualifiers).join(':')
end
def alive_reactive_cache_key(*qualifiers)
full_reactive_cache_key(*(qualifiers + ['alive']))
end
def locking_reactive_cache(*args)
lease = Gitlab::ExclusiveLease.new(full_reactive_cache_key(*args), timeout: reactive_cache_lease_timeout)
uuid = lease.try_obtain
yield if uuid
ensure
Gitlab::ExclusiveLease.cancel(full_reactive_cache_key(*args), uuid)
end
def within_reactive_cache_lifetime?(*args)
!!Rails.cache.read(alive_reactive_cache_key(*args))
end
def enqueuing_update(*args)
yield
ensure
ReactiveCachingWorker.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args)
end
end
end
|