diff --git a/lib/flipper/adapters/fallback_to_cached.rb b/lib/flipper/adapters/fallback_to_cached.rb new file mode 100644 index 00000000..1cf94536 --- /dev/null +++ b/lib/flipper/adapters/fallback_to_cached.rb @@ -0,0 +1,89 @@ +require 'flipper/adapters/memoizable' + +module Flipper + module Adapters + # Public: Adapter that wraps another adapter and caches the result of all + # adapter get calls in memory. If the primary adapter raises an error, the + # cached value will be used instead. + class FallbackToCached < Memoizable + def initialize(adapter, cache = nil) + super + @memoize = true + end + + def memoize=(value) + # raise "memoize cannot be disabled on FallbackToCached adapter" + end + + # Public: The set of known features. + # + # Returns a set of features. + def features + response = @adapter.features + cache[@features_key] = response + response + rescue => e + cache[@features_key] || raise(e) + end + + # Public: Gets the value for a feature from the primary adapter. If the + # primary adapter raises an error, the cached value will be returned + # instead. + # + # feature - The feature to get the value for. + # + # Returns the value for the feature. + def get(feature) + cache[key_for(feature.key)] = @adapter.get(feature) + rescue => e + cache[key_for(feature.key)] || raise(e) + end + + # Public: Gets the values for multiple features from the primary adapter. + # If the primary adapter raises an error, the cached values will be + # returned instead. + # + # features - The features to get the values for. + # + # Returns a hash of feature keys to values. + def get_multi(features) + response = @adapter.get_multi(features) + cache.clear + features.each do |feature| + cache[key_for(feature.key)] = response[feature.key] + end + response + rescue => e + result = {} + features.each do |feature| + result[feature.key] = cache[key_for(feature.key)] || raise(e) + end + result + end + + # Public: Gets all the values from the primary adapter. If the primary + # adapter raises an error, the cached values will be returned instead. + # + # Returns a hash of feature keys to values. + def get_all + response = @adapter.get_all + cache.clear + response.each do |key, value| + cache[key_for(key)] = value + end + cache[@features_key] = response.keys.to_set + response + rescue => e + raise e if cache[@features_key].empty? + response = {} + cache[@features_key].each do |key| + response[key] = cache[key_for(key)] + end + # Ensures that looking up other features that do not exist doesn't + # result in N+1 adapter calls. + response.default_proc = ->(memo, key) { memo[key] = default_config } + response + end + end + end +end diff --git a/spec/flipper/adapters/fallback_to_cached_spec.rb b/spec/flipper/adapters/fallback_to_cached_spec.rb new file mode 100644 index 00000000..b46b6151 --- /dev/null +++ b/spec/flipper/adapters/fallback_to_cached_spec.rb @@ -0,0 +1,88 @@ +require 'flipper/adapters/fallback_to_cached' + +RSpec.describe Flipper::Adapters::FallbackToCached do + let(:adapter) { Flipper::Adapters::Memory.new } + let(:flipper) { Flipper.new(subject, memoize: false) } + let(:feature_a) { flipper[:malware_rule] } + let(:feature_b) { flipper[:spam_rule] } + + subject { described_class.new(adapter) } + + before do + feature_a.enable + feature_b.disable + end + + describe "#features" do + it "uses primary adapter by default and caches value" do + expect(adapter).to receive(:features).and_call_original + expect(subject.features).to_not be_empty + end + + it "falls back to cached value if primary adapter raises an error" do + subject.features + expect(adapter).to receive(:features).and_raise(StandardError) + expect(subject.features).to_not be_empty + end + + it "raises an error if primary adapter fails and cache is empty" do + expect(adapter).to receive(:features).and_raise(StandardError) + expect { subject.features }.to raise_error StandardError + end + end + + describe "#get" do + it "uses primary adapter by default and caches value" do + expect(adapter).to receive(:get).with(feature_a).and_call_original + expect(subject.get(feature_a)).to_not be_nil + end + + it "falls back to cached value if primary adapter raises an error" do + subject.get(feature_a) + expect(adapter).to receive(:get).with(feature_a).and_raise(StandardError) + expect(subject.get(feature_a)).to_not be_nil + end + + it "raises an error if primary adapter fails and cache is empty" do + expect(adapter).to receive(:get).with(feature_a).and_raise(StandardError) + expect { subject.get(feature_a) }.to raise_error StandardError + end + end + + describe "#get_multi" do + it "uses primary adapter by default and caches value" do + expect(adapter).to receive(:get_multi).with([feature_a, feature_b]).and_call_original + expect(subject.get_multi([feature_a, feature_b])).to_not be_empty + end + + it "falls back to cached value if primary adapter raises an error" do + subject.get_multi([feature_a, feature_b]) + expect(adapter).to receive(:get_multi).with([feature_a, feature_b]).and_raise(StandardError) + expect(subject.get_multi([feature_a, feature_b])).to_not be_empty + end + + it "raises an error if primary adapter fails and cache is empty" do + expect(adapter).to receive(:get_multi).with([feature_a, feature_b]).and_raise(StandardError) + expect { subject.get_multi([feature_a, feature_b]) }.to raise_error StandardError + end + end + + describe "#get_all" do + it "uses primary adapter by default and caches value" do + expect(adapter).to receive(:get_all).and_call_original + expect(subject.get_all).to_not be_empty + end + + it "falls back to cached value if primary adapter raises an error" do + subject.get_all + expect(adapter).to receive(:get_all).and_raise(StandardError) + expect(subject.get_all).to_not be_empty + end + + it "raises an error if primary adapter fails and cache is empty" do + subject.cache.clear + expect(adapter).to receive(:get_all).and_raise(StandardError) + expect { subject.get_all }.to raise_error StandardError + end + end +end