diff --git a/docs/DockerCompose.md b/docs/DockerCompose.md index 8266d19a6..77c460969 100644 --- a/docs/DockerCompose.md +++ b/docs/DockerCompose.md @@ -7,9 +7,11 @@ new contributor could start working on code with a minumum efforts. ## Steps: 1. Install Docker Compose https://docs.docker.com/compose/install -2. Install gems `docker-compose run --rm app bundle install` -3. Run specs `docker-compose run --rm app bundle exec rspec` -4. Optional: log in to container an using a bash for running specs +1. Build the app container `docker-compose build` +1. Install gems `docker-compose run --rm app bundle install` +1. Run specs `docker-compose run --rm app bundle exec rspec` +1. Run tests `docker-compose run --rm app bundle exec rake test` +1. Optional: log in to container an using a bash for running specs ```sh docker-compose run --rm app bash bundle exec rspec diff --git a/lib/flipper/adapters/redis_cache.rb b/lib/flipper/adapters/redis_cache.rb new file mode 100644 index 000000000..6d0312261 --- /dev/null +++ b/lib/flipper/adapters/redis_cache.rb @@ -0,0 +1,128 @@ +require 'redis' + +module Flipper + module Adapters + # Public: Adapter that wraps another adapter with the ability to cache + # adapter calls in Redis + class RedisCache + include ::Flipper::Adapter + + Version = "v1".freeze + Namespace = "flipper/#{Version}".freeze + FeaturesKey = "#{Namespace}/features".freeze + + # Private + def self.key_for(key) + "#{Namespace}/feature/#{key}" + end + + # Internal + attr_reader :cache + + # Public: The name of the adapter. + attr_reader :name + + # Public + def initialize(adapter, cache, ttl = 3600) + @adapter = adapter + @name = :redis_cache + @cache = cache + @ttl = ttl + end + + # Public + def features + fetch(FeaturesKey) do + @adapter.features + end + end + + # Public + def add(feature) + result = @adapter.add(feature) + @cache.del(FeaturesKey) + result + end + + # Public + def remove(feature) + result = @adapter.remove(feature) + @cache.del(FeaturesKey) + @cache.del(key_for(feature.key)) + result + end + + # Public + def clear(feature) + result = @adapter.clear(feature) + @cache.del(key_for(feature.key)) + result + end + + # Public + def get(feature) + fetch(key_for(feature.key)) do + @adapter.get(feature) + end + end + + def get_multi(features) + keys = features.map { |feature| feature.key } + result = Hash[keys.zip(multi_cache_get(keys))] + uncached_features = features.reject do |feature| + result[feature.key] + end + + if uncached_features.any? + response = @adapter.get_multi(uncached_features) + response.each do |key, value| + set_with_ttl(key_for(key), value) + result[key] = value + end + end + result + end + + # Public + def enable(feature, gate, thing) + result = @adapter.enable(feature, gate, thing) + @cache.del(key_for(feature.key)) + result + end + + # Public + def disable(feature, gate, thing) + result = @adapter.disable(feature, gate, thing) + @cache.del(key_for(feature.key)) + result + end + + private + + def key_for(key) + self.class.key_for(key) + end + + def fetch(key, &block) + if cached = @cache.get(key) + return Marshal.load(cached) + else + to_cache = block.call + set_with_ttl(key, to_cache) + to_cache + end + end + + def set_with_ttl(key, value) + @cache.setex(key, @ttl, Marshal.dump(value)) + end + + def multi_cache_get(keys) + cache_keys = keys.map { |key| key_for(key) } + @cache.mget(cache_keys).map do |value| + value ? Marshal.load(value) : nil + end + end + end + end +end diff --git a/spec/flipper/adapters/dalli_spec.rb b/spec/flipper/adapters/dalli_spec.rb index ceeb09dd3..e2c45ba36 100644 --- a/spec/flipper/adapters/dalli_spec.rb +++ b/spec/flipper/adapters/dalli_spec.rb @@ -9,7 +9,7 @@ let(:adapter) { Flipper::Adapters::Dalli.new(memory_adapter, cache) } let(:flipper) { Flipper.new(adapter) } - subject { described_class.new(adapter, cache) } + subject { adapter } before do cache.flush diff --git a/spec/flipper/adapters/redis_cache_spec.rb b/spec/flipper/adapters/redis_cache_spec.rb new file mode 100644 index 000000000..0ed6536bd --- /dev/null +++ b/spec/flipper/adapters/redis_cache_spec.rb @@ -0,0 +1,65 @@ +require 'helper' +require 'flipper/adapters/redis_cache' +require 'flipper/spec/shared_adapter_specs' + +RSpec.describe Flipper::Adapters::RedisCache do + let(:client) { + options = {} + + if ENV['BOXEN_REDIS_URL'] + options[:url] = ENV['BOXEN_REDIS_URL'] + end + + Redis.new(options) + } + + let(:memory_adapter) { Flipper::Adapters::Memory.new } + let(:cache) { Redis.new({url: ENV.fetch('BOXEN_REDIS_URL', 'redis://localhost:6379')}) } + let(:adapter) { Flipper::Adapters::RedisCache.new(memory_adapter, cache) } + let(:flipper) { Flipper.new(adapter) } + + subject { adapter } + + before do + client.flushdb + end + + it_should_behave_like 'a flipper adapter' + + describe "#remove" do + it "expires feature" do + feature = flipper[:stats] + adapter.get(feature) + adapter.remove(feature) + expect(cache.get(described_class.key_for(feature))).to be(nil) + end + end + + describe "#get_multi" do + it "warms uncached features" do + stats = flipper[:stats] + search = flipper[:search] + other = flipper[:other] + stats.enable + search.enable + + adapter.get(stats) + expect(cache.get(described_class.key_for(search))).to be(nil) + expect(cache.get(described_class.key_for(other))).to be(nil) + + adapter.get_multi([stats, search, other]) + + search_cache_value, other_cache_value = [search, other].map do |f| + Marshal.load(cache.get(described_class.key_for(f))) + end + expect(search_cache_value[:boolean]).to eq("true") + expect(other_cache_value[:boolean]).to be(nil) + end + end + + describe "#name" do + it "is redis_cache" do + expect(subject.name).to be(:redis_cache) + end + end +end diff --git a/test/adapters/dalli_test.rb b/test/adapters/dalli_test.rb index 1be510238..bb903cc8c 100644 --- a/test/adapters/dalli_test.rb +++ b/test/adapters/dalli_test.rb @@ -6,7 +6,8 @@ class DalliTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests def setup - @cache = Dalli::Client.new('localhost:11211') + url = ENV.fetch('BOXEN_MEMCACHED_URL', 'localhost:11211') + @cache = Dalli::Client.new(url) @cache.flush memory_adapter = Flipper::Adapters::Memory.new @adapter = Flipper::Adapters::Dalli.new(memory_adapter, @cache) diff --git a/test/adapters/mongo_test.rb b/test/adapters/mongo_test.rb index bbdb504f1..a96cb238f 100644 --- a/test/adapters/mongo_test.rb +++ b/test/adapters/mongo_test.rb @@ -5,7 +5,7 @@ class MongoTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests def setup - host = '127.0.0.1' + host = ENV.fetch('BOXEN_MONGODB_HOST', '127.0.0.1') port = '27017' logger = Logger.new("/dev/null") collection = Mongo::Client.new(["#{host}:#{port}"], server_selection_timeout: 1, database: 'testing', logger: logger)['testing'] diff --git a/test/adapters/redis_cache_test.rb b/test/adapters/redis_cache_test.rb new file mode 100644 index 000000000..e6e628d2b --- /dev/null +++ b/test/adapters/redis_cache_test.rb @@ -0,0 +1,18 @@ +require 'test_helper' +require 'flipper/adapters/memory' +require 'flipper/adapters/redis_cache' + +class DalliTest < MiniTest::Test + prepend Flipper::Test::SharedAdapterTests + + def setup + url = ENV.fetch('BOXEN_REDIS_URL', 'redis://localhost:6379') + @cache = Redis.new({url: url}).tap { |c| c.flushdb } + memory_adapter = Flipper::Adapters::Memory.new + @adapter = Flipper::Adapters::RedisCache.new(memory_adapter, @cache) + end + + def teardown + @cache.flushdb + end +end diff --git a/test/adapters/redis_test.rb b/test/adapters/redis_test.rb index b626c9542..5cc73fc9e 100644 --- a/test/adapters/redis_test.rb +++ b/test/adapters/redis_test.rb @@ -5,7 +5,8 @@ class RedisTest < MiniTest::Test prepend Flipper::Test::SharedAdapterTests def setup - client = Redis.new({}).tap { |c| c.flushdb } - @adapter = Flipper::Adapters::Redis.new(client) + url = ENV.fetch('BOXEN_REDIS_URL', 'redis://localhost:6379') + client = Redis.new({url: url}).tap { |c| c.flushdb } + @adapter = Flipper::Adapters::Redis.new(client) end end