diff --git a/Gemfile b/Gemfile index 1a5c0b39..b4551fa6 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,7 @@ Dir['flipper-*.gemspec'].each do |gemspec| end gem 'concurrent-ruby', '1.3.4' +gem 'connection_pool' gem 'debug' gem 'rake' gem 'statsd-ruby', '~> 1.2.1' diff --git a/lib/flipper/adapters/redis_connection_pool.rb b/lib/flipper/adapters/redis_connection_pool.rb new file mode 100644 index 00000000..28e76114 --- /dev/null +++ b/lib/flipper/adapters/redis_connection_pool.rb @@ -0,0 +1,239 @@ +require 'set' +require 'redis' +require 'flipper' +require 'connection_pool' + +module Flipper + module Adapters + class RedisConnectionPool + include ::Flipper::Adapter + + def self.default_pool + return @default_pool if defined?(@default_pool) + + size = ENV.fetch('FLIPPER_REDIS_POOL_SIZE', 5).to_i + timeout = ENV.fetch('FLIPPER_REDIS_POOL_TIMEOUT', 5).to_i + @default_pool = ConnectionPool.new(size: size, timeout: timeout) do + Redis.new(url: ENV["FLIPPER_REDIS_URL"] || ENV["REDIS_URL"]) + end + end + + attr_reader :key_prefix + + def features_key + "#{key_prefix}flipper_features" + end + + def key_for(feature_name) + "#{key_prefix}#{feature_name}" + end + + # Public: Initializes a Redis flipper adapter. + # + # pool - The Redis connection pool to use. + # key_prefix - an optional prefix with which to namespace + # flipper's Redis keys + def initialize(pool, key_prefix: nil) + @pool = pool + @key_prefix = key_prefix + @sadd_returns_boolean = with_connection do |conn| + conn.class.respond_to?(:sadd_returns_boolean) && conn.class.sadd_returns_boolean + end + end + + # Public: The set of known features. + def features + read_feature_keys + end + + # Public: Adds a feature to the set of known features. + def add(feature) + if redis_sadd_returns_boolean? + with_connection { |conn| conn.sadd? features_key, feature.key } + else + with_connection { |conn| conn.sadd features_key, feature.key } + end + true + end + + # Public: Removes a feature from the set of known features. + def remove(feature) + if redis_sadd_returns_boolean? + with_connection { |conn| conn.srem? features_key, feature.key } + else + with_connection { |conn| conn.srem features_key, feature.key } + end + with_connection { |conn| conn.del key_for(feature.key) } + true + end + + # Public: Clears the gate values for a feature. + def clear(feature) + with_connection { |conn| conn.del key_for(feature.key) } + true + end + + # Public: Gets the values for all gates for a given feature. + # + # Returns a Hash of Flipper::Gate#key => value. + def get(feature) + doc = with_connection { |conn| doc_for(conn, feature) } + result_for_feature(feature, doc) + end + + def get_multi(features) + read_many_features(features) + end + + def get_all + features = read_feature_keys.map { |key| Flipper::Feature.new(key, self) } + read_many_features(features) + end + + # Public: Enables a gate for a given thing. + # + # feature - The Flipper::Feature for the gate. + # gate - The Flipper::Gate to enable. + # thing - The Flipper::Type being enabled for the gate. + # + # Returns true. + def enable(feature, gate, thing) + feature_key = key_for(feature.key) + case gate.data_type + when :boolean + clear(feature) + with_connection do |conn| + conn.hset feature_key, gate.key, thing.value.to_s + end + when :integer + with_connection do |conn| + conn.hset feature_key, gate.key, thing.value.to_s + end + when :set + with_connection do |conn| + conn.hset feature_key, to_field(gate, thing), 1 + end + when :json + with_connection do |conn| + conn.hset feature_key, gate.key, Typecast.to_json(thing.value) + end + else + unsupported_data_type gate.data_type + end + + true + end + + # Public: Disables a gate for a given thing. + # + # feature - The Flipper::Feature for the gate. + # gate - The Flipper::Gate to disable. + # thing - The Flipper::Type being disabled for the gate. + # + # Returns true. + def disable(feature, gate, thing) + feature_key = key_for(feature.key) + case gate.data_type + when :boolean + with_connection { |conn| conn.del feature_key } + when :integer + with_connection { |conn| conn.hset feature_key, gate.key, thing.value.to_s } + when :set + with_connection { |conn| conn.hdel feature_key, to_field(gate, thing) } + when :json + with_connection { |conn| conn.hdel feature_key, gate.key } + else + unsupported_data_type gate.data_type + end + + true + end + + private + + def with_connection(&block) + @pool.with(&block) + end + + def redis_sadd_returns_boolean? + @sadd_returns_boolean + end + + def read_many_features(features) + docs = docs_for(features) + result = {} + features.zip(docs) do |feature, doc| + result[feature.key] = result_for_feature(feature, doc) + end + result + end + + def read_feature_keys + with_connection { |conn| conn.smembers(features_key).to_set } + end + + def doc_for(connection, feature) + connection.hgetall(key_for(feature.key)) + end + + def docs_for(features) + with_connection do |conn| + conn.pipelined do |pipeline| + features.each do |feature| + doc_for(pipeline, feature) + end + end + end + end + + def result_for_feature(feature, doc) + result = {} + fields = doc.keys + + feature.gates.each do |gate| + result[gate.key] = + case gate.data_type + when :boolean, :integer + doc[gate.key.to_s] + when :set + fields_to_gate_value fields, gate + when :json + value = doc[gate.key.to_s] + Typecast.from_json(value) + else + unsupported_data_type gate.data_type + end + end + + result + end + + # Private: Converts gate and thing to hash key. + def to_field(gate, thing) + "#{gate.key}/#{thing.value}" + end + + # Private: Returns a set of values given an array of fields and a gate. + # + # Returns a Set of the values enabled for the gate. + def fields_to_gate_value(fields, gate) + regex = %r{^#{Regexp.escape(gate.key.to_s)}/} + keys = fields.grep(regex) + values = keys.map { |key| key.split('/', 2).last } + values.to_set + end + + # Private + def unsupported_data_type(data_type) + raise "#{data_type} is not supported by this adapter" + end + end + end +end + +Flipper.configure do |config| + config.adapter do + default_pool = Flipper::Adapters::RedisConnectionPool.default_pool + Flipper::Adapters::RedisConnectionPool.new(default_pool) + end +end diff --git a/spec/flipper/adapters/redis_connection_pool_spec.rb b/spec/flipper/adapters/redis_connection_pool_spec.rb new file mode 100644 index 00000000..f68b0310 --- /dev/null +++ b/spec/flipper/adapters/redis_connection_pool_spec.rb @@ -0,0 +1,60 @@ +require 'flipper/adapters/redis_connection_pool' + +RSpec.describe Flipper::Adapters::RedisConnectionPool do + let(:pool) do + options = {} + + options[:url] = ENV['REDIS_URL'] if ENV['REDIS_URL'] + + Redis.raise_deprecations = true + + ConnectionPool.new(size: 5, timeout: 5) { Redis.new(options) } + end + + subject { described_class.new(pool) } + + before do + skip_on_error(Redis::CannotConnectError, 'Redis not available') do + pool.with { |conn| conn.flushdb } + end + end + + it_should_behave_like 'a flipper adapter' + + it 'configures itself on load' do + Flipper.configuration = nil + Flipper.instance = nil + + silence { load 'flipper/adapters/redis_connection_pool.rb' } + + expect(Flipper.adapter.adapter).to be_a(Flipper::Adapters::RedisConnectionPool) + end + + describe 'with a key_prefix' do + let(:subject) { described_class.new(pool, key_prefix: "lockbox:") } + let(:feature) { Flipper::Feature.new(:search, subject) } + + it_should_behave_like 'a flipper adapter' + + it 'namespaces feature-keys' do + subject.add(feature) + + pool.with do |conn| + expect(conn.smembers("flipper_features")).to eq([]) + expect(conn.exists?("search")).to eq(false) + expect(conn.smembers("lockbox:flipper_features")).to eq(["search"]) + expect(conn.hgetall("lockbox:search")).not_to eq(nil) + end + end + + it "can remove namespaced keys" do + subject.add(feature) + + pool.with do |conn| + expect(conn.smembers("lockbox:flipper_features")).to eq(["search"]) + subject.remove(feature) + expect(conn.smembers("lockbox:flipper_features")).to be_empty + end + end + end +end