386 lines
10 KiB
Ruby
386 lines
10 KiB
Ruby
# 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
|
|
self.reactive_cache_work_type = :no_dependency
|
|
|
|
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
|
|
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
|
|
|
|
describe 'classes including this concern' do
|
|
it 'sets reactive_cache_work_type', :eager_load do
|
|
classes = ObjectSpace.each_object(Class).select do |klass|
|
|
klass < described_class && klass.name
|
|
end
|
|
|
|
expect(classes).to all(have_attributes(reactive_cache_work_type: be_in(described_class::WORK_TYPE.keys)))
|
|
end
|
|
end
|
|
end
|