From 74fc247fc08bb1af63e9dd89f2986098c4a0445b Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Tue, 28 Apr 2026 09:05:53 -0400 Subject: [PATCH 01/15] Add version-aware sync to skip redundant local writes Introduces read_integer and set_integer_if_greater on the adapter contract so the Synchronizer can short-circuit when the local store is already at or beyond the remote sync_version. Implements typed integer storage in Memory, Redis (Lua CAS), Mongo ($max upsert), ActiveRecord and Sequel (new flipper_kv_integers table with retry-on-race). The HTTP adapter exposes the server's version field from get_all responses. Adds a response_extensions hook on the features API action so servers can attach version and other fields to the response payload. Co-Authored-By: Claude Opus 4.7 --- lib/flipper/adapter.rb | 15 +++++ lib/flipper/adapters/active_record.rb | 41 ++++++++++++ .../adapters/active_record/kv_integer.rb | 18 ++++++ lib/flipper/adapters/http.rb | 7 ++ lib/flipper/adapters/memory.rb | 18 ++++++ lib/flipper/adapters/mongo.rb | 18 ++++++ lib/flipper/adapters/redis.rb | 24 +++++++ lib/flipper/adapters/sequel.rb | 39 +++++++++++ lib/flipper/adapters/sync/synchronizer.rb | 16 +++++ lib/flipper/api/v1/actions/features.rb | 19 +++++- .../flipper/templates/migration.erb | 8 +++ .../flipper/templates/sequel_migration.rb | 9 +++ .../03_create_flipper_kv_integers.rb.erb | 18 ++++++ spec/flipper/adapters/active_record_spec.rb | 52 +++++++++++++++ spec/flipper/adapters/http_spec.rb | 63 ++++++++++++++++++ spec/flipper/adapters/memory_spec.rb | 47 ++++++++++++++ spec/flipper/adapters/mongo_spec.rb | 29 +++++++++ spec/flipper/adapters/redis_spec.rb | 36 +++++++++++ spec/flipper/adapters/sequel_spec.rb | 38 +++++++++++ .../adapters/sync/synchronizer_spec.rb | 64 +++++++++++++++++++ spec/flipper/api/v1/actions/features_spec.rb | 51 +++++++++++++++ test/adapters/active_record_test.rb | 5 ++ .../flipper/active_record_generator_test.rb | 8 +++ 23 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 lib/flipper/adapters/active_record/kv_integer.rb create mode 100644 lib/generators/flipper/templates/update/migrations/03_create_flipper_kv_integers.rb.erb diff --git a/lib/flipper/adapter.rb b/lib/flipper/adapter.rb index 33f6c1fd1..65e279631 100644 --- a/lib/flipper/adapter.rb +++ b/lib/flipper/adapter.rb @@ -28,6 +28,21 @@ def read_only? 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. diff --git a/lib/flipper/adapters/active_record.rb b/lib/flipper/adapters/active_record.rb index 6733b46d4..ee5160082 100644 --- a/lib/flipper/adapters/active_record.rb +++ b/lib/flipper/adapters/active_record.rb @@ -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 @@ -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. @@ -180,6 +182,38 @@ 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).pick(:value) + end + 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 + # Two attempts: UPDATE-then-INSERT-on-miss, retry UPDATE once if a + # concurrent INSERT raced us. After two passes, stored is >= ours. + 2.times 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, or someone race-inserted. Loop. + end + end + false + end + end + # Private def unsupported_data_type(data_type) raise "#{data_type} is not supported by this adapter" @@ -187,6 +221,13 @@ def unsupported_data_type(data_type) 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 + @kv_integer_table_present = false + end + def set(feature, gate, thing, options = {}) clear_feature = options.fetch(:clear, false) json_feature = options.fetch(:json, false) diff --git a/lib/flipper/adapters/active_record/kv_integer.rb b/lib/flipper/adapters/active_record/kv_integer.rb new file mode 100644 index 000000000..e8cf670e9 --- /dev/null +++ b/lib/flipper/adapters/active_record/kv_integer.rb @@ -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 diff --git a/lib/flipper/adapters/http.rb b/lib/flipper/adapters/http.rb index ea73bdcfe..91d73e11b 100644 --- a/lib/flipper/adapters/http.rb +++ b/lib/flipper/adapters/http.rb @@ -25,6 +25,7 @@ def initialize(options = {}) @last_get_all_etag = nil @last_get_all_result = nil @last_get_all_response = nil + @last_sync_version = nil @get_all_mutex = Mutex.new end @@ -100,6 +101,7 @@ def get_all(cache_bust: false) @get_all_mutex.synchronize do @last_get_all_etag = response['etag'] if response['etag'] @last_get_all_result = result + @last_sync_version = parsed_response['version'] end result @@ -109,6 +111,11 @@ def last_get_all_response @get_all_mutex.synchronize { @last_get_all_response } end + def read_integer(key) + return nil unless key.to_sym == :sync_version + @get_all_mutex.synchronize { @last_sync_version } + end + def features response = @client.get('/features?exclude_gate_names=true') raise Error, response unless response.is_a?(Net::HTTPOK) diff --git a/lib/flipper/adapters/memory.rb b/lib/flipper/adapters/memory.rb index 67a312af9..b076229f3 100644 --- a/lib/flipper/adapters/memory.rb +++ b/lib/flipper/adapters/memory.rb @@ -11,6 +11,7 @@ class Memory # Public def initialize(source = nil, threadsafe: true) @source = Typecast.features_hash(source) + @integers = {} @lock = Mutex.new if threadsafe reset end @@ -120,6 +121,23 @@ def import(source) true end + def read_integer(key) + synchronize { @integers[key.to_s] } + end + + def set_integer_if_greater(key, value) + value = value.to_i + synchronize do + current = @integers[key.to_s] + if current.nil? || value > current + @integers[key.to_s] = value + true + else + false + end + end + end + private def reset diff --git a/lib/flipper/adapters/mongo.rb b/lib/flipper/adapters/mongo.rb index 598aebf71..7c1abc32f 100644 --- a/lib/flipper/adapters/mongo.rb +++ b/lib/flipper/adapters/mongo.rb @@ -89,6 +89,19 @@ def enable(feature, gate, thing) true end + def read_integer(key) + doc = @collection.find(_id: integer_id_for(key)).limit(1).first + doc && doc['value'] + end + + def set_integer_if_greater(key, value) + result = @collection.find(_id: integer_id_for(key)).update_one( + { '$max' => { 'value' => value.to_i } }, + upsert: true + ) + result.modified_count.to_i > 0 || result.upserted_count.to_i > 0 + end + # Public: Disables a gate for a given thing. # # feature - The Flipper::Feature for the gate. @@ -139,6 +152,11 @@ def unsupported_data_type(data_type) raise "#{data_type} is not supported by this adapter" end + # Private + def integer_id_for(key) + "flipper_int:#{key}" + end + # Private def find(key) @collection.find(_id: key.to_s).limit(1).first || {} diff --git a/lib/flipper/adapters/redis.rb b/lib/flipper/adapters/redis.rb index b196b42e7..2023a8be6 100644 --- a/lib/flipper/adapters/redis.rb +++ b/lib/flipper/adapters/redis.rb @@ -11,6 +11,15 @@ class Redis attr_reader :key_prefix + SET_INTEGER_IF_GREATER_LUA = <<~LUA.freeze + local current = redis.call('GET', KEYS[1]) + if current == false or tonumber(ARGV[1]) > tonumber(current) then + redis.call('SET', KEYS[1], ARGV[1]) + return 1 + end + return 0 + LUA + def features_key "#{key_prefix}flipper_features" end @@ -19,6 +28,10 @@ def key_for(feature_name) "#{key_prefix}#{feature_name}" end + def integer_key_for(key) + "#{key_prefix}flipper_int:#{key}" + end + # Public: Initializes a Redis flipper adapter. # # client - The Redis client to use. @@ -132,6 +145,17 @@ def disable(feature, gate, thing) true end + def read_integer(key) + value = with_connection { |conn| conn.get(integer_key_for(key)) } + value && value.to_i + end + + def set_integer_if_greater(key, value) + with_connection do |conn| + conn.public_send(:eval, SET_INTEGER_IF_GREATER_LUA, keys: [integer_key_for(key)], argv: [value.to_i]) == 1 + end + end + private def redis_sadd_returns_boolean? diff --git a/lib/flipper/adapters/sequel.rb b/lib/flipper/adapters/sequel.rb index afdf5adb4..530fd69d0 100644 --- a/lib/flipper/adapters/sequel.rb +++ b/lib/flipper/adapters/sequel.rb @@ -25,6 +25,11 @@ class Gate < ::Sequel::Model(:flipper_gates) plugin :timestamps, update_on_create: true end + + # Private: Do not use outside of this adapter. + class KvInteger < ::Sequel::Model(:flipper_kv_integers) + plugin :timestamps, update_on_create: true + end ensure ::Sequel::Model.require_valid_table = old end @@ -50,6 +55,7 @@ def initialize(options = {}) @name = options.fetch(:name, :sequel) @feature_class = options.fetch(:feature_class) { Feature } @gate_class = options.fetch(:gate_class) { Gate } + @kv_integer_class = options.fetch(:kv_integer_class) { KvInteger } warn VALUE_TO_TEXT_WARNING if value_not_text? end @@ -169,8 +175,41 @@ def disable(feature, gate, thing) true end + def read_integer(key) + return nil unless kv_integer_table_present? + @kv_integer_class.where(key: key.to_s).get(:value) + end + + def set_integer_if_greater(key, incoming) + return false unless kv_integer_table_present? + incoming = incoming.to_i + key = key.to_s + 2.times do + updated = @kv_integer_class + .where(key: key) + .where { value < incoming } + .update(value: incoming, updated_at: Time.now) + return true if updated > 0 + + begin + @kv_integer_class.insert(key: key, value: incoming, created_at: Time.now, updated_at: Time.now) + return true + rescue ::Sequel::UniqueConstraintViolation + # Row exists; either stored >= ours, or someone race-inserted. Loop. + end + end + false + end + private + def kv_integer_table_present? + return @kv_integer_table_present if defined?(@kv_integer_table_present) + @kv_integer_table_present = @kv_integer_class.db.table_exists?(@kv_integer_class.table_name) + rescue ::Sequel::DatabaseError + @kv_integer_table_present = false + end + def unsupported_data_type(data_type) raise "#{data_type} is not supported by this adapter" end diff --git a/lib/flipper/adapters/sync/synchronizer.rb b/lib/flipper/adapters/sync/synchronizer.rb index 7ce58a471..050d2bf0f 100644 --- a/lib/flipper/adapters/sync/synchronizer.rb +++ b/lib/flipper/adapters/sync/synchronizer.rb @@ -10,6 +10,8 @@ class Sync # Public: Given a local and remote adapter, it can update the local to # match the remote doing only the necessary enable/disable operations. class Synchronizer + SYNC_VERSION_KEY = :sync_version + # Public: Initializes a new synchronizer. # # local - The Flipper adapter to get in sync with the remote. @@ -42,6 +44,13 @@ def sync local_get_all = @local.get_all remote_get_all = @remote.get_all(cache_bust: @cache_bust) + remote_version = @remote.read_integer(SYNC_VERSION_KEY) + local_version = @local.read_integer(SYNC_VERSION_KEY) + + if remote_version && local_version && remote_version.to_i <= local_version.to_i + return + end + # Sync all the gate values. remote_get_all.each do |feature_key, remote_gates_hash| feature = Feature.new(feature_key, @local, instrumenter: @instrumenter) @@ -60,6 +69,13 @@ def sync features_to_remove = local_get_all.keys - remote_get_all.keys features_to_remove.each { |key| Feature.new(key, @local, instrumenter: @instrumenter).remove } + if remote_version + accepted = @local.set_integer_if_greater(SYNC_VERSION_KEY, remote_version) + unless accepted + @instrumenter.instrument("synchronizer_outvoted.flipper", remote_version: remote_version) + end + end + nil rescue => exception @instrumenter.instrument("synchronizer_exception.flipper", exception: exception) diff --git a/lib/flipper/api/v1/actions/features.rb b/lib/flipper/api/v1/actions/features.rb index 7b7af6756..cf11555f9 100644 --- a/lib/flipper/api/v1/actions/features.rb +++ b/lib/flipper/api/v1/actions/features.rb @@ -8,6 +8,20 @@ module Actions class Features < Api::Action route %r{\A/features/?\Z} + # Public: Procs registered here are called per GET request. Each + # receives the action instance and should return a Hash; the hashes + # are merged into the response body in registration order. Use this + # to add fields like protocol versions, server capabilities, or + # feature counts without subclassing or monkey-patching. + # + # Example: + # Flipper::Api::V1::Actions::Features.response_extensions << ->(action) { + # { version: action.request.env["x-version"].to_i } + # } + def self.response_extensions + @response_extensions ||= [] + end + def get keys = params['keys'] exclude_gates = params['exclude_gates']&.downcase == "true" @@ -35,7 +49,10 @@ def get ) end - json_response(features: decorated_features) + extras = self.class.response_extensions.reduce({}) do |memo, ext| + memo.merge(ext.call(self)) + end + json_response({features: decorated_features}.merge(extras)) end def post diff --git a/lib/generators/flipper/templates/migration.erb b/lib/generators/flipper/templates/migration.erb index 178f0356b..fdb87c7d7 100644 --- a/lib/generators/flipper/templates/migration.erb +++ b/lib/generators/flipper/templates/migration.erb @@ -13,9 +13,17 @@ class CreateFlipperTables < ActiveRecord::Migration<%= migration_version %> t.timestamps null: false end add_index :flipper_gates, [:feature_key, :key, :value], unique: true, length: { value: 255 } + + create_table :flipper_kv_integers do |t| + t.string :key, null: false + t.bigint :value, null: false, default: 0 + t.timestamps null: false + end + add_index :flipper_kv_integers, :key, unique: true end def down + drop_table :flipper_kv_integers drop_table :flipper_gates drop_table :flipper_features end diff --git a/lib/generators/flipper/templates/sequel_migration.rb b/lib/generators/flipper/templates/sequel_migration.rb index 0ed08b7f0..6598e7fdc 100644 --- a/lib/generators/flipper/templates/sequel_migration.rb +++ b/lib/generators/flipper/templates/sequel_migration.rb @@ -14,9 +14,18 @@ def up DateTime :updated_at, null: false primary_key [:feature_key, :key, :value] end + + create_table :flipper_kv_integers do + primary_key :id + String :key, null: false, unique: true + Bignum :value, null: false, default: 0 + DateTime :created_at, null: false + DateTime :updated_at, null: false + end end def down + drop_table :flipper_kv_integers drop_table :flipper_gates drop_table :flipper_features end diff --git a/lib/generators/flipper/templates/update/migrations/03_create_flipper_kv_integers.rb.erb b/lib/generators/flipper/templates/update/migrations/03_create_flipper_kv_integers.rb.erb new file mode 100644 index 000000000..c92708f92 --- /dev/null +++ b/lib/generators/flipper/templates/update/migrations/03_create_flipper_kv_integers.rb.erb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateFlipperKvIntegers < ActiveRecord::Migration<%= migration_version %> + def up + return if table_exists? :flipper_kv_integers + + create_table :flipper_kv_integers do |t| + t.string :key, null: false + t.bigint :value, null: false, default: 0 + t.timestamps null: false + end + add_index :flipper_kv_integers, :key, unique: true + end + + def down + drop_table :flipper_kv_integers + end +end diff --git a/spec/flipper/adapters/active_record_spec.rb b/spec/flipper/adapters/active_record_spec.rb index 7fc90bf85..4aee863bd 100644 --- a/spec/flipper/adapters/active_record_spec.rb +++ b/spec/flipper/adapters/active_record_spec.rb @@ -78,6 +78,58 @@ flipper.preload([:foo]) end + describe 'read_integer / set_integer_if_greater' do + it 'returns nil for unknown keys' do + expect(subject.read_integer(:sync_version)).to be_nil + end + + it 'sets a new value when none exists' do + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'rejects a lower value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 99)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'rejects an equal value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'accepts a strictly greater value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 200)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(200) + end + + it 'tracks separate keys independently' do + subject.set_integer_if_greater(:foo, 100) + subject.set_integer_if_greater(:bar, 50) + expect(subject.read_integer(:foo)).to eq(100) + expect(subject.read_integer(:bar)).to eq(50) + end + + context 'when flipper_kv_integers table is missing' do + before do + silence { ActiveRecord::Base.connection.drop_table(:flipper_kv_integers) } + end + + it 'read_integer returns nil' do + fresh = described_class.new + expect(fresh.read_integer(:sync_version)).to be_nil + end + + it 'set_integer_if_greater returns false' do + fresh = described_class.new + expect(fresh.set_integer_if_greater(:sync_version, 100)).to eq(false) + end + end + end + it 'should not poison wrapping transactions' do flipper = Flipper.new(subject) diff --git a/spec/flipper/adapters/http_spec.rb b/spec/flipper/adapters/http_spec.rb index e9114e88e..936da090c 100644 --- a/spec/flipper/adapters/http_spec.rb +++ b/spec/flipper/adapters/http_spec.rb @@ -175,6 +175,69 @@ end end + describe "#read_integer" do + it "returns nil before any get_all has happened" do + adapter = described_class.new(url: 'http://app.com/flipper') + expect(adapter.read_integer(:sync_version)).to be_nil + end + + it "returns the version parsed from the most recent get_all response" do + features_response = { + "version" => 12345, + "features" => [], + } + stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") + .to_return(status: 200, body: JSON.generate(features_response)) + + adapter = described_class.new(url: 'http://app.com/flipper') + adapter.get_all + expect(adapter.read_integer(:sync_version)).to eq(12345) + end + + it "returns nil when the server response omits version (older server)" do + features_response = { "features" => [] } + stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") + .to_return(status: 200, body: JSON.generate(features_response)) + + adapter = described_class.new(url: 'http://app.com/flipper') + adapter.get_all + expect(adapter.read_integer(:sync_version)).to be_nil + end + + it "preserves the parsed version across 304 Not Modified responses" do + features_response = { + "version" => 12345, + "features" => [], + } + + stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") + .to_return(status: 200, body: JSON.generate(features_response), headers: { 'ETag' => '"abc"' }) + + adapter = described_class.new(url: 'http://app.com/flipper') + adapter.get_all + expect(adapter.read_integer(:sync_version)).to eq(12345) + + stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") + .with(headers: { 'If-None-Match' => '"abc"' }) + .to_return(status: 304, headers: { 'ETag' => '"abc"' }) + + adapter.get_all + expect(adapter.read_integer(:sync_version)).to eq(12345) + end + + it "returns nil for keys other than :sync_version" do + adapter = described_class.new(url: 'http://app.com/flipper') + expect(adapter.read_integer(:other_key)).to be_nil + end + end + + describe "#set_integer_if_greater" do + it "returns false (writes never go server-side via this method)" do + adapter = described_class.new(url: 'http://app.com/flipper') + expect(adapter.set_integer_if_greater(:sync_version, 100)).to eq(false) + end + end + describe "#get_all" do it "raises error when not successful response" do stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") diff --git a/spec/flipper/adapters/memory_spec.rb b/spec/flipper/adapters/memory_spec.rb index 5477bc490..b52c8a254 100644 --- a/spec/flipper/adapters/memory_spec.rb +++ b/spec/flipper/adapters/memory_spec.rb @@ -13,6 +13,53 @@ it_should_behave_like 'a flipper adapter' end + describe 'read_integer / set_integer_if_greater' do + subject { described_class.new } + + it 'returns nil for unknown keys' do + expect(subject.read_integer(:sync_version)).to be_nil + end + + it 'sets a new value when none exists' do + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'rejects a lower value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 99)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'rejects an equal value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'accepts a strictly greater value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 200)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(200) + end + + it 'tracks separate keys independently' do + subject.set_integer_if_greater(:foo, 100) + subject.set_integer_if_greater(:bar, 50) + expect(subject.read_integer(:foo)).to eq(100) + expect(subject.read_integer(:bar)).to eq(50) + end + + it 'is isolated from get_all and clear' do + flipper = Flipper.new(subject) + flipper.enable(:my_feature) + subject.set_integer_if_greater(:sync_version, 100) + + flipper[:my_feature].clear + expect(subject.read_integer(:sync_version)).to eq(100) + end + end + it "can initialize from big hash" do flipper = Flipper.new(subject) flipper.enable :subscriptions diff --git a/spec/flipper/adapters/mongo_spec.rb b/spec/flipper/adapters/mongo_spec.rb index c6b3bd6b0..cfd4ed4d5 100644 --- a/spec/flipper/adapters/mongo_spec.rb +++ b/spec/flipper/adapters/mongo_spec.rb @@ -26,6 +26,35 @@ it_should_behave_like 'a flipper adapter' + describe 'read_integer / set_integer_if_greater' do + it 'returns nil for unknown keys' do + expect(subject.read_integer(:sync_version)).to be_nil + end + + it 'sets a new value when none exists' do + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'rejects a lower value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 99)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'rejects an equal value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'accepts a strictly greater value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 200)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(200) + end + end + it 'configures itself on load' do Flipper.configuration = nil Flipper.instance = nil diff --git a/spec/flipper/adapters/redis_spec.rb b/spec/flipper/adapters/redis_spec.rb index e1852f324..1352570fe 100644 --- a/spec/flipper/adapters/redis_spec.rb +++ b/spec/flipper/adapters/redis_spec.rb @@ -18,6 +18,42 @@ it_should_behave_like 'a flipper adapter' + describe 'read_integer / set_integer_if_greater' do + it 'returns nil for unknown keys' do + expect(subject.read_integer(:sync_version)).to be_nil + end + + it 'sets a new value when none exists' do + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'rejects a lower value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 99)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'rejects an equal value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'accepts a strictly greater value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 200)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(200) + end + + it 'namespaces under key_prefix' do + prefixed = described_class.new(client, key_prefix: 'lockbox:') + prefixed.set_integer_if_greater(:sync_version, 100) + expect(client.get('lockbox:flipper_int:sync_version')).to eq('100') + expect(client.get('flipper_int:sync_version')).to be_nil + end + end + it 'configures itself on load' do Flipper.configuration = nil Flipper.instance = nil diff --git a/spec/flipper/adapters/sequel_spec.rb b/spec/flipper/adapters/sequel_spec.rb index 11744f532..5aa04da90 100644 --- a/spec/flipper/adapters/sequel_spec.rb +++ b/spec/flipper/adapters/sequel_spec.rb @@ -14,11 +14,13 @@ let(:feature_class) { Flipper::Adapters::Sequel::Feature } let(:gate_class) { Flipper::Adapters::Sequel::Gate } + let(:kv_integer_class) { Flipper::Adapters::Sequel::KvInteger } before(:each) do CreateFlipperTablesSequel.new(Sequel::Model.db).up feature_class.dataset = feature_class.dataset gate_class.dataset = gate_class.dataset + kv_integer_class.dataset = kv_integer_class.dataset end after(:each) do @@ -27,6 +29,42 @@ it_should_behave_like 'a flipper adapter' + describe 'read_integer / set_integer_if_greater' do + it 'returns nil for unknown keys' do + expect(subject.read_integer(:sync_version)).to be_nil + end + + it 'sets a new value when none exists' do + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'rejects a lower value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 99)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'rejects an equal value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'accepts a strictly greater value' do + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.set_integer_if_greater(:sync_version, 200)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(200) + end + + it 'tracks separate keys independently' do + subject.set_integer_if_greater(:foo, 100) + subject.set_integer_if_greater(:bar, 50) + expect(subject.read_integer(:foo)).to eq(100) + expect(subject.read_integer(:bar)).to eq(50) + end + end + context 'requiring "flipper-sequel"' do before do Flipper.configuration = nil diff --git a/spec/flipper/adapters/sync/synchronizer_spec.rb b/spec/flipper/adapters/sync/synchronizer_spec.rb index 6e6c19a99..d67417906 100644 --- a/spec/flipper/adapters/sync/synchronizer_spec.rb +++ b/spec/flipper/adapters/sync/synchronizer_spec.rb @@ -126,6 +126,70 @@ end end + describe 'sync_version gating' do + it 'skips sync when remote version is not strictly greater than local version' do + local.set_integer_if_greater(:sync_version, 100) + allow(remote).to receive(:read_integer).with(:sync_version).and_return(99) + remote_flipper.enable(:search) + + subject.call + + expect(local_flipper.features.map(&:key)).to eq([]) + end + + it 'skips sync when remote version equals local version' do + local.set_integer_if_greater(:sync_version, 100) + allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) + remote_flipper.enable(:search) + + subject.call + + expect(local_flipper.features.map(&:key)).to eq([]) + end + + it 'syncs and bumps local version when remote version is strictly greater' do + local.set_integer_if_greater(:sync_version, 99) + allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) + remote_flipper.enable(:search) + + subject.call + + expect(local_flipper.features.map(&:key)).to eq(["search"]) + expect(local.read_integer(:sync_version)).to eq(100) + end + + it 'syncs normally when remote returns nil version (older server)' do + allow(remote).to receive(:read_integer).with(:sync_version).and_return(nil) + remote_flipper.enable(:search) + + subject.call + + expect(local_flipper.features.map(&:key)).to eq(["search"]) + end + + it 'syncs normally when local has no stored version yet' do + allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) + remote_flipper.enable(:search) + + subject.call + + expect(local_flipper.features.map(&:key)).to eq(["search"]) + expect(local.read_integer(:sync_version)).to eq(100) + end + + it 'instruments synchronizer_outvoted.flipper when bump_sync_version is rejected' do + allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) + remote_flipper.enable(:search) + allow(local).to receive(:set_integer_if_greater).with(:sync_version, 100).and_return(false) + + subject.call + + events = instrumenter.events_by_name("synchronizer_outvoted.flipper") + expect(events.size).to eq(1) + expect(events.first.payload[:remote_version]).to eq(100) + end + end + context 'with ActorLimit adapter wrapping local' do let(:limit) { 10 } let(:limited_local) { Flipper::Adapters::ActorLimit.new(local, limit) } diff --git a/spec/flipper/api/v1/actions/features_spec.rb b/spec/flipper/api/v1/actions/features_spec.rb index ee69e4af0..143d1e6b3 100644 --- a/spec/flipper/api/v1/actions/features_spec.rb +++ b/spec/flipper/api/v1/actions/features_spec.rb @@ -259,4 +259,55 @@ end end end + + describe 'response_extensions' do + around do |example| + original = described_class.instance_variable_get(:@response_extensions) + described_class.instance_variable_set(:@response_extensions, []) + example.run + described_class.instance_variable_set(:@response_extensions, original) + end + + it 'merges hashes returned by registered procs into the response' do + described_class.response_extensions << ->(action) { { version: 12345 } } + + get '/features' + + expect(last_response.status).to eq(200) + expect(json_response.fetch('version')).to eq(12345) + expect(json_response).to have_key('features') + end + + it 'composes multiple extensions in registration order' do + described_class.response_extensions << ->(action) { { a: 1, b: 1 } } + described_class.response_extensions << ->(action) { { b: 2, c: 3 } } + + get '/features' + + expect(json_response.fetch('a')).to eq(1) + expect(json_response.fetch('b')).to eq(2) + expect(json_response.fetch('c')).to eq(3) + end + + it 'passes the action instance so extensions can read request, params, and flipper' do + captured = nil + described_class.response_extensions << ->(action) { + captured = action + {} + } + + get '/features' + + expect(captured).to be_a(described_class) + expect(captured.request).to respond_to(:params) + expect(captured.flipper).to respond_to(:features) + end + + it 'is a no-op when no extensions are registered' do + get '/features' + + expect(last_response.status).to eq(200) + expect(json_response.keys).to eq(['features']) + end + end end diff --git a/test/adapters/active_record_test.rb b/test/adapters/active_record_test.rb index 0b3a031ba..5084c234e 100644 --- a/test/adapters/active_record_test.rb +++ b/test/adapters/active_record_test.rb @@ -51,15 +51,18 @@ def test_models_honor_table_name_prefixes_and_suffixes Flipper::Adapters::ActiveRecord.send(:remove_const, :Model) if Flipper::Adapters::ActiveRecord.const_defined?(:Model) Flipper::Adapters::ActiveRecord.send(:remove_const, :Feature) if Flipper::Adapters::ActiveRecord.const_defined?(:Feature) Flipper::Adapters::ActiveRecord.send(:remove_const, :Gate) if Flipper::Adapters::ActiveRecord.const_defined?(:Gate) + Flipper::Adapters::ActiveRecord.send(:remove_const, :KvInteger) if Flipper::Adapters::ActiveRecord.const_defined?(:KvInteger) Flipper::Adapters.send(:remove_const, :ActiveRecord) load("flipper/adapters/active_record.rb") load("flipper/adapters/active_record/model.rb") load("flipper/adapters/active_record/feature.rb") load("flipper/adapters/active_record/gate.rb") + load("flipper/adapters/active_record/kv_integer.rb") assert_equal "foo_flipper_features_bar", Flipper::Adapters::ActiveRecord::Feature.table_name assert_equal "foo_flipper_gates_bar", Flipper::Adapters::ActiveRecord::Gate.table_name + assert_equal "foo_flipper_kv_integers_bar", Flipper::Adapters::ActiveRecord::KvInteger.table_name ensure ActiveRecord::Base.table_name_prefix = "" @@ -69,11 +72,13 @@ def test_models_honor_table_name_prefixes_and_suffixes Flipper::Adapters::ActiveRecord.send(:remove_const, :Model) if Flipper::Adapters::ActiveRecord.const_defined?(:Model) Flipper::Adapters::ActiveRecord.send(:remove_const, :Feature) if Flipper::Adapters::ActiveRecord.const_defined?(:Feature) Flipper::Adapters::ActiveRecord.send(:remove_const, :Gate) if Flipper::Adapters::ActiveRecord.const_defined?(:Gate) + Flipper::Adapters::ActiveRecord.send(:remove_const, :KvInteger) if Flipper::Adapters::ActiveRecord.const_defined?(:KvInteger) Flipper::Adapters.send(:remove_const, :ActiveRecord) load("flipper/adapters/active_record.rb") load("flipper/adapters/active_record/model.rb") load("flipper/adapters/active_record/feature.rb") load("flipper/adapters/active_record/gate.rb") + load("flipper/adapters/active_record/kv_integer.rb") end end diff --git a/test_rails/generators/flipper/active_record_generator_test.rb b/test_rails/generators/flipper/active_record_generator_test.rb index 2ad7fab87..57fa19ecb 100644 --- a/test_rails/generators/flipper/active_record_generator_test.rb +++ b/test_rails/generators/flipper/active_record_generator_test.rb @@ -29,9 +29,17 @@ def up t.timestamps null: false end add_index :flipper_gates, [:feature_key, :key, :value], unique: true, length: { value: 255 } + + create_table :flipper_kv_integers do |t| + t.string :key, null: false + t.bigint :value, null: false, default: 0 + t.timestamps null: false + end + add_index :flipper_kv_integers, :key, unique: true end def down + drop_table :flipper_kv_integers drop_table :flipper_gates drop_table :flipper_features end From a479962dee3b3f462639184fc5125cf61fe5b4f2 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Tue, 28 Apr 2026 09:09:01 -0400 Subject: [PATCH 02/15] Forward read_integer and set_integer_if_greater through wrappers The default implementations on Flipper::Adapter return nil/false, so any wrapper that didn't explicitly forward these methods silently disabled the version-aware sync optimization and emitted spurious synchronizer_outvoted.flipper events. Add the methods to Wrapper::METHODS (covers OperationLogger and ReadOnly) and add explicit instrumented forwarding in Instrumented. Co-Authored-By: Claude Opus 4.7 --- lib/flipper/adapters/instrumented.rb | 25 +++++++++++++ lib/flipper/adapters/wrapper.rb | 2 ++ spec/flipper/adapters/instrumented_spec.rb | 35 +++++++++++++++++++ .../flipper/adapters/operation_logger_spec.rb | 20 +++++++++++ spec/flipper/adapters/read_only_spec.rb | 10 ++++++ 5 files changed, 92 insertions(+) diff --git a/lib/flipper/adapters/instrumented.rb b/lib/flipper/adapters/instrumented.rb index 37c08f2d1..621ee9c8f 100644 --- a/lib/flipper/adapters/instrumented.rb +++ b/lib/flipper/adapters/instrumented.rb @@ -165,6 +165,31 @@ def export(format: :json, version: 1) payload[:result] = @adapter.export(format: format, version: version) end end + + def read_integer(key) + default_payload = { + operation: :read_integer, + adapter_name: @adapter.name, + key: key, + } + + @instrumenter.instrument(InstrumentationName, default_payload) do |payload| + payload[:result] = @adapter.read_integer(key) + end + end + + def set_integer_if_greater(key, value) + default_payload = { + operation: :set_integer_if_greater, + adapter_name: @adapter.name, + key: key, + value: value, + } + + @instrumenter.instrument(InstrumentationName, default_payload) do |payload| + payload[:result] = @adapter.set_integer_if_greater(key, value) + end + end end end end diff --git a/lib/flipper/adapters/wrapper.rb b/lib/flipper/adapters/wrapper.rb index 6026ebc96..6211f0f10 100644 --- a/lib/flipper/adapters/wrapper.rb +++ b/lib/flipper/adapters/wrapper.rb @@ -18,6 +18,8 @@ class Wrapper :get_all, :enable, :disable, + :read_integer, + :set_integer_if_greater, ].freeze attr_reader :adapter diff --git a/spec/flipper/adapters/instrumented_spec.rb b/spec/flipper/adapters/instrumented_spec.rb index 0f25bbd9b..248cd2a5e 100644 --- a/spec/flipper/adapters/instrumented_spec.rb +++ b/spec/flipper/adapters/instrumented_spec.rb @@ -164,4 +164,39 @@ expect(event.payload[:result]).to be(result) end end + + describe '#read_integer' do + it 'forwards to wrapped adapter and records instrumentation' do + adapter.set_integer_if_greater(:sync_version, 42) + result = subject.read_integer(:sync_version) + + expect(result).to eq(42) + + event = instrumenter.events.last + expect(event).not_to be_nil + expect(event.name).to eq('adapter_operation.flipper') + expect(event.payload[:operation]).to eq(:read_integer) + expect(event.payload[:adapter_name]).to eq(:memory) + expect(event.payload[:key]).to eq(:sync_version) + expect(event.payload[:result]).to eq(42) + end + end + + describe '#set_integer_if_greater' do + it 'forwards to wrapped adapter and records instrumentation' do + result = subject.set_integer_if_greater(:sync_version, 100) + + expect(result).to eq(true) + expect(adapter.read_integer(:sync_version)).to eq(100) + + event = instrumenter.events.last + expect(event).not_to be_nil + expect(event.name).to eq('adapter_operation.flipper') + expect(event.payload[:operation]).to eq(:set_integer_if_greater) + expect(event.payload[:adapter_name]).to eq(:memory) + expect(event.payload[:key]).to eq(:sync_version) + expect(event.payload[:value]).to eq(100) + expect(event.payload[:result]).to eq(true) + end + end end diff --git a/spec/flipper/adapters/operation_logger_spec.rb b/spec/flipper/adapters/operation_logger_spec.rb index 2fe229601..03f6c247b 100644 --- a/spec/flipper/adapters/operation_logger_spec.rb +++ b/spec/flipper/adapters/operation_logger_spec.rb @@ -125,4 +125,24 @@ expect(@result).to eq(adapter.export(format: :json, version: 1)) end end + + describe '#read_integer' do + it 'forwards to wrapped adapter and logs operation' do + adapter.set_integer_if_greater(:sync_version, 42) + result = subject.read_integer(:sync_version) + + expect(result).to eq(42) + expect(subject.count(:read_integer)).to be(1) + end + end + + describe '#set_integer_if_greater' do + it 'forwards to wrapped adapter and logs operation' do + result = subject.set_integer_if_greater(:sync_version, 100) + + expect(result).to eq(true) + expect(adapter.read_integer(:sync_version)).to eq(100) + expect(subject.count(:set_integer_if_greater)).to be(1) + end + end end diff --git a/spec/flipper/adapters/read_only_spec.rb b/spec/flipper/adapters/read_only_spec.rb index a14c3b423..112f6545a 100644 --- a/spec/flipper/adapters/read_only_spec.rb +++ b/spec/flipper/adapters/read_only_spec.rb @@ -97,4 +97,14 @@ expect { subject.disable(feature, boolean_gate, Flipper::Types::Boolean.new) } .to raise_error(Flipper::Adapters::ReadOnly::WriteAttempted) end + + it 'forwards read_integer to wrapped adapter' do + adapter.set_integer_if_greater(:sync_version, 42) + expect(subject.read_integer(:sync_version)).to eq(42) + end + + it 'forwards set_integer_if_greater to wrapped adapter' do + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(true) + expect(adapter.read_integer(:sync_version)).to eq(100) + end end From da91432ccd9d50707d1285a0148568333e22dcc8 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Tue, 28 Apr 2026 09:09:44 -0400 Subject: [PATCH 03/15] Defer get_all calls until after sync_version check The local and remote get_all calls were happening before the version-skip check, so the optimization paid the cost (a full join query for AR/Sequel on the local side, an HTTP fetch on the remote side) even when the early return fired. Move both calls below the version check so the optimization actually saves the read when versions match. Co-Authored-By: Claude Opus 4.7 --- lib/flipper/adapters/sync/synchronizer.rb | 6 +++--- spec/flipper/adapters/sync/synchronizer_spec.rb | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/flipper/adapters/sync/synchronizer.rb b/lib/flipper/adapters/sync/synchronizer.rb index 050d2bf0f..4f315f450 100644 --- a/lib/flipper/adapters/sync/synchronizer.rb +++ b/lib/flipper/adapters/sync/synchronizer.rb @@ -41,9 +41,6 @@ def call private def sync - local_get_all = @local.get_all - remote_get_all = @remote.get_all(cache_bust: @cache_bust) - remote_version = @remote.read_integer(SYNC_VERSION_KEY) local_version = @local.read_integer(SYNC_VERSION_KEY) @@ -51,6 +48,9 @@ def sync return end + local_get_all = @local.get_all + remote_get_all = @remote.get_all(cache_bust: @cache_bust) + # Sync all the gate values. remote_get_all.each do |feature_key, remote_gates_hash| feature = Feature.new(feature_key, @local, instrumenter: @instrumenter) diff --git a/spec/flipper/adapters/sync/synchronizer_spec.rb b/spec/flipper/adapters/sync/synchronizer_spec.rb index d67417906..e98cd30e0 100644 --- a/spec/flipper/adapters/sync/synchronizer_spec.rb +++ b/spec/flipper/adapters/sync/synchronizer_spec.rb @@ -188,6 +188,15 @@ expect(events.size).to eq(1) expect(events.first.payload[:remote_version]).to eq(100) end + + it 'skips both get_all calls when versions indicate no sync is needed' do + local.set_integer_if_greater(:sync_version, 100) + allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) + expect(local).not_to receive(:get_all) + expect(remote).not_to receive(:get_all) + + subject.call + end end context 'with ActorLimit adapter wrapping local' do From ccda0b48ead29a7fdb234adea0b9e307932b4b70 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Tue, 28 Apr 2026 09:12:46 -0400 Subject: [PATCH 04/15] Don't cache rescue branch in kv_integer_table_present? A transient StatementInvalid/DatabaseError (e.g., a brief connection blip on the first call after boot, or connection pool exhaustion) was permanently caching the table as missing for the process lifetime, silently disabling version-aware sync until restart. Cache only the result of a successful table_exists? call (true OR false). On rescue, return false for this call but leave the ivar unset so the next call retries. Once a real result is observed it's cached forever, so the perf optimization is preserved for the steady state. Co-Authored-By: Claude Opus 4.7 --- lib/flipper/adapters/active_record.rb | 2 +- lib/flipper/adapters/sequel.rb | 2 +- spec/flipper/adapters/active_record_spec.rb | 16 ++++++++++++++++ spec/flipper/adapters/sequel_spec.rb | 16 ++++++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/flipper/adapters/active_record.rb b/lib/flipper/adapters/active_record.rb index ee5160082..cd6027684 100644 --- a/lib/flipper/adapters/active_record.rb +++ b/lib/flipper/adapters/active_record.rb @@ -225,7 +225,7 @@ 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 - @kv_integer_table_present = false + false end def set(feature, gate, thing, options = {}) diff --git a/lib/flipper/adapters/sequel.rb b/lib/flipper/adapters/sequel.rb index 530fd69d0..90ccd6c8f 100644 --- a/lib/flipper/adapters/sequel.rb +++ b/lib/flipper/adapters/sequel.rb @@ -207,7 +207,7 @@ def kv_integer_table_present? return @kv_integer_table_present if defined?(@kv_integer_table_present) @kv_integer_table_present = @kv_integer_class.db.table_exists?(@kv_integer_class.table_name) rescue ::Sequel::DatabaseError - @kv_integer_table_present = false + false end def unsupported_data_type(data_type) diff --git a/spec/flipper/adapters/active_record_spec.rb b/spec/flipper/adapters/active_record_spec.rb index 4aee863bd..8ea031abc 100644 --- a/spec/flipper/adapters/active_record_spec.rb +++ b/spec/flipper/adapters/active_record_spec.rb @@ -128,6 +128,22 @@ expect(fresh.set_integer_if_greater(:sync_version, 100)).to eq(false) end end + + it 'recovers from a transient StatementInvalid on the table presence check' do + fresh = described_class.new + kv_class = fresh.instance_variable_get(:@kv_integer_class) + + call_count = 0 + allow(kv_class).to receive(:table_exists?).and_wrap_original do |original, *args| + call_count += 1 + raise ::ActiveRecord::StatementInvalid, 'transient blip' if call_count == 1 + original.call(*args) + end + + expect(fresh.read_integer(:sync_version)).to be_nil + expect(fresh.set_integer_if_greater(:sync_version, 100)).to eq(true) + expect(fresh.read_integer(:sync_version)).to eq(100) + end end it 'should not poison wrapping transactions' do diff --git a/spec/flipper/adapters/sequel_spec.rb b/spec/flipper/adapters/sequel_spec.rb index 5aa04da90..fe627222b 100644 --- a/spec/flipper/adapters/sequel_spec.rb +++ b/spec/flipper/adapters/sequel_spec.rb @@ -63,6 +63,22 @@ expect(subject.read_integer(:foo)).to eq(100) expect(subject.read_integer(:bar)).to eq(50) end + + it 'recovers from a transient DatabaseError on the table presence check' do + fresh = described_class.new(feature_class: feature_class, gate_class: gate_class) + kv_class = fresh.instance_variable_get(:@kv_integer_class) + + call_count = 0 + allow(kv_class.db).to receive(:table_exists?).and_wrap_original do |original, *args| + call_count += 1 + raise ::Sequel::DatabaseError, 'transient blip' if call_count == 1 + original.call(*args) + end + + expect(fresh.read_integer(:sync_version)).to be_nil + expect(fresh.set_integer_if_greater(:sync_version, 100)).to eq(true) + expect(fresh.read_integer(:sync_version)).to eq(100) + end end context 'requiring "flipper-sequel"' do From 38156244c335146de6d8a5ea4417aede4bd3830f Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Tue, 28 Apr 2026 09:17:59 -0400 Subject: [PATCH 05/15] Harden Features#response_extensions hook - Eagerly initialize @response_extensions at class definition time so multi-threaded boot can't race on the lazy ||= and lose registrations. - Reverse the merge order so the built-in :features key always wins. Extensions can add new keys, but a poorly-written extension can no longer silently clobber the response payload. Adds regression tests for both. Removal/clear is unchanged: the array is publicly accessible and mutable, so callers can use .clear/.delete directly. Co-Authored-By: Claude Opus 4.7 --- lib/flipper/api/v1/actions/features.rb | 15 ++++++++----- spec/flipper/api/v1/actions/features_spec.rb | 22 +++++++++++++++++--- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/lib/flipper/api/v1/actions/features.rb b/lib/flipper/api/v1/actions/features.rb index cf11555f9..2292dc342 100644 --- a/lib/flipper/api/v1/actions/features.rb +++ b/lib/flipper/api/v1/actions/features.rb @@ -8,18 +8,23 @@ module Actions class Features < Api::Action route %r{\A/features/?\Z} + # Eagerly initialized so multi-threaded boot can't race on lazy `||=`. + @response_extensions = [] + # Public: Procs registered here are called per GET request. Each # receives the action instance and should return a Hash; the hashes - # are merged into the response body in registration order. Use this - # to add fields like protocol versions, server capabilities, or - # feature counts without subclassing or monkey-patching. + # are merged into the response body in registration order. Built-in + # keys (like :features) always win over extension keys to prevent + # accidental clobbering. Use this to add fields like protocol + # versions, server capabilities, or feature counts without + # subclassing or monkey-patching. # # Example: # Flipper::Api::V1::Actions::Features.response_extensions << ->(action) { # { version: action.request.env["x-version"].to_i } # } def self.response_extensions - @response_extensions ||= [] + @response_extensions end def get @@ -52,7 +57,7 @@ def get extras = self.class.response_extensions.reduce({}) do |memo, ext| memo.merge(ext.call(self)) end - json_response({features: decorated_features}.merge(extras)) + json_response(extras.merge(features: decorated_features)) end def post diff --git a/spec/flipper/api/v1/actions/features_spec.rb b/spec/flipper/api/v1/actions/features_spec.rb index 143d1e6b3..80a9efb6a 100644 --- a/spec/flipper/api/v1/actions/features_spec.rb +++ b/spec/flipper/api/v1/actions/features_spec.rb @@ -262,10 +262,10 @@ describe 'response_extensions' do around do |example| - original = described_class.instance_variable_get(:@response_extensions) - described_class.instance_variable_set(:@response_extensions, []) + original = described_class.response_extensions.dup + described_class.response_extensions.clear example.run - described_class.instance_variable_set(:@response_extensions, original) + described_class.response_extensions.replace(original) end it 'merges hashes returned by registered procs into the response' do @@ -309,5 +309,21 @@ expect(last_response.status).to eq(200) expect(json_response.keys).to eq(['features']) end + + it 'does not let extensions overwrite the built-in features key' do + flipper[:my_feature].enable + described_class.response_extensions << ->(action) { { features: 'clobbered' } } + + get '/features' + + expect(last_response.status).to eq(200) + features = json_response.fetch('features') + expect(features).to be_an(Array) + expect(features.first.fetch('key')).to eq('my_feature') + end + + it 'is initialized eagerly at class load time' do + expect(described_class.instance_variable_defined?(:@response_extensions)).to be(true) + end end end From 4eb9585ea4d15972dd8f859618bda464e3423152 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Tue, 28 Apr 2026 10:09:26 -0400 Subject: [PATCH 06/15] Replace pick with limit+pluck for Rails 5.2 compatibility ActiveRecord::Relation#pick was added in Rails 6.0, so the read_integer call broke CI on Rails 5.2. Co-Authored-By: Claude Opus 4.7 --- lib/flipper/adapters/active_record.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/flipper/adapters/active_record.rb b/lib/flipper/adapters/active_record.rb index cd6027684..f80b9be23 100644 --- a/lib/flipper/adapters/active_record.rb +++ b/lib/flipper/adapters/active_record.rb @@ -185,7 +185,7 @@ def disable(feature, gate, thing) 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).pick(:value) + @kv_integer_class.where(key: key.to_s).limit(1).pluck(:value).first end end From 9dc9a5bc74baac550464d52fc68b9bed06175351 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Wed, 29 Apr 2026 08:40:28 -0400 Subject: [PATCH 07/15] Move sync_version from response body to header The HTTP adapter's read_integer(:sync_version) returns whatever was cached during the last get_all. With the synchronizer reading versions before get_all, the cache could only ever reflect the previous sync, which made the version gate skip indefinitely against an HTTP-backed remote. Switch the wire format to a flipper-sync-version response header (populated cloud-side by middleware) and reorder the synchronizer to get_all the remote first, then compare versions. Remote get_all stays cheap via conditional GET; what we skip on a no-op sync is the local get_all and the diff/write loop. Drop the response_extensions hook on Features since cloud sets the header directly in middleware. Co-Authored-By: Claude Opus 4.7 --- lib/flipper/adapters/http.rb | 3 +- lib/flipper/adapters/sync/synchronizer.rb | 2 +- lib/flipper/api/v1/actions/features.rb | 24 +------ spec/flipper/adapters/http_spec.rb | 23 ++----- .../adapters/sync/synchronizer_spec.rb | 4 +- spec/flipper/api/v1/actions/features_spec.rb | 66 ------------------- 6 files changed, 13 insertions(+), 109 deletions(-) diff --git a/lib/flipper/adapters/http.rb b/lib/flipper/adapters/http.rb index 91d73e11b..b94063c97 100644 --- a/lib/flipper/adapters/http.rb +++ b/lib/flipper/adapters/http.rb @@ -98,10 +98,11 @@ def get_all(cache_bust: false) result[feature.key] = result_for_feature(feature, gates_by_key[feature.key]) end + sync_version = response['flipper-sync-version'] @get_all_mutex.synchronize do @last_get_all_etag = response['etag'] if response['etag'] @last_get_all_result = result - @last_sync_version = parsed_response['version'] + @last_sync_version = sync_version && sync_version.to_i end result diff --git a/lib/flipper/adapters/sync/synchronizer.rb b/lib/flipper/adapters/sync/synchronizer.rb index 4f315f450..5eb0e57eb 100644 --- a/lib/flipper/adapters/sync/synchronizer.rb +++ b/lib/flipper/adapters/sync/synchronizer.rb @@ -41,6 +41,7 @@ def call private def sync + remote_get_all = @remote.get_all(cache_bust: @cache_bust) remote_version = @remote.read_integer(SYNC_VERSION_KEY) local_version = @local.read_integer(SYNC_VERSION_KEY) @@ -49,7 +50,6 @@ def sync end local_get_all = @local.get_all - remote_get_all = @remote.get_all(cache_bust: @cache_bust) # Sync all the gate values. remote_get_all.each do |feature_key, remote_gates_hash| diff --git a/lib/flipper/api/v1/actions/features.rb b/lib/flipper/api/v1/actions/features.rb index 2292dc342..7b7af6756 100644 --- a/lib/flipper/api/v1/actions/features.rb +++ b/lib/flipper/api/v1/actions/features.rb @@ -8,25 +8,6 @@ module Actions class Features < Api::Action route %r{\A/features/?\Z} - # Eagerly initialized so multi-threaded boot can't race on lazy `||=`. - @response_extensions = [] - - # Public: Procs registered here are called per GET request. Each - # receives the action instance and should return a Hash; the hashes - # are merged into the response body in registration order. Built-in - # keys (like :features) always win over extension keys to prevent - # accidental clobbering. Use this to add fields like protocol - # versions, server capabilities, or feature counts without - # subclassing or monkey-patching. - # - # Example: - # Flipper::Api::V1::Actions::Features.response_extensions << ->(action) { - # { version: action.request.env["x-version"].to_i } - # } - def self.response_extensions - @response_extensions - end - def get keys = params['keys'] exclude_gates = params['exclude_gates']&.downcase == "true" @@ -54,10 +35,7 @@ def get ) end - extras = self.class.response_extensions.reduce({}) do |memo, ext| - memo.merge(ext.call(self)) - end - json_response(extras.merge(features: decorated_features)) + json_response(features: decorated_features) end def post diff --git a/spec/flipper/adapters/http_spec.rb b/spec/flipper/adapters/http_spec.rb index 936da090c..fb165492a 100644 --- a/spec/flipper/adapters/http_spec.rb +++ b/spec/flipper/adapters/http_spec.rb @@ -181,37 +181,28 @@ expect(adapter.read_integer(:sync_version)).to be_nil end - it "returns the version parsed from the most recent get_all response" do - features_response = { - "version" => 12345, - "features" => [], - } + it "returns the version from the flipper-sync-version response header" do stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") - .to_return(status: 200, body: JSON.generate(features_response)) + .to_return(status: 200, body: JSON.generate(features: []), headers: { 'Flipper-Sync-Version' => '12345' }) adapter = described_class.new(url: 'http://app.com/flipper') adapter.get_all expect(adapter.read_integer(:sync_version)).to eq(12345) end - it "returns nil when the server response omits version (older server)" do - features_response = { "features" => [] } + it "returns nil when the server response omits the header (older server)" do stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") - .to_return(status: 200, body: JSON.generate(features_response)) + .to_return(status: 200, body: JSON.generate(features: [])) adapter = described_class.new(url: 'http://app.com/flipper') adapter.get_all expect(adapter.read_integer(:sync_version)).to be_nil end - it "preserves the parsed version across 304 Not Modified responses" do - features_response = { - "version" => 12345, - "features" => [], - } - + it "preserves the cached version across 304 Not Modified responses" do stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") - .to_return(status: 200, body: JSON.generate(features_response), headers: { 'ETag' => '"abc"' }) + .to_return(status: 200, body: JSON.generate(features: []), + headers: { 'ETag' => '"abc"', 'Flipper-Sync-Version' => '12345' }) adapter = described_class.new(url: 'http://app.com/flipper') adapter.get_all diff --git a/spec/flipper/adapters/sync/synchronizer_spec.rb b/spec/flipper/adapters/sync/synchronizer_spec.rb index e98cd30e0..62c1fdf22 100644 --- a/spec/flipper/adapters/sync/synchronizer_spec.rb +++ b/spec/flipper/adapters/sync/synchronizer_spec.rb @@ -189,11 +189,11 @@ expect(events.first.payload[:remote_version]).to eq(100) end - it 'skips both get_all calls when versions indicate no sync is needed' do + it 'skips local get_all and writes when remote version is not newer' do local.set_integer_if_greater(:sync_version, 100) allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) expect(local).not_to receive(:get_all) - expect(remote).not_to receive(:get_all) + expect(remote).to receive(:get_all).and_call_original subject.call end diff --git a/spec/flipper/api/v1/actions/features_spec.rb b/spec/flipper/api/v1/actions/features_spec.rb index 80a9efb6a..9f2bfb2b4 100644 --- a/spec/flipper/api/v1/actions/features_spec.rb +++ b/spec/flipper/api/v1/actions/features_spec.rb @@ -260,70 +260,4 @@ end end - describe 'response_extensions' do - around do |example| - original = described_class.response_extensions.dup - described_class.response_extensions.clear - example.run - described_class.response_extensions.replace(original) - end - - it 'merges hashes returned by registered procs into the response' do - described_class.response_extensions << ->(action) { { version: 12345 } } - - get '/features' - - expect(last_response.status).to eq(200) - expect(json_response.fetch('version')).to eq(12345) - expect(json_response).to have_key('features') - end - - it 'composes multiple extensions in registration order' do - described_class.response_extensions << ->(action) { { a: 1, b: 1 } } - described_class.response_extensions << ->(action) { { b: 2, c: 3 } } - - get '/features' - - expect(json_response.fetch('a')).to eq(1) - expect(json_response.fetch('b')).to eq(2) - expect(json_response.fetch('c')).to eq(3) - end - - it 'passes the action instance so extensions can read request, params, and flipper' do - captured = nil - described_class.response_extensions << ->(action) { - captured = action - {} - } - - get '/features' - - expect(captured).to be_a(described_class) - expect(captured.request).to respond_to(:params) - expect(captured.flipper).to respond_to(:features) - end - - it 'is a no-op when no extensions are registered' do - get '/features' - - expect(last_response.status).to eq(200) - expect(json_response.keys).to eq(['features']) - end - - it 'does not let extensions overwrite the built-in features key' do - flipper[:my_feature].enable - described_class.response_extensions << ->(action) { { features: 'clobbered' } } - - get '/features' - - expect(last_response.status).to eq(200) - features = json_response.fetch('features') - expect(features).to be_an(Array) - expect(features.first.fetch('key')).to eq('my_feature') - end - - it 'is initialized eagerly at class load time' do - expect(described_class.instance_variable_defined?(:@response_extensions)).to be(true) - end - end end From 7209fa327f2fd067665afd7844d60e81fffbee04 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Thu, 7 May 2026 11:31:42 -0400 Subject: [PATCH 08/15] Block set_integer_if_greater on ReadOnly adapter set_integer_if_greater is a write but ReadOnly's WRITE_METHODS only listed enable/disable/add/remove/clear, so anyone wrapping their adapter in ReadOnly to enforce immutability had a silent bypass via the new sync_version path. Add it to WRITE_METHODS so it raises WriteAttempted like every other write. Co-Authored-By: Claude Opus 4.7 --- lib/flipper/adapters/read_only.rb | 2 +- spec/flipper/adapters/read_only_spec.rb | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/flipper/adapters/read_only.rb b/lib/flipper/adapters/read_only.rb index 666e25a96..7ffcd45bb 100644 --- a/lib/flipper/adapters/read_only.rb +++ b/lib/flipper/adapters/read_only.rb @@ -4,7 +4,7 @@ module Flipper module Adapters # Public: Adapter that wraps another adapter and raises for any writes. class ReadOnly < Wrapper - WRITE_METHODS = %i[add remove clear enable disable] + WRITE_METHODS = %i[add remove clear enable disable set_integer_if_greater] class WriteAttempted < Error def initialize(message = nil) diff --git a/spec/flipper/adapters/read_only_spec.rb b/spec/flipper/adapters/read_only_spec.rb index 112f6545a..47386ac52 100644 --- a/spec/flipper/adapters/read_only_spec.rb +++ b/spec/flipper/adapters/read_only_spec.rb @@ -103,8 +103,9 @@ expect(subject.read_integer(:sync_version)).to eq(42) end - it 'forwards set_integer_if_greater to wrapped adapter' do - expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(true) - expect(adapter.read_integer(:sync_version)).to eq(100) + it 'raises WriteAttempted on set_integer_if_greater' do + expect { subject.set_integer_if_greater(:sync_version, 100) } + .to raise_error(Flipper::Adapters::ReadOnly::WriteAttempted) + expect(adapter.read_integer(:sync_version)).to be_nil end end From 727dabd3aaaa3f9c9a9af1ed1f5c5e7cf851852f Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Thu, 7 May 2026 16:57:47 -0400 Subject: [PATCH 09/15] Skip synchronizer_outvoted event when local has no prior version A false return from set_integer_if_greater conflates two cases: a real race (another writer beat us) and a capability gap (the local adapter doesn't persist versions). The latter would fire on every sync forever and falsely suggest contention. Gate the event on local_version being non-nil pre-sync to silence the capability case. --- lib/flipper/adapters/sync/synchronizer.rb | 5 ++++- spec/flipper/adapters/sync/synchronizer_spec.rb | 13 ++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/flipper/adapters/sync/synchronizer.rb b/lib/flipper/adapters/sync/synchronizer.rb index 5eb0e57eb..a119aa018 100644 --- a/lib/flipper/adapters/sync/synchronizer.rb +++ b/lib/flipper/adapters/sync/synchronizer.rb @@ -71,7 +71,10 @@ def sync if remote_version accepted = @local.set_integer_if_greater(SYNC_VERSION_KEY, remote_version) - unless accepted + # Only instrument when we know the local adapter persists versions + # (local_version was non-nil pre-sync). Otherwise a false return + # likely means the adapter has no typed-integer storage, not a race. + if !accepted && local_version @instrumenter.instrument("synchronizer_outvoted.flipper", remote_version: remote_version) end end diff --git a/spec/flipper/adapters/sync/synchronizer_spec.rb b/spec/flipper/adapters/sync/synchronizer_spec.rb index 62c1fdf22..048e81548 100644 --- a/spec/flipper/adapters/sync/synchronizer_spec.rb +++ b/spec/flipper/adapters/sync/synchronizer_spec.rb @@ -177,7 +177,8 @@ expect(local.read_integer(:sync_version)).to eq(100) end - it 'instruments synchronizer_outvoted.flipper when bump_sync_version is rejected' do + it 'instruments synchronizer_outvoted.flipper when bump is rejected and local already had a version' do + local.set_integer_if_greater(:sync_version, 50) allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) remote_flipper.enable(:search) allow(local).to receive(:set_integer_if_greater).with(:sync_version, 100).and_return(false) @@ -189,6 +190,16 @@ expect(events.first.payload[:remote_version]).to eq(100) end + it 'does not instrument synchronizer_outvoted.flipper when local has no prior version' do + allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) + remote_flipper.enable(:search) + allow(local).to receive(:set_integer_if_greater).with(:sync_version, 100).and_return(false) + + subject.call + + expect(instrumenter.events_by_name("synchronizer_outvoted.flipper")).to be_empty + end + it 'skips local get_all and writes when remote version is not newer' do local.set_integer_if_greater(:sync_version, 100) allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) From c8fec53e9dcf67d4518871aae1211c4584ae6d50 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Thu, 7 May 2026 20:45:31 -0400 Subject: [PATCH 10/15] Propagate sync_version through Memory#import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cloud Poll path imports HTTP into the poller's Memory buffer, then foreground threads sync from Memory to the user's local adapter. Without this, Memory#import only copied features and dropped the sync_version the HTTP adapter learned from the response header — leaving the Synchronizer's version-skip dead in the Cloud Poll setup because remote.read_integer always returned nil. Import the version atomically inside the same synchronize block as the feature replace, and skip the write when the source has none so an existing local version isn't clobbered to nil by an older server. --- lib/flipper/adapters/memory.rb | 6 +++++- spec/flipper/adapters/memory_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/flipper/adapters/memory.rb b/lib/flipper/adapters/memory.rb index b076229f3..5d455b09d 100644 --- a/lib/flipper/adapters/memory.rb +++ b/lib/flipper/adapters/memory.rb @@ -117,7 +117,11 @@ def inspect def import(source) adapter = self.class.from(source) get_all = Typecast.features_hash(adapter.get_all) - synchronize { @source.replace(get_all) } + sync_version = adapter.read_integer(:sync_version) + synchronize do + @source.replace(get_all) + @integers["sync_version"] = sync_version if sync_version + end true end diff --git a/spec/flipper/adapters/memory_spec.rb b/spec/flipper/adapters/memory_spec.rb index b52c8a254..bbab3000a 100644 --- a/spec/flipper/adapters/memory_spec.rb +++ b/spec/flipper/adapters/memory_spec.rb @@ -58,6 +58,26 @@ flipper[:my_feature].clear expect(subject.read_integer(:sync_version)).to eq(100) end + + it 'imports sync_version from the source adapter' do + source = described_class.new + source.set_integer_if_greater(:sync_version, 42) + Flipper.new(source).enable(:search) + + subject.import(source) + + expect(subject.read_integer(:sync_version)).to eq(42) + end + + it 'does not overwrite sync_version when the source has none' do + subject.set_integer_if_greater(:sync_version, 100) + source = described_class.new + Flipper.new(source).enable(:search) + + subject.import(source) + + expect(subject.read_integer(:sync_version)).to eq(100) + end end it "can initialize from big hash" do From 0376b5a63e9224456f3bc948e8b812ebd4e85d71 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Fri, 8 May 2026 11:45:37 -0400 Subject: [PATCH 11/15] Trim redundant INSERT in set_integer_if_greater rejection path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-pass loop performed UPDATE→INSERT→UPDATE→INSERT (4 queries) to reject an equal/lower incoming value once a row existed. After the first INSERT raised RecordNotUnique we already know the row exists, so a second INSERT can't help — only the UPDATE can, and only if a racing inserter wrote a value below ours. Drop to UPDATE→INSERT→UPDATE (3 queries worst case, unchanged for the common UPDATE-hit path). Co-Authored-By: Claude Opus 4.7 --- lib/flipper/adapters/active_record.rb | 32 ++++++++++++++------------- lib/flipper/adapters/sequel.rb | 30 ++++++++++++++----------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/lib/flipper/adapters/active_record.rb b/lib/flipper/adapters/active_record.rb index f80b9be23..91d956137 100644 --- a/lib/flipper/adapters/active_record.rb +++ b/lib/flipper/adapters/active_record.rb @@ -194,23 +194,25 @@ def set_integer_if_greater(key, value) value = value.to_i key = key.to_s with_write_connection(@kv_integer_class) do - # Two attempts: UPDATE-then-INSERT-on-miss, retry UPDATE once if a - # concurrent INSERT raced us. After two passes, stored is >= ours. - 2.times do - updated = @kv_integer_class - .where(key: key) - .where("value < ?", value) - .update_all(value: value, updated_at: Time.current) - return true if updated > 0 + 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, or someone race-inserted. Loop. - end + 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 - false + + @kv_integer_class + .where(key: key) + .where("value < ?", value) + .update_all(value: value, updated_at: Time.current) > 0 end end diff --git a/lib/flipper/adapters/sequel.rb b/lib/flipper/adapters/sequel.rb index 90ccd6c8f..bedd84265 100644 --- a/lib/flipper/adapters/sequel.rb +++ b/lib/flipper/adapters/sequel.rb @@ -184,21 +184,25 @@ def set_integer_if_greater(key, incoming) return false unless kv_integer_table_present? incoming = incoming.to_i key = key.to_s - 2.times do - updated = @kv_integer_class - .where(key: key) - .where { value < incoming } - .update(value: incoming, updated_at: Time.now) - return true if updated > 0 + updated = @kv_integer_class + .where(key: key) + .where { value < incoming } + .update(value: incoming, updated_at: Time.now) + return true if updated > 0 - begin - @kv_integer_class.insert(key: key, value: incoming, created_at: Time.now, updated_at: Time.now) - return true - rescue ::Sequel::UniqueConstraintViolation - # Row exists; either stored >= ours, or someone race-inserted. Loop. - end + begin + @kv_integer_class.insert(key: key, value: incoming, created_at: Time.now, updated_at: Time.now) + return true + rescue ::Sequel::UniqueConstraintViolation + # 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 - false + + @kv_integer_class + .where(key: key) + .where { value < incoming } + .update(value: incoming, updated_at: Time.now) > 0 end private From 637871ef85401b77809bfb387b2910271cd5a03e Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Mon, 11 May 2026 09:02:11 -0400 Subject: [PATCH 12/15] Repair sync after stale version outvote --- lib/flipper/adapters/sync/synchronizer.rb | 48 ++++++++++++++----- .../adapters/sync/synchronizer_spec.rb | 32 +++++++++++++ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/lib/flipper/adapters/sync/synchronizer.rb b/lib/flipper/adapters/sync/synchronizer.rb index a119aa018..b46388542 100644 --- a/lib/flipper/adapters/sync/synchronizer.rb +++ b/lib/flipper/adapters/sync/synchronizer.rb @@ -11,6 +11,7 @@ class Sync # match the remote doing only the necessary enable/disable operations. class Synchronizer SYNC_VERSION_KEY = :sync_version + MAX_OUTVOTE_REPAIRS = 3 # Public: Initializes a new synchronizer. # @@ -49,6 +50,26 @@ def sync return end + apply(remote_get_all) + + if remote_version + accepted = @local.set_integer_if_greater(SYNC_VERSION_KEY, remote_version) + # Only instrument when we know the local adapter persists versions + # (local_version was non-nil pre-sync). Otherwise a false return + # likely means the adapter has no typed-integer storage, not a race. + if !accepted && local_version + @instrumenter.instrument("synchronizer_outvoted.flipper", remote_version: remote_version) + repair_after_outvote(remote_version) + end + end + + nil + rescue => exception + @instrumenter.instrument("synchronizer_exception.flipper", exception: exception) + raise if @raise + end + + def apply(remote_get_all) local_get_all = @local.get_all # Sync all the gate values. @@ -68,21 +89,22 @@ def sync # Remove features that are present in local and missing in remote. features_to_remove = local_get_all.keys - remote_get_all.keys features_to_remove.each { |key| Feature.new(key, @local, instrumenter: @instrumenter).remove } + end - if remote_version - accepted = @local.set_integer_if_greater(SYNC_VERSION_KEY, remote_version) - # Only instrument when we know the local adapter persists versions - # (local_version was non-nil pre-sync). Otherwise a false return - # likely means the adapter has no typed-integer storage, not a race. - if !accepted && local_version - @instrumenter.instrument("synchronizer_outvoted.flipper", remote_version: remote_version) - end - end + def repair_after_outvote(outvoted_version) + current_version = @local.read_integer(SYNC_VERSION_KEY) + return unless current_version && current_version.to_i > outvoted_version.to_i - nil - rescue => exception - @instrumenter.instrument("synchronizer_exception.flipper", exception: exception) - raise if @raise + MAX_OUTVOTE_REPAIRS.times do + remote_get_all = @remote.get_all(cache_bust: true) + remote_version = @remote.read_integer(SYNC_VERSION_KEY) + apply(remote_get_all) + + @local.set_integer_if_greater(SYNC_VERSION_KEY, remote_version) if remote_version + + current_version = @local.read_integer(SYNC_VERSION_KEY) + break unless remote_version && current_version && current_version.to_i > remote_version.to_i + end end end end diff --git a/spec/flipper/adapters/sync/synchronizer_spec.rb b/spec/flipper/adapters/sync/synchronizer_spec.rb index 048e81548..243d84f50 100644 --- a/spec/flipper/adapters/sync/synchronizer_spec.rb +++ b/spec/flipper/adapters/sync/synchronizer_spec.rb @@ -190,6 +190,38 @@ expect(events.first.payload[:remote_version]).to eq(100) end + it 'repairs local gates when an older sync is outvoted after applying its snapshot' do + old_remote = Flipper::Adapters::Memory.new + Flipper.new(old_remote).enable(:search) + old_snapshot = old_remote.get_all + + new_remote = Flipper::Adapters::Memory.new + new_flipper = Flipper.new(new_remote) + new_flipper.add(:search) + new_flipper.disable(:search) + new_snapshot = new_remote.get_all + + local.set_integer_if_greater(:sync_version, 50) + original_set_integer_if_greater = local.method(:set_integer_if_greater) + + expect(remote).to receive(:get_all).with(cache_bust: false).ordered.and_return(old_snapshot) + expect(remote).to receive(:get_all).with(cache_bust: true).ordered.and_return(new_snapshot) + allow(remote).to receive(:read_integer).with(:sync_version).and_return(100, 200) + allow(local).to receive(:set_integer_if_greater) do |key, value| + if key == :sync_version && value == 100 + original_set_integer_if_greater.call(:sync_version, 200) + false + else + original_set_integer_if_greater.call(key, value) + end + end + + subject.call + + expect(local_flipper[:search].boolean_value).to eq(false) + expect(local.read_integer(:sync_version)).to eq(200) + end + it 'does not instrument synchronizer_outvoted.flipper when local has no prior version' do allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) remote_flipper.enable(:search) From c1e982c5609073f5e75801e4e9ffb708d3730601 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Mon, 11 May 2026 09:35:09 -0400 Subject: [PATCH 13/15] Forward read_integer/set_integer_if_greater through wrapper adapters The base Flipper::Adapter module defines no-op defaults for these methods, which were inherited by wrapper-style adapters that include the module directly (Memoizable, DualWrite, Failover, Failsafe, Sync, Poll, CacheBase). Without explicit forwarding the synchronizer's version-aware skip silently degraded whenever one of these wrappers sat between it and the storage adapter, and bumps did not persist through that layer. Memoizable and CacheBase additionally cache reads and invalidate on writes (including rejected writes, since rejection implies the stored value moved past ours), matching the existing per-feature caching pattern. Co-Authored-By: Claude Opus 4.7 --- lib/flipper/adapters/cache_base.rb | 19 +++++++++ lib/flipper/adapters/dual_write.rb | 10 +++++ lib/flipper/adapters/failover.rb | 12 ++++++ lib/flipper/adapters/failsafe.rb | 12 ++++++ lib/flipper/adapters/memoizable.rb | 19 +++++++++ lib/flipper/adapters/poll.rb | 2 +- lib/flipper/adapters/sync.rb | 11 +++++ .../active_support_cache_store_spec.rb | 33 +++++++++++++++ spec/flipper/adapters/dual_write_spec.rb | 9 ++++ spec/flipper/adapters/failover_spec.rb | 27 ++++++++++++ spec/flipper/adapters/failsafe_spec.rb | 9 ++++ spec/flipper/adapters/memoizable_spec.rb | 41 +++++++++++++++++++ spec/flipper/adapters/poll_spec.rb | 9 ++++ spec/flipper/adapters/sync_spec.rb | 9 ++++ 14 files changed, 221 insertions(+), 1 deletion(-) diff --git a/lib/flipper/adapters/cache_base.rb b/lib/flipper/adapters/cache_base.rb index 03f600d09..efd45c211 100644 --- a/lib/flipper/adapters/cache_base.rb +++ b/lib/flipper/adapters/cache_base.rb @@ -112,6 +112,18 @@ 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)) + end + end + # Public: Generate the cache key for a given feature. # # key - The String or Symbol feature key. @@ -119,6 +131,13 @@ 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 read_all_features(**kwargs) diff --git a/lib/flipper/adapters/dual_write.rb b/lib/flipper/adapters/dual_write.rb index 562cd5208..a014c2b69 100644 --- a/lib/flipper/adapters/dual_write.rb +++ b/lib/flipper/adapters/dual_write.rb @@ -58,6 +58,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) + @remote.set_integer_if_greater(key, value).tap do + @local.set_integer_if_greater(key, value) + end + end end end end diff --git a/lib/flipper/adapters/failover.rb b/lib/flipper/adapters/failover.rb index cc81049e5..99ff9d2c2 100644 --- a/lib/flipper/adapters/failover.rb +++ b/lib/flipper/adapters/failover.rb @@ -80,6 +80,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) + @primary.set_integer_if_greater(key, value).tap do + @secondary.set_integer_if_greater(key, value) if @dual_write + end + end end end end diff --git a/lib/flipper/adapters/failsafe.rb b/lib/flipper/adapters/failsafe.rb index 6779efc2e..257187510 100644 --- a/lib/flipper/adapters/failsafe.rb +++ b/lib/flipper/adapters/failsafe.rb @@ -67,6 +67,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 diff --git a/lib/flipper/adapters/memoizable.rb b/lib/flipper/adapters/memoizable.rb index a037daefa..f9b1a08b3 100644 --- a/lib/flipper/adapters/memoizable.rb +++ b/lib/flipper/adapters/memoizable.rb @@ -126,6 +126,21 @@ def import(source) @adapter.import(source).tap { cache.clear if memoizing? } end + def read_integer(key) + if memoizing? + cache_key = integer_key_for(key) + cache.fetch(cache_key) { cache[cache_key] = @adapter.read_integer(key) } + else + @adapter.read_integer(key) + end + end + + def set_integer_if_greater(key, value) + @adapter.set_integer_if_greater(key, value).tap do + cache.delete(integer_key_for(key)) if memoizing? + end + end + def export(format: :json, version: 1) @adapter.export(format: format, version: version) end @@ -159,6 +174,10 @@ def key_for(key) "feature/#{key}" end + def integer_key_for(key) + "integer/#{key}" + end + def expire_feature(feature) cache.delete(key_for(feature.key)) if memoizing? end diff --git a/lib/flipper/adapters/poll.rb b/lib/flipper/adapters/poll.rb index 20cd9e920..a9c77a726 100644 --- a/lib/flipper/adapters/poll.rb +++ b/lib/flipper/adapters/poll.rb @@ -12,7 +12,7 @@ class Poll attr_reader :adapter, :poller - def_delegators :synced_adapter, :features, :get, :get_multi, :get_all, :add, :remove, :clear, :enable, :disable + def_delegators :synced_adapter, :features, :get, :get_multi, :get_all, :add, :remove, :clear, :enable, :disable, :read_integer, :set_integer_if_greater def initialize(poller, adapter) @adapter = adapter diff --git a/lib/flipper/adapters/sync.rb b/lib/flipper/adapters/sync.rb index ab34b5524..dc153c0a9 100644 --- a/lib/flipper/adapters/sync.rb +++ b/lib/flipper/adapters/sync.rb @@ -81,6 +81,17 @@ def disable(feature, gate, thing) end end + def read_integer(key) + synchronize + @local.read_integer(key) + end + + def set_integer_if_greater(key, value) + @remote.set_integer_if_greater(key, value).tap do + @local.set_integer_if_greater(key, value) + end + end + private def synchronize diff --git a/spec/flipper/adapters/active_support_cache_store_spec.rb b/spec/flipper/adapters/active_support_cache_store_spec.rb index 051b3913c..3fa0845a6 100644 --- a/spec/flipper/adapters/active_support_cache_store_spec.rb +++ b/spec/flipper/adapters/active_support_cache_store_spec.rb @@ -270,4 +270,37 @@ expect(subject.name).to be(:active_support_cache_store) end end + + describe '#read_integer / #set_integer_if_greater' do + it 'forwards to the wrapped adapter' do + expect(subject.set_integer_if_greater(:sync_version, 42)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(42) + expect(memory_adapter.read_integer(:sync_version)).to eq(42) + end + + it 'caches reads and only hits the adapter once' do + memory_adapter.set_integer_if_greater(:sync_version, 42) + memory_adapter.reset + + 3.times { subject.read_integer(:sync_version) } + expect(memory_adapter.count(:read_integer)).to eq(1) + end + + it 'invalidates the cached read after a write' do + memory_adapter.set_integer_if_greater(:sync_version, 42) + expect(subject.read_integer(:sync_version)).to eq(42) + + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'invalidates the cached read even when the write is rejected' do + memory_adapter.set_integer_if_greater(:sync_version, 100) + expect(subject.read_integer(:sync_version)).to eq(100) + + memory_adapter.set_integer_if_greater(:sync_version, 200) + expect(subject.set_integer_if_greater(:sync_version, 150)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(200) + end + end end diff --git a/spec/flipper/adapters/dual_write_spec.rb b/spec/flipper/adapters/dual_write_spec.rb index 7d4e6117e..fe01a9b1f 100644 --- a/spec/flipper/adapters/dual_write_spec.rb +++ b/spec/flipper/adapters/dual_write_spec.rb @@ -67,6 +67,15 @@ expect(local_adapter.count(:disable)).to be(1) end + describe '#read_integer / #set_integer_if_greater' do + it 'reads from local and writes to both adapters' do + expect(subject.set_integer_if_greater(:sync_version, 42)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(42) + expect(local_adapter.read_integer(:sync_version)).to eq(42) + expect(remote_adapter.read_integer(:sync_version)).to eq(42) + end + end + describe '#adapter_stack' do it 'returns the tree representation' do expect(subject.adapter_stack).to eq("dual_write(local: operation_logger -> memory, remote: operation_logger -> memory)") diff --git a/spec/flipper/adapters/failover_spec.rb b/spec/flipper/adapters/failover_spec.rb index a83114b75..fc60f6f01 100644 --- a/spec/flipper/adapters/failover_spec.rb +++ b/spec/flipper/adapters/failover_spec.rb @@ -127,6 +127,33 @@ end end + describe '#read_integer / #set_integer_if_greater' do + it 'reads from primary and writes only to primary by default' do + expect(subject.set_integer_if_greater(:sync_version, 42)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(42) + expect(primary.read_integer(:sync_version)).to eq(42) + expect(secondary.read_integer(:sync_version)).to be_nil + end + + context 'with dual_write enabled' do + let(:options) { { dual_write: true } } + + it 'writes to both primary and secondary' do + expect(subject.set_integer_if_greater(:sync_version, 42)).to eq(true) + expect(primary.read_integer(:sync_version)).to eq(42) + expect(secondary.read_integer(:sync_version)).to eq(42) + end + end + + context 'when primary raises' do + it 'falls back to secondary for reads' do + secondary.set_integer_if_greater(:sync_version, 99) + allow(primary).to receive(:read_integer).and_raise(StandardError) + expect(subject.read_integer(:sync_version)).to eq(99) + end + end + end + describe '#adapter_stack' do it 'returns the tree representation' do expect(subject.adapter_stack).to eq("failover(primary: memory, secondary: memory)") diff --git a/spec/flipper/adapters/failsafe_spec.rb b/spec/flipper/adapters/failsafe_spec.rb index a150a818c..617cb3163 100644 --- a/spec/flipper/adapters/failsafe_spec.rb +++ b/spec/flipper/adapters/failsafe_spec.rb @@ -9,6 +9,13 @@ it_should_behave_like 'a flipper adapter' + describe '#read_integer / #set_integer_if_greater' do + it 'forwards to the wrapped adapter when it does not raise' do + expect(subject.set_integer_if_greater(:sync_version, 42)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(42) + end + end + context 'when disaster strikes' do before do expect(flipper[feature.name].enable).to be(true) @@ -29,6 +36,8 @@ it { expect(subject.get_all).to eq({}) } it { expect(feature.enable).to eq(false) } it { expect(feature.disable).to eq(false) } + it { expect(subject.read_integer(:sync_version)).to be_nil } + it { expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(false) } context 'when used via Flipper' do it { expect(flipper.features).to eq(Set.new) } diff --git a/spec/flipper/adapters/memoizable_spec.rb b/spec/flipper/adapters/memoizable_spec.rb index f80f31d0e..3ae3f8350 100644 --- a/spec/flipper/adapters/memoizable_spec.rb +++ b/spec/flipper/adapters/memoizable_spec.rb @@ -374,4 +374,45 @@ end end end + + describe '#read_integer / #set_integer_if_greater' do + it 'forwards to the wrapped adapter' do + expect(subject.set_integer_if_greater(:sync_version, 42)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(42) + expect(adapter.read_integer(:sync_version)).to eq(42) + end + + context 'when memoizing' do + before { subject.memoize = true } + + it 'memoizes reads and only hits the adapter once' do + adapter.set_integer_if_greater(:sync_version, 42) + logger = Flipper::Adapters::OperationLogger.new(adapter) + memoized = described_class.new(logger, cache) + memoized.memoize = true + + 3.times { memoized.read_integer(:sync_version) } + expect(logger.count(:read_integer)).to eq(1) + end + + it 'invalidates the cached read after a write' do + adapter.set_integer_if_greater(:sync_version, 42) + expect(subject.read_integer(:sync_version)).to eq(42) + + subject.set_integer_if_greater(:sync_version, 100) + expect(subject.read_integer(:sync_version)).to eq(100) + end + + it 'invalidates the cached read even when the write is rejected' do + adapter.set_integer_if_greater(:sync_version, 100) + expect(subject.read_integer(:sync_version)).to eq(100) + + # Simulate another process bumping above what's in cache. The cached + # value (100) is now stale relative to the underlying adapter (200). + adapter.set_integer_if_greater(:sync_version, 200) + expect(subject.set_integer_if_greater(:sync_version, 150)).to eq(false) + expect(subject.read_integer(:sync_version)).to eq(200) + end + end + end end diff --git a/spec/flipper/adapters/poll_spec.rb b/spec/flipper/adapters/poll_spec.rb index 2fe08fe75..71f7ae0fe 100644 --- a/spec/flipper/adapters/poll_spec.rb +++ b/spec/flipper/adapters/poll_spec.rb @@ -38,4 +38,13 @@ expect(local_adapter.features).to eq(remote_adapter.features) end + + describe '#read_integer / #set_integer_if_greater' do + it 'forwards through the synced local adapter' do + instance = described_class.new(poller, local_adapter) + expect(instance.set_integer_if_greater(:sync_version, 42)).to eq(true) + expect(instance.read_integer(:sync_version)).to eq(42) + expect(local_adapter.read_integer(:sync_version)).to eq(42) + end + end end diff --git a/spec/flipper/adapters/sync_spec.rb b/spec/flipper/adapters/sync_spec.rb index 98d7a2fa8..94735d7e8 100644 --- a/spec/flipper/adapters/sync_spec.rb +++ b/spec/flipper/adapters/sync_spec.rb @@ -198,6 +198,15 @@ expect { subject.get_all }.not_to raise_error end + describe '#read_integer / #set_integer_if_greater' do + it 'reads from local and writes to both adapters' do + expect(subject.set_integer_if_greater(:sync_version, 42)).to eq(true) + expect(subject.read_integer(:sync_version)).to eq(42) + expect(local_adapter.read_integer(:sync_version)).to eq(42) + expect(remote_adapter.read_integer(:sync_version)).to eq(42) + end + end + describe '#adapter_stack' do it 'returns the tree representation' do expect(subject.adapter_stack).to eq("sync(local: operation_logger -> memory, remote: operation_logger -> memory)") From 4c5d950f38a30a26673edd7042452539e152e4d2 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Thu, 14 May 2026 12:07:18 -0400 Subject: [PATCH 14/15] Repair cold-start syncs that lose a version outvote The outvote repair path was gated on local_version being non-nil pre-sync. That missed the cold-start race: two synchronizers boot concurrently against a versioned remote, both see local_version as nil, the loser applies its older snapshot last, and its rejected set_integer_if_greater never triggered repair. Local gates ended up stuck at the old snapshot while the stored version read newer. Gate repair on the post-write current_version instead. The winner leaves a higher current_version behind even when local_version was nil initially, so the loser now detects the outvote and repairs. Adapters without typed-integer storage still return nil, so the capability-gap noise suppression from 727dabd3 is preserved. Co-Authored-By: Claude Opus 4.7 --- lib/flipper/adapters/sync/synchronizer.rb | 11 +++-- .../adapters/sync/synchronizer_spec.rb | 45 ++++++++++++++++++- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/lib/flipper/adapters/sync/synchronizer.rb b/lib/flipper/adapters/sync/synchronizer.rb index b46388542..ecde56b24 100644 --- a/lib/flipper/adapters/sync/synchronizer.rb +++ b/lib/flipper/adapters/sync/synchronizer.rb @@ -54,10 +54,13 @@ def sync if remote_version accepted = @local.set_integer_if_greater(SYNC_VERSION_KEY, remote_version) - # Only instrument when we know the local adapter persists versions - # (local_version was non-nil pre-sync). Otherwise a false return - # likely means the adapter has no typed-integer storage, not a race. - if !accepted && local_version + # A rejection only indicates an outvote when the local adapter actually + # stores a higher version. Adapters without typed-integer storage return + # false unconditionally, so guard on the post-write read to keep the + # event and repair scoped to real races (including cold-start interleaves + # where local_version was nil pre-sync). + current_version = @local.read_integer(SYNC_VERSION_KEY) + if !accepted && current_version && current_version.to_i > remote_version.to_i @instrumenter.instrument("synchronizer_outvoted.flipper", remote_version: remote_version) repair_after_outvote(remote_version) end diff --git a/spec/flipper/adapters/sync/synchronizer_spec.rb b/spec/flipper/adapters/sync/synchronizer_spec.rb index 243d84f50..0fbbc9443 100644 --- a/spec/flipper/adapters/sync/synchronizer_spec.rb +++ b/spec/flipper/adapters/sync/synchronizer_spec.rb @@ -177,11 +177,20 @@ expect(local.read_integer(:sync_version)).to eq(100) end - it 'instruments synchronizer_outvoted.flipper when bump is rejected and local already had a version' do + it 'instruments synchronizer_outvoted.flipper when a concurrent writer left local at a higher version' do local.set_integer_if_greater(:sync_version, 50) allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) remote_flipper.enable(:search) - allow(local).to receive(:set_integer_if_greater).with(:sync_version, 100).and_return(false) + original_set_integer_if_greater = local.method(:set_integer_if_greater) + allow(local).to receive(:set_integer_if_greater) do |key, value| + if key == :sync_version && value == 100 + original_set_integer_if_greater.call(:sync_version, 200) + false + else + original_set_integer_if_greater.call(key, value) + end + end + allow(remote).to receive(:get_all).and_return({}) subject.call @@ -232,6 +241,38 @@ expect(instrumenter.events_by_name("synchronizer_outvoted.flipper")).to be_empty end + it 'repairs local gates when a cold-start sync is outvoted by a concurrent newer sync' do + old_remote = Flipper::Adapters::Memory.new + Flipper.new(old_remote).enable(:search) + old_snapshot = old_remote.get_all + + new_remote = Flipper::Adapters::Memory.new + new_flipper = Flipper.new(new_remote) + new_flipper.add(:search) + new_flipper.disable(:search) + new_snapshot = new_remote.get_all + + original_set_integer_if_greater = local.method(:set_integer_if_greater) + + expect(remote).to receive(:get_all).with(cache_bust: false).ordered.and_return(old_snapshot) + expect(remote).to receive(:get_all).with(cache_bust: true).ordered.and_return(new_snapshot) + allow(remote).to receive(:read_integer).with(:sync_version).and_return(100, 200) + allow(local).to receive(:set_integer_if_greater) do |key, value| + if key == :sync_version && value == 100 + original_set_integer_if_greater.call(:sync_version, 200) + false + else + original_set_integer_if_greater.call(key, value) + end + end + + subject.call + + expect(local_flipper[:search].boolean_value).to eq(false) + expect(local.read_integer(:sync_version)).to eq(200) + expect(instrumenter.events_by_name("synchronizer_outvoted.flipper").size).to eq(1) + end + it 'skips local get_all and writes when remote version is not newer' do local.set_integer_if_greater(:sync_version, 100) allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) From cf8f77cc716db7a3175ccd5c311263127a3dd963 Mon Sep 17 00:00:00 2001 From: John Nunemaker Date: Thu, 14 May 2026 20:37:35 -0400 Subject: [PATCH 15/15] Add versioned snapshots for sync --- lib/flipper/adapter.rb | 9 +++ lib/flipper/adapters/cache_base.rb | 18 ++++++ lib/flipper/adapters/dual_write.rb | 6 +- lib/flipper/adapters/failover.rb | 6 +- lib/flipper/adapters/http.rb | 32 ++++++++-- lib/flipper/adapters/instrumented.rb | 11 ++++ lib/flipper/adapters/memoizable.rb | 37 ++++++++++- lib/flipper/adapters/memory.rb | 19 +++++- lib/flipper/adapters/poll.rb | 2 +- lib/flipper/adapters/sync.rb | 6 +- lib/flipper/adapters/sync/synchronizer.rb | 14 ++--- lib/flipper/adapters/wrapper.rb | 1 + lib/flipper/snapshot.rb | 16 +++++ spec/flipper/adapters/dual_write_spec.rb | 8 +++ spec/flipper/adapters/failover_spec.rb | 8 +++ spec/flipper/adapters/http_spec.rb | 31 +++++++++ spec/flipper/adapters/memory_spec.rb | 4 +- .../adapters/sync/synchronizer_spec.rb | 63 +++++++++++++------ spec/flipper/adapters/sync_spec.rb | 11 +++- 19 files changed, 253 insertions(+), 49 deletions(-) create mode 100644 lib/flipper/snapshot.rb diff --git a/lib/flipper/adapter.rb b/lib/flipper/adapter.rb index 65e279631..6cc31ed87 100644 --- a/lib/flipper/adapter.rb +++ b/lib/flipper/adapter.rb @@ -51,6 +51,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. @@ -109,5 +117,6 @@ def adapter_stack require "set" require "flipper/exporter" +require "flipper/snapshot" require "flipper/feature" require "flipper/adapters/sync/synchronizer" diff --git a/lib/flipper/adapters/cache_base.rb b/lib/flipper/adapters/cache_base.rb index efd45c211..60d71a720 100644 --- a/lib/flipper/adapters/cache_base.rb +++ b/lib/flipper/adapters/cache_base.rb @@ -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. @@ -98,6 +100,21 @@ def get_all(**kwargs) } end + def get_all_snapshot(**kwargs) + cache_fetch(@get_all_snapshot_cache_key) { + snapshot = @adapter.get_all_snapshot(**kwargs) + cacheable_snapshot = Flipper::Snapshot.new(features: snapshot.features, version: snapshot.version) + 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 + # Public def enable(feature, gate, thing) result = @adapter.enable(feature, gate, thing) @@ -121,6 +138,7 @@ def read_integer(key) 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 diff --git a/lib/flipper/adapters/dual_write.rb b/lib/flipper/adapters/dual_write.rb index a014c2b69..9ccddd608 100644 --- a/lib/flipper/adapters/dual_write.rb +++ b/lib/flipper/adapters/dual_write.rb @@ -64,9 +64,9 @@ def read_integer(key) end def set_integer_if_greater(key, value) - @remote.set_integer_if_greater(key, value).tap do - @local.set_integer_if_greater(key, value) - end + accepted = @remote.set_integer_if_greater(key, value) + @local.set_integer_if_greater(key, value) if accepted + accepted end end end diff --git a/lib/flipper/adapters/failover.rb b/lib/flipper/adapters/failover.rb index 99ff9d2c2..be09ceec8 100644 --- a/lib/flipper/adapters/failover.rb +++ b/lib/flipper/adapters/failover.rb @@ -88,9 +88,9 @@ def read_integer(key) end def set_integer_if_greater(key, value) - @primary.set_integer_if_greater(key, value).tap do - @secondary.set_integer_if_greater(key, value) if @dual_write - end + accepted = @primary.set_integer_if_greater(key, value) + @secondary.set_integer_if_greater(key, value) if accepted && @dual_write + accepted end end end diff --git a/lib/flipper/adapters/http.rb b/lib/flipper/adapters/http.rb index b94063c97..cc28e8fdb 100644 --- a/lib/flipper/adapters/http.rb +++ b/lib/flipper/adapters/http.rb @@ -61,6 +61,10 @@ def get_multi(features) end def get_all(cache_bust: false) + get_all_snapshot(cache_bust: cache_bust).features + end + + def get_all_snapshot(cache_bust: false) options = {} path = "/features?exclude_gate_names=true" path += "&_cb=#{Time.now.to_i}" if cache_bust @@ -74,10 +78,14 @@ def get_all(cache_bust: false) @get_all_mutex.synchronize { @last_get_all_response = response } if response.is_a?(Net::HTTPNotModified) - cached_result = @get_all_mutex.synchronize { @last_get_all_result } + cached_result, cached_version = @get_all_mutex.synchronize { [@last_get_all_result, @last_sync_version] } if cached_result - return cached_result + return Flipper::Snapshot.new( + features: cached_result, + version: cached_version, + metadata: { response: response } + ) else raise Error, response end @@ -98,14 +106,18 @@ def get_all(cache_bust: false) result[feature.key] = result_for_feature(feature, gates_by_key[feature.key]) end - sync_version = response['flipper-sync-version'] + sync_version = sync_version_from(response) @get_all_mutex.synchronize do @last_get_all_etag = response['etag'] if response['etag'] @last_get_all_result = result - @last_sync_version = sync_version && sync_version.to_i + @last_sync_version = sync_version end - result + Flipper::Snapshot.new( + features: result, + version: sync_version, + metadata: { response: response } + ) end def last_get_all_response @@ -114,6 +126,9 @@ def last_get_all_response def read_integer(key) return nil unless key.to_sym == :sync_version + # HTTP exposes sync_version as metadata on /features. This returns the + # last observed value from get_all_snapshot rather than making a second + # remote read that could race the feature snapshot. @get_all_mutex.synchronize { @last_sync_version } end @@ -175,6 +190,13 @@ def import(source) private + def sync_version_from(response) + header = response['flipper-sync-version'] + Integer(header) if header + rescue ArgumentError + nil + end + def request_body_for_gate(gate, value) data = case gate.key when :boolean diff --git a/lib/flipper/adapters/instrumented.rb b/lib/flipper/adapters/instrumented.rb index 621ee9c8f..a53297df2 100644 --- a/lib/flipper/adapters/instrumented.rb +++ b/lib/flipper/adapters/instrumented.rb @@ -112,6 +112,17 @@ def get_all(**kwargs) end end + def get_all_snapshot(**kwargs) + default_payload = { + operation: :get_all_snapshot, + adapter_name: @adapter.name, + } + + @instrumenter.instrument(InstrumentationName, default_payload) do |payload| + payload[:result] = @adapter.get_all_snapshot(**kwargs) + end + end + # Public def enable(feature, gate, thing) default_payload = { diff --git a/lib/flipper/adapters/memoizable.rb b/lib/flipper/adapters/memoizable.rb index f9b1a08b3..3a2b45d99 100644 --- a/lib/flipper/adapters/memoizable.rb +++ b/lib/flipper/adapters/memoizable.rb @@ -21,6 +21,7 @@ def initialize(adapter, cache = nil) @memoize = false @features_key = :flipper_features @get_all_key = :all_memoized + @get_all_snapshot_key = :all_snapshot_memoized end # Public @@ -107,6 +108,27 @@ def get_all(**kwargs) end end + def get_all_snapshot(**kwargs) + if memoizing? + cache.fetch(@get_all_snapshot_key) do + snapshot = @adapter.get_all_snapshot(**kwargs) + snapshot.features.each do |key, value| + cache[key_for(key)] = value + end + cache[@features_key] = snapshot.features.keys.to_set + cache[@get_all_key] = true + if snapshot.version + cache[integer_key_for(:sync_version)] = snapshot.version + else + cache.delete(integer_key_for(:sync_version)) + end + cache[@get_all_snapshot_key] = snapshot + end + else + @adapter.get_all_snapshot(**kwargs) + end + end + # Public def enable(feature, gate, thing) @adapter.enable(feature, gate, thing).tap { expire_feature(feature) } @@ -137,7 +159,10 @@ def read_integer(key) def set_integer_if_greater(key, value) @adapter.set_integer_if_greater(key, value).tap do - cache.delete(integer_key_for(key)) if memoizing? + if memoizing? + cache.delete(integer_key_for(key)) + cache.delete(@get_all_snapshot_key) + end end end @@ -179,11 +204,17 @@ def integer_key_for(key) end def expire_feature(feature) - cache.delete(key_for(feature.key)) if memoizing? + if memoizing? + cache.delete(key_for(feature.key)) + cache.delete(@get_all_snapshot_key) + end end def expire_features_set - cache.delete(@features_key) if memoizing? + if memoizing? + cache.delete(@features_key) + cache.delete(@get_all_snapshot_key) + end end end end diff --git a/lib/flipper/adapters/memory.rb b/lib/flipper/adapters/memory.rb index 5d455b09d..2a9421684 100644 --- a/lib/flipper/adapters/memory.rb +++ b/lib/flipper/adapters/memory.rb @@ -59,6 +59,15 @@ def get_all(**kwargs) synchronize { Typecast.features_hash(@source) } end + def get_all_snapshot(**kwargs) + synchronize do + Flipper::Snapshot.new( + features: Typecast.features_hash(@source), + version: @integers["sync_version"] + ) + end + end + # Public def enable(feature, gate, thing) synchronize do @@ -116,11 +125,15 @@ def inspect # Public: a more efficient implementation of import for this adapter def import(source) adapter = self.class.from(source) - get_all = Typecast.features_hash(adapter.get_all) - sync_version = adapter.read_integer(:sync_version) + snapshot = adapter.get_all_snapshot + get_all = Typecast.features_hash(snapshot.features) synchronize do @source.replace(get_all) - @integers["sync_version"] = sync_version if sync_version + if snapshot.version + @integers["sync_version"] = snapshot.version + else + @integers.delete("sync_version") + end end true end diff --git a/lib/flipper/adapters/poll.rb b/lib/flipper/adapters/poll.rb index a9c77a726..f68a25ff7 100644 --- a/lib/flipper/adapters/poll.rb +++ b/lib/flipper/adapters/poll.rb @@ -12,7 +12,7 @@ class Poll attr_reader :adapter, :poller - def_delegators :synced_adapter, :features, :get, :get_multi, :get_all, :add, :remove, :clear, :enable, :disable, :read_integer, :set_integer_if_greater + def_delegators :synced_adapter, :features, :get, :get_multi, :get_all, :get_all_snapshot, :add, :remove, :clear, :enable, :disable, :read_integer, :set_integer_if_greater def initialize(poller, adapter) @adapter = adapter diff --git a/lib/flipper/adapters/sync.rb b/lib/flipper/adapters/sync.rb index dc153c0a9..17fa2919a 100644 --- a/lib/flipper/adapters/sync.rb +++ b/lib/flipper/adapters/sync.rb @@ -87,9 +87,9 @@ def read_integer(key) end def set_integer_if_greater(key, value) - @remote.set_integer_if_greater(key, value).tap do - @local.set_integer_if_greater(key, value) - end + accepted = @remote.set_integer_if_greater(key, value) + @local.set_integer_if_greater(key, value) if accepted + accepted end private diff --git a/lib/flipper/adapters/sync/synchronizer.rb b/lib/flipper/adapters/sync/synchronizer.rb index ecde56b24..297ed84b4 100644 --- a/lib/flipper/adapters/sync/synchronizer.rb +++ b/lib/flipper/adapters/sync/synchronizer.rb @@ -42,8 +42,9 @@ def call private def sync - remote_get_all = @remote.get_all(cache_bust: @cache_bust) - remote_version = @remote.read_integer(SYNC_VERSION_KEY) + remote_snapshot = @remote.get_all_snapshot(cache_bust: @cache_bust) + remote_get_all = remote_snapshot.features + remote_version = remote_snapshot.version local_version = @local.read_integer(SYNC_VERSION_KEY) if remote_version && local_version && remote_version.to_i <= local_version.to_i @@ -99,14 +100,13 @@ def repair_after_outvote(outvoted_version) return unless current_version && current_version.to_i > outvoted_version.to_i MAX_OUTVOTE_REPAIRS.times do - remote_get_all = @remote.get_all(cache_bust: true) - remote_version = @remote.read_integer(SYNC_VERSION_KEY) - apply(remote_get_all) + remote_snapshot = @remote.get_all_snapshot(cache_bust: true) + apply(remote_snapshot.features) - @local.set_integer_if_greater(SYNC_VERSION_KEY, remote_version) if remote_version + @local.set_integer_if_greater(SYNC_VERSION_KEY, remote_snapshot.version) if remote_snapshot.version current_version = @local.read_integer(SYNC_VERSION_KEY) - break unless remote_version && current_version && current_version.to_i > remote_version.to_i + break unless remote_snapshot.version && current_version && current_version.to_i > remote_snapshot.version.to_i end end end diff --git a/lib/flipper/adapters/wrapper.rb b/lib/flipper/adapters/wrapper.rb index 6211f0f10..ca754bcb6 100644 --- a/lib/flipper/adapters/wrapper.rb +++ b/lib/flipper/adapters/wrapper.rb @@ -16,6 +16,7 @@ class Wrapper :get, :get_multi, :get_all, + :get_all_snapshot, :enable, :disable, :read_integer, diff --git a/lib/flipper/snapshot.rb b/lib/flipper/snapshot.rb new file mode 100644 index 000000000..16e34a16e --- /dev/null +++ b/lib/flipper/snapshot.rb @@ -0,0 +1,16 @@ +module Flipper + # Public: A point-in-time view of all feature gate data and its source version. + # + # When version is present, it must describe the exact feature data returned in + # this snapshot. Adapters that cannot provide that guarantee should return nil. + class Snapshot + attr_reader :features, :version, :metadata + + def initialize(features:, version: nil, metadata: {}) + @features = features + @version = version + @metadata = metadata.freeze + freeze + end + end +end diff --git a/spec/flipper/adapters/dual_write_spec.rb b/spec/flipper/adapters/dual_write_spec.rb index fe01a9b1f..6fac9ae9d 100644 --- a/spec/flipper/adapters/dual_write_spec.rb +++ b/spec/flipper/adapters/dual_write_spec.rb @@ -74,6 +74,14 @@ expect(local_adapter.read_integer(:sync_version)).to eq(42) expect(remote_adapter.read_integer(:sync_version)).to eq(42) end + + it 'does not write a rejected remote version to local' do + remote_adapter.set_integer_if_greater(:sync_version, 200) + + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(false) + expect(local_adapter.read_integer(:sync_version)).to be_nil + expect(remote_adapter.read_integer(:sync_version)).to eq(200) + end end describe '#adapter_stack' do diff --git a/spec/flipper/adapters/failover_spec.rb b/spec/flipper/adapters/failover_spec.rb index fc60f6f01..f86cf8bcb 100644 --- a/spec/flipper/adapters/failover_spec.rb +++ b/spec/flipper/adapters/failover_spec.rb @@ -143,6 +143,14 @@ expect(primary.read_integer(:sync_version)).to eq(42) expect(secondary.read_integer(:sync_version)).to eq(42) end + + it 'does not write a rejected primary version to secondary' do + primary.set_integer_if_greater(:sync_version, 200) + + expect(subject.set_integer_if_greater(:sync_version, 100)).to eq(false) + expect(primary.read_integer(:sync_version)).to eq(200) + expect(secondary.read_integer(:sync_version)).to be_nil + end end context 'when primary raises' do diff --git a/spec/flipper/adapters/http_spec.rb b/spec/flipper/adapters/http_spec.rb index fb165492a..cfd470029 100644 --- a/spec/flipper/adapters/http_spec.rb +++ b/spec/flipper/adapters/http_spec.rb @@ -222,6 +222,37 @@ end end + describe "#get_all_snapshot" do + it "returns features and sync version from the same response" do + stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") + .to_return(status: 200, body: JSON.generate(features: []), headers: { 'Flipper-Sync-Version' => '12345' }) + + adapter = described_class.new(url: 'http://app.com/flipper') + snapshot = adapter.get_all_snapshot + + expect(snapshot.features).to eq({}) + expect(snapshot.version).to eq(12345) + expect(adapter.read_integer(:sync_version)).to eq(12345) + end + + it "reuses the cached features and version for 304 Not Modified responses" do + stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") + .to_return(status: 200, body: JSON.generate(features: []), + headers: { 'ETag' => '"abc"', 'Flipper-Sync-Version' => '12345' }) + + adapter = described_class.new(url: 'http://app.com/flipper') + adapter.get_all_snapshot + + stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true") + .with(headers: { 'If-None-Match' => '"abc"' }) + .to_return(status: 304, headers: { 'ETag' => '"abc"', 'Flipper-Sync-Version' => '99999' }) + + snapshot = adapter.get_all_snapshot + expect(snapshot.features).to eq({}) + expect(snapshot.version).to eq(12345) + end + end + describe "#set_integer_if_greater" do it "returns false (writes never go server-side via this method)" do adapter = described_class.new(url: 'http://app.com/flipper') diff --git a/spec/flipper/adapters/memory_spec.rb b/spec/flipper/adapters/memory_spec.rb index bbab3000a..570596cfb 100644 --- a/spec/flipper/adapters/memory_spec.rb +++ b/spec/flipper/adapters/memory_spec.rb @@ -69,14 +69,14 @@ expect(subject.read_integer(:sync_version)).to eq(42) end - it 'does not overwrite sync_version when the source has none' do + it 'clears sync_version when the source has none' do subject.set_integer_if_greater(:sync_version, 100) source = described_class.new Flipper.new(source).enable(:search) subject.import(source) - expect(subject.read_integer(:sync_version)).to eq(100) + expect(subject.read_integer(:sync_version)).to be_nil end end diff --git a/spec/flipper/adapters/sync/synchronizer_spec.rb b/spec/flipper/adapters/sync/synchronizer_spec.rb index 0fbbc9443..abada77a7 100644 --- a/spec/flipper/adapters/sync/synchronizer_spec.rb +++ b/spec/flipper/adapters/sync/synchronizer_spec.rb @@ -22,7 +22,7 @@ it "raises errors by default" do exception = StandardError.new - expect(remote).to receive(:get_all).and_raise(exception) + expect(remote).to receive(:get_all_snapshot).and_raise(exception) expect { subject.call }.to raise_error(exception) end @@ -38,7 +38,7 @@ it "does not raise, but instruments exceptions for visibility" do exception = StandardError.new - expect(remote).to receive(:get_all).and_raise(exception) + expect(remote).to receive(:get_all_snapshot).and_raise(exception) expect { subject.call }.not_to raise_error @@ -129,7 +129,7 @@ describe 'sync_version gating' do it 'skips sync when remote version is not strictly greater than local version' do local.set_integer_if_greater(:sync_version, 100) - allow(remote).to receive(:read_integer).with(:sync_version).and_return(99) + remote.set_integer_if_greater(:sync_version, 99) remote_flipper.enable(:search) subject.call @@ -139,7 +139,7 @@ it 'skips sync when remote version equals local version' do local.set_integer_if_greater(:sync_version, 100) - allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) + remote.set_integer_if_greater(:sync_version, 100) remote_flipper.enable(:search) subject.call @@ -149,7 +149,7 @@ it 'syncs and bumps local version when remote version is strictly greater' do local.set_integer_if_greater(:sync_version, 99) - allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) + remote.set_integer_if_greater(:sync_version, 100) remote_flipper.enable(:search) subject.call @@ -158,8 +158,23 @@ expect(local.read_integer(:sync_version)).to eq(100) end + it 'uses the snapshot version instead of a separate remote version read' do + stale_remote = Flipper::Adapters::Memory.new + Flipper.new(stale_remote).enable(:search) + + local.set_integer_if_greater(:sync_version, 50) + allow(remote).to receive(:get_all_snapshot).and_return( + Flipper::Snapshot.new(features: stale_remote.get_all, version: 100) + ) + expect(remote).not_to receive(:read_integer) + + subject.call + + expect(local_flipper[:search].boolean_value).to eq(true) + expect(local.read_integer(:sync_version)).to eq(100) + end + it 'syncs normally when remote returns nil version (older server)' do - allow(remote).to receive(:read_integer).with(:sync_version).and_return(nil) remote_flipper.enable(:search) subject.call @@ -168,7 +183,7 @@ end it 'syncs normally when local has no stored version yet' do - allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) + remote.set_integer_if_greater(:sync_version, 100) remote_flipper.enable(:search) subject.call @@ -179,8 +194,13 @@ it 'instruments synchronizer_outvoted.flipper when a concurrent writer left local at a higher version' do local.set_integer_if_greater(:sync_version, 50) - allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) remote_flipper.enable(:search) + allow(remote).to receive(:get_all_snapshot).with(cache_bust: false).and_return( + Flipper::Snapshot.new(features: remote.get_all, version: 100) + ) + allow(remote).to receive(:get_all_snapshot).with(cache_bust: true).and_return( + Flipper::Snapshot.new(features: {}, version: 200) + ) original_set_integer_if_greater = local.method(:set_integer_if_greater) allow(local).to receive(:set_integer_if_greater) do |key, value| if key == :sync_version && value == 100 @@ -190,7 +210,6 @@ original_set_integer_if_greater.call(key, value) end end - allow(remote).to receive(:get_all).and_return({}) subject.call @@ -213,9 +232,12 @@ local.set_integer_if_greater(:sync_version, 50) original_set_integer_if_greater = local.method(:set_integer_if_greater) - expect(remote).to receive(:get_all).with(cache_bust: false).ordered.and_return(old_snapshot) - expect(remote).to receive(:get_all).with(cache_bust: true).ordered.and_return(new_snapshot) - allow(remote).to receive(:read_integer).with(:sync_version).and_return(100, 200) + expect(remote).to receive(:get_all_snapshot).with(cache_bust: false).ordered.and_return( + Flipper::Snapshot.new(features: old_snapshot, version: 100) + ) + expect(remote).to receive(:get_all_snapshot).with(cache_bust: true).ordered.and_return( + Flipper::Snapshot.new(features: new_snapshot, version: 200) + ) allow(local).to receive(:set_integer_if_greater) do |key, value| if key == :sync_version && value == 100 original_set_integer_if_greater.call(:sync_version, 200) @@ -232,8 +254,10 @@ end it 'does not instrument synchronizer_outvoted.flipper when local has no prior version' do - allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) remote_flipper.enable(:search) + allow(remote).to receive(:get_all_snapshot).with(cache_bust: false).and_return( + Flipper::Snapshot.new(features: remote.get_all, version: 100) + ) allow(local).to receive(:set_integer_if_greater).with(:sync_version, 100).and_return(false) subject.call @@ -254,9 +278,12 @@ original_set_integer_if_greater = local.method(:set_integer_if_greater) - expect(remote).to receive(:get_all).with(cache_bust: false).ordered.and_return(old_snapshot) - expect(remote).to receive(:get_all).with(cache_bust: true).ordered.and_return(new_snapshot) - allow(remote).to receive(:read_integer).with(:sync_version).and_return(100, 200) + expect(remote).to receive(:get_all_snapshot).with(cache_bust: false).ordered.and_return( + Flipper::Snapshot.new(features: old_snapshot, version: 100) + ) + expect(remote).to receive(:get_all_snapshot).with(cache_bust: true).ordered.and_return( + Flipper::Snapshot.new(features: new_snapshot, version: 200) + ) allow(local).to receive(:set_integer_if_greater) do |key, value| if key == :sync_version && value == 100 original_set_integer_if_greater.call(:sync_version, 200) @@ -275,9 +302,9 @@ it 'skips local get_all and writes when remote version is not newer' do local.set_integer_if_greater(:sync_version, 100) - allow(remote).to receive(:read_integer).with(:sync_version).and_return(100) + remote.set_integer_if_greater(:sync_version, 100) expect(local).not_to receive(:get_all) - expect(remote).to receive(:get_all).and_call_original + expect(remote).to receive(:get_all_snapshot).and_call_original subject.call end diff --git a/spec/flipper/adapters/sync_spec.rb b/spec/flipper/adapters/sync_spec.rb index 94735d7e8..08c1195b3 100644 --- a/spec/flipper/adapters/sync_spec.rb +++ b/spec/flipper/adapters/sync_spec.rb @@ -194,7 +194,7 @@ it 'does not raise sync exceptions' do exception = StandardError.new - expect(remote_adapter).to receive(:get_all).and_raise(exception) + expect(remote_adapter).to receive(:get_all_snapshot).and_raise(exception) expect { subject.get_all }.not_to raise_error end @@ -205,6 +205,15 @@ expect(local_adapter.read_integer(:sync_version)).to eq(42) expect(remote_adapter.read_integer(:sync_version)).to eq(42) end + + it 'does not write a rejected remote version to local' do + adapter = subject + remote_adapter.set_integer_if_greater(:sync_version, 200) + + expect(adapter.set_integer_if_greater(:sync_version, 100)).to eq(false) + expect(local_adapter.read_integer(:sync_version)).to be_nil + expect(remote_adapter.read_integer(:sync_version)).to eq(200) + end end describe '#adapter_stack' do