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

Poll Memoizer #704

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions lib/flipper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def groups_registry=(registry)
require 'flipper/adapters/memory'
require 'flipper/adapters/strict'
require 'flipper/adapters/instrumented'
require 'flipper/adapters/poll'
require 'flipper/adapter_builder'
require 'flipper/configuration'
require 'flipper/dsl'
Expand All @@ -189,6 +190,7 @@ def groups_registry=(registry)
require 'flipper/identifier'
require 'flipper/middleware/memoizer'
require 'flipper/middleware/setup_env'
require 'flipper/middleware/sync'
require 'flipper/poller'
require 'flipper/registry'
require 'flipper/expression'
Expand Down
74 changes: 51 additions & 23 deletions lib/flipper/adapters/poll.rb
Original file line number Diff line number Diff line change
@@ -1,36 +1,64 @@
require 'flipper/adapters/sync/synchronizer'
require 'flipper/adapters/dual_write'
require 'flipper/poller'

module Flipper
module Adapters
class Poll
extend Forwardable
include ::Flipper::Adapter
# An adapter that keeps a local and remote adapter in sync via a background poller.
#
# Synchronization is performed when the adapter is accessed if the background
# poller has synced.
class Poll < DualWrite
# Public: The Poller used to sync in a background thread.
attr_reader :poller

# Deprecated
Poller = ::Flipper::Poller

attr_reader :adapter, :poller

def_delegators :synced_adapter, :features, :get, :get_multi, :get_all, :add, :remove, :clear, :enable, :disable

def initialize(poller, adapter)
@adapter = adapter
@poller = poller
# Instantiate a new Poll adapter.
#
# local = Flipper::Adapters::ActiveRecord.new
# remote = Flipper::Adapters::Http.new(url: 'http://example.com/flipper')
# adapter = Flipper::Adapters::Poll.new(local, remote, key: 'unique_poller_name', interval: 5)
#
# local - Local adapter that will be used for reads and gets synchronized on an interval.
# remote - Remote adapter that will be polled on an interval.
# key: The key used to identify the poller.
# **options: Options to pass to the poller. See Flipper::Poller for options.
def initialize(local, remote, key:, **options)
super(local, remote)
@name = :poll
@poller = Flipper::Poller.get(key, remote, options).tap(&:start)
bkeepers marked this conversation as resolved.
Show resolved Hide resolved
@last_synced_at = 0
@poller.start
@sync_automatically = true
end

private
# Public: Synchronizes the local adapter with the current state of the remote adapter.
# If given a block, the adapter will be synced once and then not synced again for the
# duration of the block.
#
# poll = Flipper::Adapters::Poll.new(local, remote)
# poll.sync do
# # Long running operation that doesn't need to be synced
# end
def sync
if @sync_automatically
poller_last_synced_at = @poller.last_synced_at.value
if poller_last_synced_at > @last_synced_at
@local.import(@poller.adapter)
@last_synced_at = poller_last_synced_at
end
end
if block_given?
begin
sync_automatically_was, @sync_automatically = @sync_automatically, false
yield
ensure
@sync_automatically = sync_automatically_was
end
end
end

def synced_adapter
@poller.start
poller_last_synced_at = @poller.last_synced_at.value
if poller_last_synced_at > @last_synced_at
Flipper::Adapters::Sync::Synchronizer.new(@adapter, @poller.adapter).call
@last_synced_at = poller_last_synced_at
%i[features get get_multi get_all add remove clear enable disable].each do |method|
define_method(method) do |*args|
sync { super(*args) }
end
@adapter
end
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/flipper/adapters/poll/poller.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
warn "DEPRECATION WARNING: Flipper::Adapters::Poll::Poller is deprecated. Use Flipper::Poller instead."
require 'flipper/adapters/poll'
Flipper::Adapters::Poll::Poller = ::Flipper::Poller
24 changes: 13 additions & 11 deletions lib/flipper/cloud/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
require "socket"
require "flipper/adapters/http"
require "flipper/adapters/poll"
require "flipper/poller"
require "flipper/adapters/memory"
require "flipper/adapters/dual_write"
require "flipper/adapters/sync/synchronizer"
Expand Down Expand Up @@ -38,6 +37,9 @@ class Configuration
# Public: net/http write timeout for all http requests (default: 5).
attr_accessor :write_timeout

