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

Sync Adapter #341

Merged
merged 26 commits into from
Feb 22, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
82e1bba
Start of the sync adapter
jnunemaker Feb 9, 2018
3aeba1d
Add some docs
jnunemaker Feb 9, 2018
b79e455
Merge branch 'master' into sync-adapter
jnunemaker Feb 9, 2018
b1342f5
Merge branch 'master' into sync-adapter
jnunemaker Feb 10, 2018
1bc13e7
Merge branch 'master' into sync-adapter
jnunemaker Feb 10, 2018
a4e6383
First pass at forcing synchronization on an interval
jnunemaker Feb 11, 2018
487daf2
Sync for all read methods in sync adapter
jnunemaker Feb 11, 2018
7eeb584
Minor: constant docs
jnunemaker Feb 11, 2018
3727139
Sync every 10 seconds by default instead of 1
jnunemaker Feb 11, 2018
62b8492
Make rubo happy
jnunemaker Feb 12, 2018
96b08ef
Add todo for sync adapter
jnunemaker Feb 12, 2018
71eca94
Stop synchronizer from raising and instrument any exceptions
jnunemaker Feb 12, 2018
e3890ef
Move sync adapter helper classes to their own files so things stay tidy
jnunemaker Feb 12, 2018
57dfada
Add synchronizer spec and start moving some specs there
jnunemaker Feb 18, 2018
413a434
Minor doc additions
jnunemaker Feb 21, 2018
1acb1d0
Add missing requires to sync stuff
jnunemaker Feb 21, 2018
5e84b20
Inline payload variable
jnunemaker Feb 21, 2018
81be23e
Dont run rubocop on start
jnunemaker Feb 21, 2018
9609173
Specs for interval synchronizer
jnunemaker Feb 21, 2018
6d3b38a
Add todo about jitter
jnunemaker Feb 22, 2018
6ce6201
Double check that a double add doesn't add to set twice for actors an…
jnunemaker Feb 22, 2018
1e02b60
Add specs for feature synchronizer
jnunemaker Feb 22, 2018
8d1c76f
Add feature sync tests to ensure minimum operations happen
jnunemaker Feb 22, 2018
3de4dc6
Switch from struct to PORO
jnunemaker Feb 22, 2018
4b5818e
Add todo for backgrounding remote get all
jnunemaker Feb 22, 2018
27d70a3
Make the specs for feature sync a bit more readable
jnunemaker Feb 22, 2018
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
5 changes: 4 additions & 1 deletion Guardfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ sass_options = {
}
guard 'sass', sass_options

guard :rubocop do
rubo_options = {
all_on_start: false,
}
guard :rubocop, rubo_options do
watch(/.+\.rb$/)
watch(%r{(?:.+/)?\.rubocop(?:_todo)?\.yml$}) { |m| File.dirname(m[0]) }
end
16 changes: 14 additions & 2 deletions lib/flipper/adapters/operation_logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@ module Adapters
class OperationLogger < SimpleDelegator
include ::Flipper::Adapter

Operation = Struct.new(:type, :args)
class Operation
attr_reader :type, :args

def initialize(type, args)
@type = type
@args = args
end
end

