Skip to content
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
156 changes: 152 additions & 4 deletions lib/flipper/adapters/poll.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'flipper/adapters/sync/synchronizer'
require 'flipper/adapters/memory'
require 'flipper/poller'

module Flipper
Expand All @@ -7,6 +8,36 @@ class Poll
extend Forwardable
include ::Flipper::Adapter

class InFlightAdapter
extend Forwardable

def_delegators :@snapshot, :features, :get, :get_multi, :get_all
def_delegators :@adapter, :add, :remove, :clear, :enable, :disable

def initialize(snapshot, adapter)
@snapshot = snapshot
@adapter = adapter
end
end

class PendingSnapshotAdapter
extend Forwardable

def_delegators :read_adapter, :features, :get, :get_multi, :get_all
def_delegators :@adapter, :add, :remove, :clear, :enable, :disable

def initialize(snapshot, adapter)
@snapshot = snapshot
@adapter = adapter
end

private

def read_adapter
@snapshot.call || @adapter
end
end

# Deprecated
Poller = ::Flipper::Poller

Expand All @@ -17,8 +48,11 @@ class Poll
def initialize(poller, adapter)
@adapter = adapter
@poller = poller
@mutex = Mutex.new
@pid = Process.pid
@syncing = false
@last_synced_at = 0

@sync_failed = false
# If the adapter is empty, we need to sync before starting the poller.
# Yes, this will block the main thread, but that's better than thinking
# nothing is enabled.
Expand All @@ -33,6 +67,14 @@ def initialize(poller, adapter)
end
end

snapshot = begin
build_snapshot(adapter.get_all)
rescue
# Preserve the existing fail-open initialization behavior. The first
# successful request establishes the snapshot before a sync can run.
end
@snapshot = snapshot

@poller.start
end

Expand All @@ -41,11 +83,117 @@ def initialize(poller, adapter)
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
claim, value = claim_sync(poller_last_synced_at)
case claim
when :claimed
completed_snapshot = nil
begin
Flipper::Adapters::Sync::Synchronizer.new(
@adapter,
@poller.adapter,
local_get_all: value
).call
begin
completed_snapshot = build_snapshot(@adapter.get_all)
rescue
# The adapter is synchronized, but its completed state could not
# be captured. Keep serving the previous trusted snapshot to
# contenders and retry publication on the next request.
end
ensure
if completed_snapshot
complete_sync(poller_last_synced_at, completed_snapshot)
else
release_sync
end
end
@adapter
when :syncing, :contended, :snapshot_established
value
else
@adapter
end
end

# Internal: Attempts to claim the right to sync. Returns the status and
# the data associated with that status: the local state for :claimed or
# the latest coherent adapter for all other read statuses. Never blocks.
# Callers that lose an in-flight claim read from the snapshot rather than
# waiting on a sync in the middle of a request.
def claim_sync(poller_last_synced_at)
unless @mutex.try_lock
adapter = @snapshot || PendingSnapshotAdapter.new(-> { @snapshot }, @adapter)
return [:contended, adapter]
end

begin
reset_if_forked
return [:syncing, @snapshot] if @syncing
unless @snapshot
local_get_all = @adapter.get_all
established_snapshot = build_snapshot(local_get_all)
@snapshot = established_snapshot
return [:snapshot_established, established_snapshot]
end
return [:not_needed, nil] unless poller_last_synced_at > @last_synced_at

local_get_all = @adapter.get_all
unless @sync_failed
@snapshot = build_snapshot(local_get_all)
end
@syncing = true
[:claimed, local_get_all]
ensure
@mutex.unlock
end
end

def complete_sync(poller_last_synced_at, completed_snapshot)
@mutex.synchronize do
@last_synced_at = poller_last_synced_at
@snapshot = completed_snapshot
@sync_failed = false
@syncing = false
end
end

def build_snapshot(local_get_all)
snapshot = Flipper::Adapters::Memory.new(snapshot_copy(local_get_all))
InFlightAdapter.new(snapshot, @adapter)
end

def snapshot_copy(value)
case value
when Hash
value.each_with_object({}) do |(key, nested_value), copy|
copy[key] = snapshot_copy(nested_value)
end
when Set
Set.new(value.map { |nested_value| snapshot_copy(nested_value) })
when Array
value.map { |nested_value| snapshot_copy(nested_value) }
when String
value.dup
else
value
end
end

def release_sync
@mutex.synchronize do
@sync_failed = true
@syncing = false
end
end

def reset_if_forked
return if @pid == Process.pid

@pid = Process.pid
if @syncing
@syncing = false
@sync_failed = true
end
@adapter
end
end
end
Expand Down
47 changes: 42 additions & 5 deletions lib/flipper/adapters/sync/interval_synchronizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,62 @@ def initialize(synchronizer, interval: nil)
@interval = interval || DEFAULT_INTERVAL
# TODO: add jitter to this so all processes booting at the same time
# don't phone home at the same time.
@mutex = Mutex.new
@pid = Process.pid
@syncing = false
@last_sync_at = 0
end

def call
return unless time_to_sync?
return unless sync_needed?

@last_sync_at = now
@synchronizer.call
begin
@synchronizer.call
ensure
complete_sync
end

nil
end

private

def time_to_sync?
seconds_since_last_sync = now - @last_sync_at
def sync_needed?
return false unless @mutex.try_lock

begin
reset_if_forked
return false if @syncing

current_time = now
return false unless time_to_sync?(current_time)

@last_sync_at = current_time
@syncing = true
true
ensure
@mutex.unlock
end
end

def complete_sync
@mutex.synchronize do
@syncing = false
end
end

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

def reset_if_forked
return if @pid == Process.pid

@pid = Process.pid
@syncing = false
end

def now
Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
end
Expand Down
4 changes: 3 additions & 1 deletion lib/flipper/adapters/sync/synchronizer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ class Synchronizer
# :instrumenter - The instrumenter used to instrument.
# :raise - Should errors be raised (default: true).
# :cache_bust - Should cache busting be used for remote get_all (default: false).
# :local_get_all - Optional pre-fetched local adapter state.
def initialize(local, remote, options = {})
@local = local
@remote = remote
@instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
@raise = options.fetch(:raise, true)
@cache_bust = options.fetch(:cache_bust, false)
@local_get_all = options[:local_get_all]
end

# Public: Forces a sync.
Expand All @@ -39,7 +41,7 @@ def call
private

def sync
local_get_all = @local.get_all
local_get_all = @local_get_all || @local.get_all
remote_get_all = @remote.get_all(cache_bust: @cache_bust)

# Sync all the gate values.
Expand Down
Loading