# Public: Memoize adapter operations. Defaults to true.
attr_accessor :memoize

# Public: IO stream to send debug output too. Off by default.
#
# # for example, this would send all http request information to STDOUT
Expand Down Expand Up @@ -138,20 +140,19 @@ def log(message, level: :debug)
private

def app_adapter
read_adapter = sync_method == :webhook ? local_adapter : poll_adapter
Flipper::Adapters::DualWrite.new(read_adapter, http_adapter)
if sync_method == :webhook
Flipper::Adapters::DualWrite.new(local_adapter, http_adapter)
else
poll_adapter
end
end

def poller
Flipper::Poller.get(@url + @token, {
def poll_adapter
Flipper::Adapters::Poll.new(local_adapter, http_adapter,
key: @url + @token,
interval: sync_interval,
remote_adapter: http_adapter,
instrumenter: instrumenter,
}).tap(&:start)
end

def poll_adapter
Flipper::Adapters::Poll.new(poller, local_adapter)
)
end

def http_adapter
Expand Down Expand Up @@ -197,6 +198,7 @@ def setup_sync(options)
end

def setup_adapter(options)
set_option :memoize, options, default: true
set_option :local_adapter, options, default: -> { Adapters::Memory.new }, from_env: false
@adapter_block = ->(adapter) { adapter }
end
Expand Down
5 changes: 4 additions & 1 deletion lib/flipper/cloud/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ class DSL < SimpleDelegator

def initialize(cloud_configuration)
@cloud_configuration = cloud_configuration
super Flipper.new(@cloud_configuration.adapter, instrumenter: @cloud_configuration.instrumenter)
super Flipper.new(@cloud_configuration.adapter,
instrumenter: @cloud_configuration.instrumenter,
memoize: @cloud_configuration.memoize
)
end

def sync
Expand Down
17 changes: 15 additions & 2 deletions lib/flipper/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,21 @@ class DSL
def initialize(adapter, options = {})
@instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
memoize = options.fetch(:memoize, true)
adapter = Adapters::Memoizable.new(adapter) if memoize
@adapter = adapter
@adapter = if memoize == :poll
Adapters::Poll.new(Adapters::Memory.new, adapter,
key: 'memoizer',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If multiple DSL instances are created with memoize: :poll and different adapters, this will not behave as expected. I wonder if Poller.get should also look for equality in the adapters. 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the key just be unique per adapter?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or rather a better way to phrase that, is there a way to make the key unique per instance? We have the name. That would help with different adapters. Perhaps there is another way to add uniqueness per instance too... hmm

interval: 5,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXME: make configurable

instrumenter: @instrumenter
).tap do |poll|
# Force poller to sync in current thread now
poll.poller.sync
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be tempted to make a method for this to avoid reaching into poller.

end
elsif memoize
Adapters::Memoizable.new(adapter)
else
adapter
end

@memoized_features = {}
end

Expand Down
12 changes: 9 additions & 3 deletions lib/flipper/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,14 @@ class Engine < Rails::Engine
if cloud?
Flipper::Cloud.new(
local_adapter: config.adapter,
instrumenter: app.config.flipper.instrumenter
instrumenter: app.config.flipper.instrumenter,
memoize: app.config.flipper.memoize
)
else
Flipper.new(config.adapter, instrumenter: app.config.flipper.instrumenter)
Flipper.new(config.adapter, {
instrumenter: app.config.flipper.instrumenter,
memoize: app.config.flipper.memoize,
})
end
end
end
Expand All @@ -57,7 +61,9 @@ class Engine < Rails::Engine
initializer "flipper.memoizer", after: :load_config_initializers do |app|
flipper = app.config.flipper

