Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,6 @@ For outbound HTTP requests, use `Flipper::Adapters::Http::Client` instead of raw

Uses both RSpec (currently preferred for new tests) and Minitest. Shared adapter specs ensure consistency across all storage backends. Extensive testing across multiple Rails versions (5.0-8.0).

When adding a new method to the adapter interface or implementing a method across adapters, add shared coverage for the expected behavior in both adapter shared suites: `lib/flipper/spec/shared_adapter_specs.rb` for RSpec and `lib/flipper/test/shared_adapter_test.rb` for Minitest. Add more than one shared example/test when the behavior has multiple important cases, such as successful writes, rejected writes, fallback behavior, or default return values. Optional adapter capabilities should be advertised with `supports?(:capability_name)` so shared tests can enforce behavior for supported adapters and assert the default no-op contract for unsupported adapters.

`Flipper.configuration` is reset to nil before each spec (in `spec/spec_helper.rb`), but `Flipper::UI.configuration` is **not** globally reset. When modifying UI config in tests, set the value in `before` and reset it in `after` to match the existing pattern throughout the spec suite.
29 changes: 29 additions & 0 deletions lib/flipper/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ def read_only?
false
end

# Public: Whether this adapter supports an optional adapter capability.
def supports?(feature)
false
end

# Public: Read a named integer value from the adapter, or nil if absent.
# Adapters that support typed integer storage override this; the default
# is a no-op so unaware adapters degrade to today's behavior.
def read_integer(key)
nil
end

# Public: Atomically set a named integer to value if and only if the new
# value is strictly greater than the currently stored value. Returns true
# if the write happened, false if rejected or unsupported. Adapters that
# support typed integer storage override this.
def set_integer_if_greater(key, value)
false
end

# Public: Get all features and gate values in one call. Defaults to one call
# to features and another to get_multi. Feel free to override per adapter to
# make this more efficient.
Expand All @@ -36,6 +56,14 @@ def get_all(**kwargs)
get_multi(instances)
end

# Public: Get all features and the version that describes that exact result.
# Adapters with native snapshot/version support should override this. The
# default is intentionally unversioned because independent get_all and
# read_integer calls cannot prove the version describes the returned data.
def get_all_snapshot(**kwargs)
Flipper::Snapshot.new(features: get_all(**kwargs))
end

# Public: Get multiple features in one call. Defaults to one get per
# feature. Feel free to override per adapter to make this more efficient and
# reduce network calls.
Expand Down Expand Up @@ -94,5 +122,6 @@ def adapter_stack

require "set"
require "flipper/exporter"
require "flipper/snapshot"
require "flipper/feature"
require "flipper/adapters/sync/synchronizer"
47 changes: 47 additions & 0 deletions lib/flipper/adapters/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require_relative 'active_record/model'
require_relative 'active_record/feature'
require_relative 'active_record/gate'
require_relative 'active_record/kv_integer'

module Flipper
module Adapters
Expand Down Expand Up @@ -32,6 +33,7 @@ def initialize(options = {})
@name = options.fetch(:name, :active_record)
@feature_class = options.fetch(:feature_class) { Flipper::Adapters::ActiveRecord::Feature }
@gate_class = options.fetch(:gate_class) { Flipper::Adapters::ActiveRecord::Gate }
@kv_integer_class = options.fetch(:kv_integer_class) { Flipper::Adapters::ActiveRecord::KvInteger }
end

# Public: The set of known features.
Expand Down Expand Up @@ -180,13 +182,58 @@ def disable(feature, gate, thing)
true
end

def read_integer(key)
return nil unless kv_integer_table_present?
with_connection(@kv_integer_class) do
@kv_integer_class.where(key: key.to_s).limit(1).pluck(:value).first
end
end

def supports?(feature)
feature.to_sym == :versioned_integers ? kv_integer_table_present? : super
end

def set_integer_if_greater(key, value)
return false unless kv_integer_table_present?
value = value.to_i
key = key.to_s
with_write_connection(@kv_integer_class) do
updated = @kv_integer_class
.where(key: key)
.where("value < ?", value)
.update_all(value: value, updated_at: Time.current)
return true if updated > 0

begin
@kv_integer_class.create!(key: key, value: value)
return true
rescue ::ActiveRecord::RecordNotUnique
# Row exists. Either stored >= ours (steady-state rejection) or a
# concurrent insert raced us with a lower value. Retry UPDATE once;
# if it still matches nothing, stored is provably >= ours.
end

@kv_integer_class
.where(key: key)
.where("value < ?", value)
.update_all(value: value, updated_at: Time.current) > 0
end
end

# Private
def unsupported_data_type(data_type)
raise "#{data_type} is not supported by this adapter"
end

private

def kv_integer_table_present?
return @kv_integer_table_present if defined?(@kv_integer_table_present)
@kv_integer_table_present = with_connection(@kv_integer_class) { @kv_integer_class.table_exists? }
rescue ::ActiveRecord::StatementInvalid
false
end

def set(feature, gate, thing, options = {})
clear_feature = options.fetch(:clear, false)
json_feature = options.fetch(:json, false)
Expand Down
18 changes: 18 additions & 0 deletions lib/flipper/adapters/active_record/kv_integer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
require 'flipper/adapters/active_record/model'

module Flipper
module Adapters
class ActiveRecord
# Private: Do not use outside of this adapter.
class KvInteger < Model
self.table_name = [
Model.table_name_prefix,
"flipper_kv_integers",
Model.table_name_suffix,
].join

