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
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
|
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ReactiveCaching, :use_clean_rails_memory_store_caching do
include ExclusiveLeaseHelpers
include ReactiveCachingHelpers
let(:cache_class_test) do
Class.new do
include ReactiveCaching
self.reactive_cache_key = ->(thing) { ["foo", thing.id] }
self.reactive_cache_lifetime = 5.minutes
self.reactive_cache_refresh_interval = 15.seconds
attr_reader :id
def self.primary_key
:id
end
def initialize(id, &blk)
@id = id
@calculator = blk
end
def calculate_reactive_cache
@calculator.call
end
def result
with_reactive_cache do |data|
data
end
end
end
end
let(:external_dependency_cache_class_test) do
Class.new(cache_class_test) do
self.reactive_cache_work_type = :external_dependency
end
end
let(:calculation) { -> { 2 + 2 } }
let(:cache_key) { "foo:666" }
let(:instance) { cache_class_test.new(666, &calculation) }
describe '#with_reactive_cache' do
before do
stub_reactive_cache
end
subject(:go!) { instance.result }
shared_examples 'reactive worker call' do |worker_class|
let(:instance) do
test_class.new(666, &calculation)
end
it 'performs caching with correct worker' do
expect(worker_class).to receive(:perform_async).with(test_class, 666)
go!
end
end
shared_examples 'a cacheable value' do |cached_value|
before do
stub_reactive_cache(instance, cached_value)
end
it { is_expected.to eq(cached_value) }
it 'does not enqueue a background worker' do
expect(ReactiveCachingWorker).not_to receive(:perform_async)
go!
end
it 'updates the cache lifespan' do
expect(Rails.cache).to receive(:write).with(alive_reactive_cache_key(instance), true, expires_in: anything)
go!
end
context 'and expired' do
before do
invalidate_reactive_cache(instance)
end
it { is_expected.to be_nil }
it_behaves_like 'reactive worker call', ReactiveCachingWorker do
let(:test_class) { cache_class_test }
end
it_behaves_like 'reactive worker call', ExternalServiceReactiveCachingWorker do
let(:test_class) { external_dependency_cache_class_test }
end
end
end
context 'when cache is empty' do
it { is_expected.to be_nil }
it_behaves_like 'reactive worker call', ReactiveCachingWorker do
let(:test_class) { cache_class_test }
end
it_behaves_like 'reactive worker call', ExternalServiceReactiveCachingWorker do
let(:test_class) { external_dependency_cache_class_test }
end
it 'updates the cache lifespan' do
expect(reactive_cache_alive?(instance)).to be_falsy
go!
expect(reactive_cache_alive?(instance)).to be_truthy
end
end
context 'when the cache is full' do
it_behaves_like 'a cacheable value', 4
end
context 'when the cache contains non-nil but blank value' do
it_behaves_like 'a cacheable value', false
end
context 'when the cache contains nil value' do
it_behaves_like 'a cacheable value', nil
end
end
describe '#with_reactive_cache_set', :use_clean_rails_redis_caching do
subject(:go!) do
instance.with_reactive_cache_set('resource', {}) do |data|
data
end
end
it 'calls with_reactive_cache' do
expect(instance)
.to receive(:with_reactive_cache)
go!
end
context 'data returned' do
let(:resource) { 'resource' }
let(:set_key) { "#{cache_key}:#{resource}" }
let(:set_cache) { Gitlab::ReactiveCacheSetCache.new }
before do
stub_reactive_cache(instance, true, resource, {})
end
it 'saves keys in set' do
expect(set_cache.read(set_key)).to be_empty
go!
expect(set_cache.read(set_key)).not_to be_empty
end
it 'returns the data' do
expect(go!).to eq(true)
end
end
end
describe '.reactive_cache_worker_finder' do
context 'with default reactive_cache_worker_finder' do
let(:args) { %w(other args) }
before do
allow(instance.class).to receive(:find_by).with(id: instance.id)
.and_return(instance)
end
it 'calls the activerecord find_by method' do
result = instance.class.reactive_cache_worker_finder.call(instance.id, *args)
expect(result).to eq(instance)
expect(instance.class).to have_received(:find_by).with(id: instance.id)
end
end
context 'with custom reactive_cache_worker_finder' do
let(:args) { %w(arg1 arg2) }
let(:instance) { custom_finder_cache_test.new(666, &calculation) }
let(:custom_finder_cache_test) do
Class.new(cache_class_test) do
self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
def self.from_cache(*args); end
end
end
before do
allow(instance.class).to receive(:from_cache).with(*args).and_return(instance)
end
it 'overrides the default reactive_cache_worker_finder' do
result = instance.class.reactive_cache_worker_finder.call(instance.id, *args)
expect(result).to eq(instance)
expect(instance.class).to have_received(:from_cache).with(*args)
end
end
end
describe '#clear_reactive_cache!' do
before do
stub_reactive_cache(instance, 4)
instance.clear_reactive_cache!
end
it { expect(instance.result).to be_nil }
it { expect(reactive_cache_alive?(instance)).to be_falsy }
end
describe '#exclusively_update_reactive_cache!' do
subject(:go!) { instance.exclusively_update_reactive_cache! }
shared_examples 'successful cache' do
it 'caches the result of #calculate_reactive_cache' do
go!
expect(read_reactive_cache(instance)).to eq(calculation.call)
end
it 'does not raise the exception' do
expect { go! }.not_to raise_exception(ReactiveCaching::ExceededReactiveCacheLimit)
end
end
context 'when the lease is free and lifetime is not exceeded' do
before do
stub_reactive_cache(instance, 'preexisting')
end
it_behaves_like 'successful cache'
it 'takes and releases the lease' do
expect_to_obtain_exclusive_lease(cache_key, 'uuid')
expect_to_cancel_exclusive_lease(cache_key, 'uuid')
go!
end
it 'enqueues a repeat worker' do
expect_reactive_cache_update_queued(instance)
go!
end
context 'when :external_dependency cache' do
let(:instance) do
external_dependency_cache_class_test.new(666, &calculation)
end
it 'enqueues a repeat worker' do
expect_reactive_cache_update_queued(instance, worker_klass: ExternalServiceReactiveCachingWorker)
go!
end
end
it 'calls a reactive_cache_updated only once if content did not change on subsequent update' do
expect(instance).to receive(:calculate_reactive_cache).twice
expect(instance).to receive(:reactive_cache_updated).once
2.times { instance.exclusively_update_reactive_cache! }
end
it 'does not delete the value key' do
expect(Rails.cache).to receive(:delete).with(cache_key).never
go!
end
context 'when reactive_cache_hard_limit is set' do
let(:test_class) { Class.new(cache_class_test) { self.reactive_cache_hard_limit = 1.megabyte } }
let(:instance) { test_class.new(666, &calculation) }
context 'when cache size is over the overridden limit' do
let(:calculation) { -> { 'a' * 2 * 1.megabyte } }
it 'raises ExceededReactiveCacheLimit exception and does not cache new data' do
expect { go! }.to raise_exception(ReactiveCaching::ExceededReactiveCacheLimit)
expect(read_reactive_cache(instance)).not_to eq(calculation.call)
end
context 'when reactive_cache_limit_enabled? is overridden to return false' do
before do
allow(instance).to receive(:reactive_cache_limit_enabled?).and_return(false)
end
it_behaves_like 'successful cache'
end
end
context 'when cache size is within the overridden limit' do
let(:calculation) { -> { 'Smaller than 1Mb reactive_cache_hard_limit' } }
it_behaves_like 'successful cache'
end
end
context 'and #calculate_reactive_cache raises an exception' do
before do
stub_reactive_cache(instance, "preexisting")
end
let(:calculation) { -> { raise "foo"} }
it 'leaves the cache untouched' do
expect { go! }.to raise_error("foo")
expect(read_reactive_cache(instance)).to eq("preexisting")
end
it 'does not enqueue a repeat worker' do
expect(ReactiveCachingWorker)
.not_to receive(:perform_in)
expect { go! }.to raise_error("foo")
end
end
end
context 'when lifetime is exceeded' do
it 'skips the calculation' do
expect(instance).to receive(:calculate_reactive_cache).never
go!
end
it 'deletes the value key' do
expect(Rails.cache).to receive(:delete).with(cache_key).once
go!
end
end
context 'when the lease is already taken' do
it 'skips the calculation' do
stub_exclusive_lease_taken(cache_key)
expect(instance).to receive(:calculate_reactive_cache).never
go!
end
end
end
describe 'default options' do
let(:cached_class) { Class.new { include ReactiveCaching } }
subject { cached_class.new }
it { expect(subject.reactive_cache_lease_timeout).to be_a(ActiveSupport::Duration) }
it { expect(subject.reactive_cache_refresh_interval).to be_a(ActiveSupport::Duration) }
it { expect(subject.reactive_cache_lifetime).to be_a(ActiveSupport::Duration) }
it { expect(subject.reactive_cache_key).to respond_to(:call) }
it { expect(subject.reactive_cache_hard_limit).to be_nil }
it { expect(subject.reactive_cache_worker_finder).to respond_to(:call) }
end
end
|