if flipper.memoize
if flipper.memoize == :poll
app.middleware.use Flipper::Middleware::Sync
elsif flipper.memoize
app.middleware.use Flipper::Middleware::Memoizer, {
env_key: flipper.env_key,
preload: flipper.preload,
Expand Down
6 changes: 5 additions & 1 deletion lib/flipper/middleware/memoizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ def call(env)
private

def memoize?(request)
if @opts[:if]
flipper = request.env.fetch(@env_key) { Flipper }

if !flipper.adapter.is_a?(Flipper::Adapters::Memoizable)
return false
elsif @opts[:if]
@opts[:if].call(request)
elsif @opts[:unless]
!@opts[:unless].call(request)
Expand Down
19 changes: 19 additions & 0 deletions lib/flipper/middleware/sync.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module Flipper
module Middleware
class Sync
def initialize(app, options = {})
@app = app
@env_key = options.fetch(:env_key, 'flipper')
end

def call(env)
flipper = env.fetch(@env_key) { Flipper }
if flipper.adapter.respond_to?(:sync)
flipper.adapter.sync { @app.call(env) }
else
@app.call(env)
end
end
end
end
end
24 changes: 11 additions & 13 deletions lib/flipper/poller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,32 @@ def self.instances
end
private_class_method :instances

def self.get(key, options = {})
instances.compute_if_absent(key) { new(options) }
def self.get(key, *args)
instances.compute_if_absent(key) { new(*args) }
end

def self.reset
instances.each {|_, instance| instance.stop }.clear
end

def initialize(options = {})
@thread = nil
@pid = Process.pid
@mutex = Mutex.new
def initialize(remote_adapter, options = {})
@remote_adapter = remote_adapter
@instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
@remote_adapter = options.fetch(:remote_adapter)
@interval = options.fetch(:interval, 10).to_f
@last_synced_at = Concurrent::AtomicFixnum.new(0)
@adapter = Adapters::Memory.new(nil, threadsafe: true)

if @interval < 1
warn "Flipper::Cloud poll interval must be greater than or equal to 1 but was #{@interval}. Setting @interval to 1."
@interval = 1
end

@start_automatically = options.fetch(:start_automatically, true)
@thread = nil
@pid = Process.pid
@mutex = Mutex.new
@last_synced_at = Concurrent::AtomicFixnum.new(0)
@adapter = Adapters::Memory.new

if options.fetch(:shutdown_automatically, true)
at_exit { stop }
end
start if options.fetch(:start_automatically, true)
at_exit { stop } if options.fetch(:shutdown_automatically, true)
end

def start
Expand Down
66 changes: 66 additions & 0 deletions spec/flipper/adapters/poll_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
require 'flipper/adapters/poll'
require 'flipper/adapters/operation_logger'
require 'active_support/notifications'

RSpec.describe Flipper::Adapters::Poll do
let(:remote_adapter) do
Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new
end
let(:local_adapter) do
Flipper::Adapters::OperationLogger.new Flipper::Adapters::Memory.new
end
let(:local) { Flipper.new(local_adapter) }
let(:remote) { Flipper.new(remote_adapter) }
let(:poll) { Flipper.new(subject) }

subject do
described_class.new(local_adapter, remote_adapter, key: 'test', start_automatically: false)
end

it_should_behave_like 'a flipper adapter'

it 'syncs features when poller has been synced' do
remote.enable(:search)

subject.poller.sync # sync poller from remote

expect(subject.poller.adapter).to receive(:get_all).and_call_original
expect(poll[:search].boolean_value).to be(true)
expect(subject.features.sort).to eq(%w(search))
end

it 'writes to both local and remote' do
poll.enable(:search)

expect(local[:search].boolean_value).to be(true)
expect(remote[:search].boolean_value).to be(true)
end

it 'does not sync features with poller has not been synced' do
# Perform initial sync
subject.poller.sync
subject.features

# Remote feature enabled, but poller has not synced yet
remote.enable(:search)
expect(subject.poller.adapter).to_not receive(:get_all)

expect(subject.features.sort).to eq(%w())
end

describe '#sync' do
it "performs initial sync and then does not sync during block" do
remote.enable(:search)
subject.poller.sync # Sync poller

subject.sync do
expect(poll[:search].boolean_value).to be(true)

remote.enable(:stats)
subject.poller.sync # Sync poller

expect(poll[:stats].boolean_value).to be(false)
end
end
end
end
4 changes: 2 additions & 2 deletions spec/flipper/cloud/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")

instance = described_class.new(required_options.merge(sync_interval: 20))
poller = instance.send(:poller)
poller = instance.send(:poll_adapter).poller
expect(poller.interval).to eq(20)
end

Expand All @@ -91,7 +91,7 @@
stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")

instance = described_class.new(required_options)
expect(instance.adapter).to be_instance_of(Flipper::Adapters::DualWrite)
expect(instance.adapter).to be_instance_of(Flipper::Adapters::Poll)
end

it "can override adapter block" do
Expand Down