validates :key, presence: true
end
end
end
end
51 changes: 51 additions & 0 deletions lib/flipper/adapters/cache_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ def initialize(adapter, cache, ttl = 300, prefix: nil)
@namespace = @namespace.prepend(prefix) if prefix
@features_cache_key = "#{@namespace}/features"
@get_all_cache_key = "#{@namespace}/get_all"
@get_all_snapshot_cache_key = "#{@namespace}/get_all_snapshot"
end

# Public: Expire the cache for the set of all features with gates.
def expire_get_all_cache
cache_delete @get_all_cache_key
cache_delete @get_all_snapshot_cache_key
end

# Public: Expire the cache for the set of known feature names.
Expand Down Expand Up @@ -98,6 +100,22 @@ def get_all(**kwargs)
}
end

def get_all_snapshot(**kwargs)
if kwargs[:cache_bust]
snapshot = @adapter.get_all_snapshot(**kwargs)
write_snapshot_caches(snapshot)
else
cache_fetch(@get_all_snapshot_cache_key) {
snapshot = @adapter.get_all_snapshot(**kwargs)
write_snapshot_caches(snapshot)
}
end
end

def supports?(feature)
@adapter.supports?(feature)
end

# Public
def enable(feature, gate, thing)
result = @adapter.enable(feature, gate, thing)
Expand All @@ -112,15 +130,48 @@ def disable(feature, gate, thing)
result
end

# Public
def read_integer(key)
cache_fetch(integer_cache_key(key)) { @adapter.read_integer(key) }
end

# Public
def set_integer_if_greater(key, value)
@adapter.set_integer_if_greater(key, value).tap do
cache_delete(integer_cache_key(key))
cache_delete(@get_all_snapshot_cache_key)
end
end

# Public: Generate the cache key for a given feature.
#
# key - The String or Symbol feature key.
def feature_cache_key(key)
"#{@namespace}/feature/#{key}"
end

# Public: Generate the cache key for a given integer.
#
# key - The String or Symbol integer key.
def integer_cache_key(key)
"#{@namespace}/integer/#{key}"
end

private

def write_snapshot_caches(snapshot)
cacheable_snapshot = Flipper::Snapshot.new(features: snapshot.features, version: snapshot.version)
cache_write @get_all_snapshot_cache_key, cacheable_snapshot
cache_write @get_all_cache_key, snapshot.features
cache_write @features_cache_key, snapshot.features.keys.to_set
if snapshot.version
cache_write integer_cache_key(:sync_version), snapshot.version
else
cache_delete integer_cache_key(:sync_version)
end
cacheable_snapshot
end

def read_all_features(**kwargs)
@adapter.get_all(**kwargs)
end
Expand Down
18 changes: 18 additions & 0 deletions lib/flipper/adapters/dual_write.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ def get_all(**kwargs)
@local.get_all(**kwargs)
end

def get_all_snapshot(**kwargs)
@local.get_all_snapshot(**kwargs)
end

def supports?(feature)
@local.supports?(feature) && @remote.supports?(feature)
end

def add(feature)
@remote.add(feature).tap { @local.add(feature) }
end
Expand All @@ -58,6 +66,16 @@ def disable(feature, gate, thing)
@local.disable(feature, gate, thing)
end
end

def read_integer(key)
@local.read_integer(key)
end

def set_integer_if_greater(key, value)
accepted = @remote.set_integer_if_greater(key, value)
@local.set_integer_if_greater(key, value) if accepted
accepted
end
end
end
end
22 changes: 22 additions & 0 deletions lib/flipper/adapters/failover.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ def get_all(**kwargs)
@secondary.get_all(**kwargs)
end

def get_all_snapshot(**kwargs)
@primary.get_all_snapshot(**kwargs)
rescue *@errors
@secondary.get_all_snapshot(**kwargs)
end

def supports?(feature)
@primary.supports?(feature)
end

def add(feature)
@primary.add(feature).tap do
@secondary.add(feature) if @dual_write
Expand Down Expand Up @@ -80,6 +90,18 @@ def disable(feature, gate, thing)
@secondary.disable(feature, gate, thing) if @dual_write
end
end

def read_integer(key)
@primary.read_integer(key)
rescue *@errors
@secondary.read_integer(key)
end

def set_integer_if_greater(key, value)
accepted = @primary.set_integer_if_greater(key, value)
@secondary.set_integer_if_greater(key, value) if accepted && @dual_write
accepted
end
end
end
end
24 changes: 24 additions & 0 deletions lib/flipper/adapters/failsafe.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ def get_all(**kwargs)
{}
end

def get_all_snapshot(**kwargs)
@adapter.get_all_snapshot(**kwargs)
rescue *@errors
Flipper::Snapshot.new(features: {})
end

def supports?(feature)
@adapter.supports?(feature)
rescue *@errors
false
end

def enable(feature, gate, thing)
@adapter.enable(feature, gate, thing)
rescue *@errors
Expand All @@ -67,6 +79,18 @@ def disable(feature, gate, thing)
rescue *@errors
false
end

def read_integer(key)
@adapter.read_integer(key)
rescue *@errors
nil
end

def set_integer_if_greater(key, value)
@adapter.set_integer_if_greater(key, value)
rescue *@errors
false
end
end
end
end
Loading