Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RedisCacheConnectionPool adapter #826

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ gem 'mysql2'
gem 'pg'
gem 'cuprite'
gem 'puma'
gem 'connection_pool'

group(:guard) do
gem 'guard'
Expand Down
165 changes: 165 additions & 0 deletions lib/flipper/adapters/redis_cache_connection_pool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
require "redis"
require "flipper"

module Flipper
module Adapters
# Public: Adapter that wraps another adapter with the ability to cache
# adapter calls in Redis
class RedisCacheConnectionPool
include ::Flipper::Adapter

# Internal
attr_reader :pool

# Public
def initialize(adapter, pool, ttl = 3600)
@adapter = adapter
@pool = pool
@ttl = ttl

@version = "v1"
@namespace = "flipper/#{@version}"
@features_key = "#{@namespace}/features"
@get_all_key = "#{@namespace}/get_all"
end

# Public
def features
read_feature_keys
end

# Public
def add(feature)
result = @adapter.add(feature)
@pool.with do |conn|
conn.del(@features_key)
end
result
end

# Public
def remove(feature)
result = @adapter.remove(feature)
@pool.with do |conn|
conn.del(@features_key)
conn.del(key_for(feature.key))
end
result
end

# Public
def clear(feature)
result = @adapter.clear(feature)
@pool.with do |conn|
conn.del(key_for(feature.key))
end
result
end

# Public
def get(feature)
fetch(key_for(feature.key)) do
@adapter.get(feature)
end
end

def get_multi(features)
read_many_features(features)
end

def get_all
@pool.with do |conn|
if conn.setnx(@get_all_key, Time.now.to_i)
conn.expire(@get_all_key, @ttl)
response = @adapter.get_all
response.each do |key, value|
set_with_ttl key_for(key), value
end
set_with_ttl @features_key, response.keys.to_set
response
else
features = read_feature_keys.map { |key| Flipper::Feature.new(key, self) }
read_many_features(features)
end
end
end

# Public
def enable(feature, gate, thing)
result = @adapter.enable(feature, gate, thing)
@pool.with do |conn|
conn.del(key_for(feature.key))
end
result
end

# Public
def disable(feature, gate, thing)
result = @adapter.disable(feature, gate, thing)
@pool.with do |conn|
conn.del(key_for(feature.key))
end
result
end

private
def key_for(key)
"#{@namespace}/feature/#{key}"
end

def read_feature_keys
fetch(@features_key) { @adapter.features }
end

def read_many_features(features)
keys = features.map(&:key)
cache_result = Hash[keys.zip(multi_cache_get(keys))]
uncached_features = features.reject { |feature| cache_result[feature.key] }

if uncached_features.any?
response = @adapter.get_multi(uncached_features)
response.each do |key, value|
set_with_ttl(key_for(key), value)
cache_result[key] = value
end
end

result = {}
features.each do |feature|
result[feature.key] = cache_result[feature.key]
end
result
end

def fetch(cache_key)
cached = @pool.with do |conn|
conn.get(cache_key)
end
if cached
Marshal.load(cached)
else
to_cache = yield
set_with_ttl(cache_key, to_cache)
to_cache
end
end

def set_with_ttl(key, value)
@pool.with do |conn|
conn.setex(key, @ttl, Marshal.dump(value))
end
end

def multi_cache_get(keys)
return [] if keys.empty?

cache_keys = keys.map { |key| key_for(key) }
@pool.with do |conn|
conn.mget(*cache_keys).map do |value|
value ? Marshal.load(value) : nil
end
end
end
end
end
end
105 changes: 105 additions & 0 deletions spec/flipper/adapters/redis_cache_connection_pool_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
require 'flipper/adapters/operation_logger'
require 'flipper/adapters/redis_cache_connection_pool'
require 'connection_pool'

RSpec.describe Flipper::Adapters::RedisCacheConnectionPool do
let(:client) do
options = {}
options[:url] = ENV['REDIS_URL'] if ENV['REDIS_URL']
ConnectionPool.new(size: 1, timeout: 1) { Redis.new(options) }
end

let(:memory_adapter) do
Flipper::Adapters::OperationLogger.new(Flipper::Adapters::Memory.new)
end
let(:adapter) { described_class.new(memory_adapter, client) }
let(:flipper) { Flipper.new(adapter) }

subject { adapter }

before do
skip_on_error(Redis::CannotConnectError, 'Redis not available') do
client.with { |conn| conn.flushdb }
end
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(client.with { |conn| conn.get("flipper/v1/feature/#{feature.key}") }).to be(nil)
end
end

describe '#get' do
it 'uses correct cache key' do
stats = flipper[:stats]
adapter.get(stats)
expect(client.with { |conn| conn.get("flipper/v1/feature/#{stats.key}") }).not_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

memory_adapter.reset

adapter.get(stats)
expect(client.with { |conn| conn.get("flipper/v1/feature/#{search.key}") }).to be(nil)
expect(client.with { |conn| conn.get("flipper/v1/feature/#{other.key}") }).to be(nil)

adapter.get_multi([stats, search, other])

search_cache_value, other_cache_value = [search, other].map do |f|
Marshal.load(client.with { |conn| conn.get("flipper/v1/feature/#{f.key}") })
end
expect(search_cache_value[:boolean]).to eq('true')
expect(other_cache_value[:boolean]).to be(nil)

adapter.get_multi([stats, search, other])
adapter.get_multi([stats, search, other])
expect(memory_adapter.count(:get_multi)).to eq(1)
end
end

describe '#get_all' do
let(:stats) { flipper[:stats] }
let(:search) { flipper[:search] }

before do
stats.enable
search.add
end

it 'warms all features' do
adapter.get_all
expect(Marshal.load(client.with { |conn| conn.get("flipper/v1/feature/#{stats.key}") })[:boolean]).to eq('true')
expect(Marshal.load(client.with { |conn| conn.get("flipper/v1/feature/#{search.key}") })[:boolean]).to be(nil)
expect(client.with { |conn| conn.get("flipper/v1/get_all").to_i }).to be_within(2).of(Time.now.to_i)
end

it 'returns same result when already cached' do
expect(adapter.get_all).to eq(adapter.get_all)
end

it 'only invokes one call to wrapped adapter' do
memory_adapter.reset
5.times { adapter.get_all }
expect(memory_adapter.count(:get_all)).to eq(1)
end
end

describe '#name' do
it 'is redis_cache_connection_pool' do
expect(subject.name).to be(:redis_cache_connection_pool)
end
end
end
Loading