OperationTypes = [
:features,
Expand Down Expand Up @@ -93,7 +100,12 @@ def disable(feature, gate, thing)

# Public: Count the number of times a certain operation happened.
def count(type)
@operations.select { |operation| operation.type == type }.size
type(type).size
end

# Public: Get all operations of a certain type.
def type(type)
@operations.select { |operation| operation.type == type }
end

# Public: Get the last operation of a certain type.
Expand Down
93 changes: 93 additions & 0 deletions lib/flipper/adapters/sync.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
require "flipper/instrumenters/noop"
require "flipper/adapters/sync/synchronizer"
require "flipper/adapters/sync/interval_synchronizer"

module Flipper
module Adapters
# TODO: Syncing should happen in a background thread on a regular interval
# rather than in the main thread only when reads happen.
class Sync
include ::Flipper::Adapter

# Public: The name of the adapter.
attr_reader :name

# Public: Build a new sync instance.
#
# local - The local flipper adapter that should serve reads.
# remote - The remote flipper adpater that should serve writes and update
# the local on an interval.
# interval - The number of milliseconds between syncs from remote to
# local. Default value is set in IntervalSynchronizer.
def initialize(local, remote, options = {})
@name = :sync
@local = local
@remote = remote
@synchronizer = options.fetch(:synchronizer) do
instrumenter = options[:instrumenter]
sync_options = {}
sync_options[:instrumenter] = instrumenter if instrumenter
synchronizer = Synchronizer.new(@local, @remote, sync_options)
IntervalSynchronizer.new(synchronizer, interval: options[:interval])
end
sync
end

def features
sync
@local.features
end

def get(feature)
sync
@local.get(feature)
end

def get_multi(features)
sync
@local.get_multi(features)
end

def get_all
sync
@local.get_all
end

def add(feature)
result = @remote.add(feature)
@local.add(feature)
result
end

def remove(feature)
result = @remote.remove(feature)
@local.remove(feature)
result
end

def clear(feature)
result = @remote.clear(feature)
@local.clear(feature)
result
end

def enable(feature, gate, thing)
result = @remote.enable(feature, gate, thing)
@local.enable(feature, gate, thing)
result
end

def disable(feature, gate, thing)
result = @remote.disable(feature, gate, thing)
@local.disable(feature, gate, thing)
result
end

private

def sync
@synchronizer.call
end
end
end
end
117 changes: 117 additions & 0 deletions lib/flipper/adapters/sync/feature_synchronizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
require "flipper/actor"
require "flipper/gate_values"

module Flipper
module Adapters
class Sync
# Internal: Given a feature, local gate values and remote gate values,
# makes the local equal to the remote.
class FeatureSynchronizer
extend Forwardable

def_delegator :@local_gate_values, :boolean, :local_boolean
def_delegator :@local_gate_values, :actors, :local_actors
def_delegator :@local_gate_values, :groups, :local_groups
def_delegator :@local_gate_values, :percentage_of_actors,
:local_percentage_of_actors
def_delegator :@local_gate_values, :percentage_of_time,
:local_percentage_of_time

def_delegator :@remote_gate_values, :boolean, :remote_boolean
def_delegator :@remote_gate_values, :actors, :remote_actors
def_delegator :@remote_gate_values, :groups, :remote_groups
def_delegator :@remote_gate_values, :percentage_of_actors,
:remote_percentage_of_actors
def_delegator :@remote_gate_values, :percentage_of_time,
:remote_percentage_of_time

def initialize(feature, local_gate_values, remote_gate_values)
@feature = feature
@local_gate_values = local_gate_values
@remote_gate_values = remote_gate_values
end

def call
if remote_disabled?
return if local_disabled?
@feature.disable
elsif remote_boolean_enabled?
return if local_boolean_enabled?
@feature.enable
else
sync_actors
sync_groups
sync_percentage_of_actors
sync_percentage_of_time
end
end

private

def sync_actors
remote_actors_added = remote_actors - local_actors
remote_actors_added.each do |flipper_id|
@feature.enable_actor Actor.new(flipper_id)
end

remote_actors_removed = local_actors - remote_actors
remote_actors_removed.each do |flipper_id|
@feature.disable_actor Actor.new(flipper_id)
end
end

def sync_groups
remote_groups_added = remote_groups - local_groups
remote_groups_added.each do |group_name|
@feature.enable_group group_name
end

remote_groups_removed = local_groups - remote_groups
remote_groups_removed.each do |group_name|
@feature.disable_group group_name
end
end

def sync_percentage_of_actors
return if local_percentage_of_actors == remote_percentage_of_actors

@feature.enable_percentage_of_actors remote_percentage_of_actors
end

def sync_percentage_of_time
return if local_percentage_of_time == remote_percentage_of_time

@feature.enable_percentage_of_time remote_percentage_of_time
end

def default_config
@default_config ||= @feature.adapter.default_config
end

def default_gate_values
@default_gate_values ||= GateValues.new(default_config)
end

def default_gate_values?(gate_values)
gate_values == default_gate_values
end

def local_disabled?
default_gate_values? @local_gate_values
end

def remote_disabled?
default_gate_values? @remote_gate_values
end

def local_boolean_enabled?
local_boolean
end

def remote_boolean_enabled?
remote_boolean
end
end
end
end
end
49 changes: 49 additions & 0 deletions lib/flipper/adapters/sync/interval_synchronizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
module Flipper
module Adapters
class Sync
# Internal: Wraps a Synchronizer instance and only invokes it every
# N milliseconds.
class IntervalSynchronizer
# Private: Number of milliseconds between syncs (default: 10 seconds).
DEFAULT_INTERVAL_MS = 10_000

# Private
def self.now_ms
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
end

# Public: Initializes a new interval synchronizer.
#
# synchronizer - The Synchronizer to call when the interval has passed.
# interval - The Integer number of milliseconds between invocations of
# the wrapped synchronizer.
def initialize(synchronizer, interval: nil)
@synchronizer = synchronizer
@interval = interval || DEFAULT_INTERVAL_MS
# TODO: add jitter to this so all processes booting at the same time
# don't phone home at the same time.
@last_sync_at = 0
end

def call
return unless time_to_sync?

@last_sync_at = now_ms
@synchronizer.call

nil
end

private

def time_to_sync?
(now_ms - @last_sync_at) >= @interval
end

def now_ms
self.class.now_ms
end
end
end
end
end
48 changes: 48 additions & 0 deletions lib/flipper/adapters/sync/synchronizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
require "flipper/feature"
require "flipper/gate_values"
require "flipper/instrumenters/noop"
require "flipper/adapters/sync/feature_synchronizer"

module Flipper
module Adapters
class Sync
# Internal: Given a local and remote adapter, it can update the local to
# match the remote doing only the necessary enable/disable operations.
class Synchronizer
def initialize(local, remote, options = {})
@local = local
@remote = remote
@instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
end

def call
@instrumenter.instrument("synchronizer_call.flipper") { sync }
end

private

def sync
local_get_all = @local.get_all
# TODO: Move remote get all to background thread to minimize impact to
# whatever is happening in main thread.
remote_get_all = @remote.get_all

# Sync all the gate values.
remote_get_all.each do |feature_key, remote_gates_hash|
feature = Feature.new(feature_key, @local)
local_gates_hash = local_get_all[feature_key] || @local.default_config
local_gate_values = GateValues.new(local_gates_hash)
remote_gate_values = GateValues.new(remote_gates_hash)
FeatureSynchronizer.new(feature, local_gate_values, remote_gate_values).call
end

# Add features that are missing
features_to_add = remote_get_all.keys - local_get_all.keys
features_to_add.each { |key| Feature.new(key, @local).add }
rescue => exception
@instrumenter.instrument("synchronizer_exception.flipper", exception: exception)
end
end
end
end
end
2 changes: 2 additions & 0 deletions lib/flipper/spec/shared_adapter_specs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,12 @@
actor = Flipper::Actor.new('Flipper::Actor;22')
expect(subject.enable(feature, actor_gate, flipper.actor(actor))).to eq(true)
expect(subject.enable(feature, actor_gate, flipper.actor(actor))).to eq(true)
expect(subject.get(feature).fetch(:actors)).to eq(Set['Flipper::Actor;22'])
end

it 'can double enable a group without error' do
expect(subject.enable(feature, group_gate, flipper.group(:admins))).to eq(true)
expect(subject.enable(feature, group_gate, flipper.group(:admins))).to eq(true)
expect(subject.get(feature).fetch(:groups)).to eq(Set['admins'])
end
end
2 changes: 2 additions & 0 deletions lib/flipper/test/shared_adapter_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,13 @@ def test_can_double_enable_an_actor_without_error
actor = Flipper::Actor.new('Flipper::Actor;22')
assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(actor))
assert_equal true, @adapter.enable(@feature, @actor_gate, @flipper.actor(actor))
assert_equal Set['Flipper::Actor;22'], @adapter.get(@feature).fetch(:actors)
end

def test_can_double_enable_a_group_without_error
assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:admins))
assert_equal true, @adapter.enable(@feature, @group_gate, @flipper.group(:admins))
assert_equal Set['admins'], @adapter.get(@feature).fetch(:groups)
end
end
end
Expand Down
Loading