From dc280dc3e422bde81ab2b483e99cc813c43ba245 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 2 Nov 2026 11:39:20 -0500 Subject: [PATCH 1/2] feat!: Require status and close methods on feature stores The SDK no longer checks with respond_to? for the optional feature store methods. Custom stores must now implement them. A custom LaunchDarkly::Interfaces::FeatureStore must implement: - monitoring_enabled? -- return true only if the store publishes status updates when it becomes unavailable and when it recovers. In-memory stores return false. - available? -- return true if the underlying data store is reachable. The SDK polls this method after a store operation fails, while monitoring is enabled. Stores that cannot become unavailable return true. - stop -- release resources. The SDK calls it when the client closes. A custom FeatureStoreCore used with CachingStoreWrapper must implement available? and stop. CachingStoreWrapper now always reports monitoring_enabled? as true and delegates available? to the core. Before this change, a store without these methods silently lost status monitoring, and a core without available? turned monitoring off. The interface docs also disagreed on whether available? would stay. Making the methods required gives one clear contract and removes the probing. The lifecycle method stays named stop. All built-in stores, the big segment store, data sources and event processors already use stop, so renaming only the feature store to close would make the SDK less consistent. --- .../feature_store_client_wrapper.rb | 29 ++------- .../impl/data_store/status_provider.rb | 1 - lib/ldclient-rb/impl/data_store/store.rb | 2 +- lib/ldclient-rb/impl/store_client_wrapper.rb | 28 ++------- lib/ldclient-rb/in_memory_store.rb | 4 ++ .../integrations/util/store_wrapper.rb | 17 ++++-- lib/ldclient-rb/interfaces/feature_store.rb | 61 ++++++++----------- .../feature_store_client_wrapper_spec.rb | 27 +++++++- spec/impl/data_store/store_spec.rb | 29 +++++++++ spec/impl/store_client_wrapper_spec.rb | 31 ++++------ spec/in_memory_feature_store_spec.rb | 4 ++ spec/integrations/util/store_wrapper_spec.rb | 17 ++++-- spec/mock_components.rb | 8 +++ 13 files changed, 145 insertions(+), 113 deletions(-) diff --git a/lib/ldclient-rb/impl/data_store/feature_store_client_wrapper.rb b/lib/ldclient-rb/impl/data_store/feature_store_client_wrapper.rb index 5c86147f..0a0e43af 100644 --- a/lib/ldclient-rb/impl/data_store/feature_store_client_wrapper.rb +++ b/lib/ldclient-rb/impl/data_store/feature_store_client_wrapper.rb @@ -26,7 +26,7 @@ def initialize(store, store_update_sink, logger) @store = store @store_update_sink = store_update_sink @logger = logger - @monitoring_enabled = store_supports_monitoring? + @monitoring_enabled = @store.monitoring_enabled? # Thread synchronization @mutex = Mutex.new @@ -103,6 +103,11 @@ def monitoring_enabled? @monitoring_enabled end + # (see LaunchDarkly::Interfaces::FeatureStore#available?) + def available? + @store.available? + end + # # Wraps store operations with exception handling and availability tracking. # @@ -171,28 +176,6 @@ def monitoring_enabled? @logger.error { "[LDClient] Unexpected error from data store status function: #{e.message}" } end end - - # - # Determines whether the wrapped store can support enabling monitoring. - # - # The wrapped store must provide a monitoring_enabled? method, which must - # be true. But this alone is not sufficient. - # - # Because this class wraps all interactions with a provided store, it can - # technically "monitor" any store. However, monitoring also requires that - # we notify listeners when the store is available again. - # - # We determine this by checking the store's available? method, so this - # is also a requirement for monitoring support. - # - # @return [Boolean] - # - private def store_supports_monitoring? - return false unless @store.respond_to?(:monitoring_enabled?) - return false unless @store.respond_to?(:available?) - - @store.monitoring_enabled? - end end end end diff --git a/lib/ldclient-rb/impl/data_store/status_provider.rb b/lib/ldclient-rb/impl/data_store/status_provider.rb index deae9ee0..cdb73969 100644 --- a/lib/ldclient-rb/impl/data_store/status_provider.rb +++ b/lib/ldclient-rb/impl/data_store/status_provider.rb @@ -63,7 +63,6 @@ def monitoring_enabled? # private def store_supports_monitoring? return false if @store.nil? - return false unless @store.respond_to?(:monitoring_enabled?) @store.monitoring_enabled? end diff --git a/lib/ldclient-rb/impl/data_store/store.rb b/lib/ldclient-rb/impl/data_store/store.rb index 704c82c5..becf7671 100644 --- a/lib/ldclient-rb/impl/data_store/store.rb +++ b/lib/ldclient-rb/impl/data_store/store.rb @@ -97,7 +97,7 @@ def close return nil if @persistent_store.nil? begin - @persistent_store.stop if @persistent_store.respond_to?(:stop) + @persistent_store.stop rescue => e return e end diff --git a/lib/ldclient-rb/impl/store_client_wrapper.rb b/lib/ldclient-rb/impl/store_client_wrapper.rb index 7698b43f..73fd836a 100644 --- a/lib/ldclient-rb/impl/store_client_wrapper.rb +++ b/lib/ldclient-rb/impl/store_client_wrapper.rb @@ -15,7 +15,7 @@ def initialize(store, store_update_sink, logger) # @type [LaunchDarkly::Interfaces::FeatureStore] @store = store - @monitoring_enabled = does_store_support_monitoring? + @monitoring_enabled = @store.monitoring_enabled? # @type [LaunchDarkly::Impl::DataStore::UpdateSink] @store_update_sink = store_update_sink @@ -68,6 +68,10 @@ def monitoring_enabled? @monitoring_enabled end + def available? + @store.available? + end + private def wrapper() begin yield @@ -120,28 +124,6 @@ def monitoring_enabled? @logger.error("Unexpected error from data store status function: #{e}") end end - - # This methods determines whether the wrapped store can support enabling monitoring. - # - # The wrapped store must provide a monitoring_enabled method, which must - # be true. But this alone is not sufficient. - # - # Because this class wraps all interactions with a provided store, it can - # technically "monitor" any store. However, monitoring also requires that - # we notify listeners when the store is available again. - # - # We determine this by checking the store's `available?` method, so this - # is also a requirement for monitoring support. - # - # These extra checks won't be necessary once `available` becomes a part - # of the core interface requirements and this class no longer wraps every - # feature store. - private def does_store_support_monitoring? - return false unless @store.respond_to? :monitoring_enabled? - return false unless @store.respond_to? :available? - - @store.monitoring_enabled? - end end end end diff --git a/lib/ldclient-rb/in_memory_store.rb b/lib/ldclient-rb/in_memory_store.rb index 200b629c..0c6cb2d7 100644 --- a/lib/ldclient-rb/in_memory_store.rb +++ b/lib/ldclient-rb/in_memory_store.rb @@ -20,6 +20,10 @@ def monitoring_enabled? false end + def available? + true + end + def get(kind, key) @lock.with_read_lock do coll = @items[kind] diff --git a/lib/ldclient-rb/integrations/util/store_wrapper.rb b/lib/ldclient-rb/integrations/util/store_wrapper.rb index 402e59ec..bbb600aa 100644 --- a/lib/ldclient-rb/integrations/util/store_wrapper.rb +++ b/lib/ldclient-rb/integrations/util/store_wrapper.rb @@ -44,16 +44,13 @@ def initialize(core, opts) end @inited = Concurrent::AtomicBoolean.new(false) - @has_available_method = @core.respond_to? :available? end def monitoring_enabled? - @has_available_method + true end def available? - return false unless @has_available_method - @core.available? end @@ -254,6 +251,18 @@ def upsert_internal(kind, item) def initialized_internal? end + # + # Tests whether the data store operates normally. This is the same as + # {LaunchDarkly::Interfaces::FeatureStore#available?}. + # + # The wrapper reports status monitoring as enabled for all cores. When an operation + # fails, the SDK calls this method at intervals until it returns true. + # + # @return [Boolean] true if the underlying data store is reachable + # + def available? + end + # # Performs any necessary cleanup to shut down the store when the client is being shut down. # diff --git a/lib/ldclient-rb/interfaces/feature_store.rb b/lib/ldclient-rb/interfaces/feature_store.rb index f3527b0b..65f6e7b3 100644 --- a/lib/ldclient-rb/interfaces/feature_store.rb +++ b/lib/ldclient-rb/interfaces/feature_store.rb @@ -112,54 +112,47 @@ def stop end # - # WARN: This isn't a required method on a FeatureStore yet. The SDK will - # currently check if the provided store responds to this method, and if - # it does, will take appropriate action based on the documented behavior - # below. This will become required in a future major version release of - # the SDK. + # Returns true if this store supports status monitoring. # - # Returns true if this data store implementation supports status - # monitoring. + # This is normally true only for persistent data stores. A custom + # {FeatureStore} implementation can also return true. # - # This is normally only true for persistent data stores but it could also - # be true for any custom {FeatureStore} implementation. + # If this method returns true, the store must publish a status update + # when it enters an invalid state. An invalid state is a state where an + # operation has failed, or where the store knows that operations cannot + # succeed. The store must then publish another status update when it + # returns to a valid state. The SDK uses {#available?} to detect this + # return. # - # Returning true means that the store guarantees that if it ever enters - # an invalid state (that is, an operation has failed or it knows that - # operations cannot succeed at the moment), it will publish a status - # update, and will then publish another status update once it has - # returned to a valid state. + # In-memory stores must return false. # - # Custom implementations must implement `def available?` which - # synchronously checks if the store is available. Without this method, - # the SDK cannot ensure status updates will occur once the store has gone - # offline. + # The SDK returns the same value from + # {LaunchDarkly::Interfaces::DataStore::StatusProvider#monitoring_enabled?}. # - # The same value will be returned from - # {StatusProvider::monitoring_enabled?}. + # @return [Boolean] true if the store supports status monitoring # - # def monitoring_enabled? end + def monitoring_enabled? + end # - # WARN: This isn't a required method on a FeatureStore. The SDK will - # check if the provided store responds to this method, and if it does, - # will take appropriate action based on the documented behavior below. - # Usage of this method will be dropped in a future version of the SDK. + # Tests whether the data store operates normally. # - # Tests whether the data store seems to be functioning normally. + # This must not be a detailed test of different kinds of operations. Do + # the smallest possible operation that shows that the store works. For + # example, make sure that the database is reachable. # - # This should not be a detailed test of different kinds of operations, - # but just the smallest possible operation to determine whether (for - # instance) we can reach the database. + # When one of the other methods of the store raises an exception, the SDK + # assumes that the store can be unavailable. If {#monitoring_enabled?} + # returns true, the SDK then calls this method at intervals until it + # returns true. # - # Whenever one of the store's other methods throws an exception, the SDK - # will assume that it may have become unavailable (e.g. the database - # connection was lost). The SDK will then call {#available?} at intervals - # until it returns true. + # Stores that cannot become unavailable, such as in-memory stores, must + # return true. # # @return [Boolean] true if the underlying data store is reachable # - # def available? end + def available? + end end end end diff --git a/spec/impl/data_store/feature_store_client_wrapper_spec.rb b/spec/impl/data_store/feature_store_client_wrapper_spec.rb index 0a390922..94d6cf4a 100644 --- a/spec/impl/data_store/feature_store_client_wrapper_spec.rb +++ b/spec/impl/data_store/feature_store_client_wrapper_spec.rb @@ -10,9 +10,32 @@ module DataStore let(:logger) { double.as_null_object } let(:status_sink) { double(update_status: nil) } + describe "required store methods" do + [true, false].each do |enabled| + it "reports monitoring_enabled? #{enabled} from the store" do + inner = double("store", monitoring_enabled?: enabled) + expect(described_class.new(inner, status_sink, logger).monitoring_enabled?).to be enabled + end + end + + [true, false].each do |available| + it "gets available? #{available} from the store" do + inner = double("store", monitoring_enabled?: true, available?: available) + expect(described_class.new(inner, status_sink, logger).available?).to be available + end + end + + it "stops the store" do + inner = double("store", monitoring_enabled?: false) + expect(inner).to receive(:stop).once + described_class.new(inner, status_sink, logger).stop + end + end + describe "#disable_cache" do it "forwards to the underlying store when supported" do - inner = double("store", disable_cache: nil, init: nil, get: nil, all: nil, delete: nil, upsert: nil, initialized?: false, stop: nil) + inner = double("store", disable_cache: nil, init: nil, get: nil, all: nil, delete: nil, upsert: nil, initialized?: false, + stop: nil, monitoring_enabled?: false, available?: true) expect(inner).to receive(:disable_cache).once wrapper = described_class.new(inner, status_sink, logger) @@ -27,6 +50,8 @@ def all(_); end def delete(_, _, _); end def upsert(_, _); end def initialized?; false; end + def monitoring_enabled?; false; end + def available?; true; end def stop; end end.new diff --git a/spec/impl/data_store/store_spec.rb b/spec/impl/data_store/store_spec.rb index 546371d3..75cf6ff4 100644 --- a/spec/impl/data_store/store_spec.rb +++ b/spec/impl/data_store/store_spec.rb @@ -60,6 +60,14 @@ def disable_cache @disable_cache_called_count += 1 end + def monitoring_enabled? + true + end + + def available? + true + end + def init(all_data) @init_called_count += 1 if @should_fail @@ -130,6 +138,8 @@ def all(kind); @data[kind] || {}; end def upsert(_kind, _item); end def delete(_kind, _key, _version); end def initialized?; @init_called_count > 0; end + def monitoring_enabled?; true; end + def available?; true; end def stop; end end @@ -701,6 +711,25 @@ def changed_flags end end + describe "#close" do + it "stops the persistent store" do + persistent = StubPersistentStore.new + subject.with_persistence(persistent, true, nil) + expect(persistent).to receive(:stop).once + + expect(subject.close).to be_nil + end + + it "returns the error when the persistent store fails to stop" do + persistent = StubPersistentStore.new + subject.with_persistence(persistent, true, nil) + error = RuntimeError.new("stop failed") + allow(persistent).to receive(:stop).and_raise(error) + + expect(subject.close).to be error + end + end + describe "FDv2 cache lifecycle" do let(:flag_change) do LaunchDarkly::Interfaces::DataSystem::Change.new( diff --git a/spec/impl/store_client_wrapper_spec.rb b/spec/impl/store_client_wrapper_spec.rb index 43ba7745..3f5d2ace 100644 --- a/spec/impl/store_client_wrapper_spec.rb +++ b/spec/impl/store_client_wrapper_spec.rb @@ -25,26 +25,6 @@ module Impl end end - it "will not notify sink if wrapped store cannot come back online" do - store = double - sink = double - - allow(store).to receive(:stop) - allow(store).to receive(:monitoring_enabled?).and_return(true) - allow(store).to receive(:init).and_raise(StandardError.new('init error')) - - ensure_stop(FeatureStoreClientWrapper.new(store, sink, $null_log)) do |wrapper| - begin - wrapper.init({}) - raise "init should have raised exception" - rescue StandardError - # Ignored - end - - expect(sink).not_to receive(:update_status) - end - end - it "sink will be notified when store is back online" do event = Concurrent::Event.new statuses = [] @@ -142,6 +122,17 @@ module Impl expect(checks.value).to eq 0 end end + + describe "required store methods" do + [true, false].each do |available| + it "gets available? #{available} from the store" do + store = double(monitoring_enabled?: true, available?: available, stop: nil) + ensure_stop(FeatureStoreClientWrapper.new(store, double, $null_log)) do |wrapper| + expect(wrapper.available?).to be available + end + end + end + end end end end diff --git a/spec/in_memory_feature_store_spec.rb b/spec/in_memory_feature_store_spec.rb index 6216c2fd..d3c663b6 100644 --- a/spec/in_memory_feature_store_spec.rb +++ b/spec/in_memory_feature_store_spec.rb @@ -18,5 +18,9 @@ def create_feature_store expect(store.monitoring_enabled?).to be false end + + it "is always available" do + expect(subject.new.available?).to be true + end end end diff --git a/spec/integrations/util/store_wrapper_spec.rb b/spec/integrations/util/store_wrapper_spec.rb index a696745e..b1e0c487 100644 --- a/spec/integrations/util/store_wrapper_spec.rb +++ b/spec/integrations/util/store_wrapper_spec.rb @@ -7,7 +7,7 @@ module Integrations THINGS = { namespace: "things" } - it "monitoring enabled if available is defined" do + it "reports monitoring enabled and gets availability from the core" do [true, false].each do |expected| core = double allow(core).to receive(:available?).and_return(expected) @@ -18,12 +18,10 @@ module Integrations end end - it "available is false if core doesn't support monitoring" do + it "stops the core" do core = double - wrapper = subject.new(core, {}) - - expect(wrapper.monitoring_enabled?).to be false - expect(wrapper.available?).to be false + expect(core).to receive(:stop).once + subject.new(core, {}).stop end shared_examples "tests" do |cached| @@ -355,6 +353,13 @@ def initialized_internal? @inited_query_count = @inited_query_count + 1 @inited end + + def available? + true + end + + def stop + end end end end diff --git a/spec/mock_components.rb b/spec/mock_components.rb index c7dc8b9b..fc11d010 100644 --- a/spec/mock_components.rb +++ b/spec/mock_components.rb @@ -78,6 +78,14 @@ def init(all_data) @received_data = all_data end + def monitoring_enabled? + false + end + + def available? + true + end + def stop end end From c539475ce04b09c1994d78a937e85d7f4523a777 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Mon, 2 Nov 2026 12:45:09 -0500 Subject: [PATCH 2/2] feat!: Require feature stores to report whether an upsert or delete applied FeatureStore#upsert and FeatureStore#delete must now return true when the store applies the write, and false when the store already has an item with the same or a higher version. FeatureStoreCore#upsert_internal, used with CachingStoreWrapper, must return the same boolean instead of the final item. Before this change, the SDK sent flag change events after every upsert or delete from the data source, even when the store rejected a stale write. A repeated or out-of-order update therefore produced change events for flags that did not change. The SDK now updates its dependency tracking and sends change events only when the store reports that it applied the write. When a core reports that it did not apply an upsert, CachingStoreWrapper reads the current item from the core and caches it. This matches the Go SDK's persistent store wrapper. Custom store implementers: - A FeatureStore that returns nil or another falsy value from upsert or delete stops flag change events for those writes. The default methods in LaunchDarkly::Interfaces::FeatureStore return nil, so implementations must return the result explicitly. - A FeatureStoreCore must return true or false from upsert_internal. The wrapper no longer accepts the final item as the return value. --- lib/ldclient-rb/impl/data_source.rb | 8 ++-- .../impl/integrations/consul_impl.rb | 9 ++-- .../impl/integrations/dynamodb_impl.rb | 5 +- .../impl/integrations/redis_impl.rb | 6 +-- lib/ldclient-rb/in_memory_store.rb | 12 ++--- .../integrations/util/store_wrapper.rb | 13 +++--- lib/ldclient-rb/interfaces/feature_store.rb | 10 +++- spec/feature_store_spec_base.rb | 14 +++--- spec/impl/data_source_spec.rb | 25 ++++------ spec/integrations/util/store_wrapper_spec.rb | 46 +++++++++++++++++-- 10 files changed, 91 insertions(+), 57 deletions(-) diff --git a/lib/ldclient-rb/impl/data_source.rb b/lib/ldclient-rb/impl/data_source.rb index 8459d81f..1be1a46b 100644 --- a/lib/ldclient-rb/impl/data_source.rb +++ b/lib/ldclient-rb/impl/data_source.rb @@ -107,10 +107,9 @@ def init(all_data) end def upsert(kind, item) - monitor_store_update { @data_store.upsert(kind, item) } + updated = monitor_store_update { @data_store.upsert(kind, item) } + return unless updated - # TODO(sc-197908): We only want to do this if the store successfully - # updates the record. @dependency_tracker.update_dependencies_from(kind, item[:key], item) if @flag_change_broadcaster.has_listeners? affected_items = Set.new @@ -120,7 +119,8 @@ def upsert(kind, item) end def delete(kind, key, version) - monitor_store_update { @data_store.delete(kind, key, version) } + deleted = monitor_store_update { @data_store.delete(kind, key, version) } + return unless deleted @dependency_tracker.update_dependencies_from(kind, key, nil) if @flag_change_broadcaster.has_listeners? diff --git a/lib/ldclient-rb/impl/integrations/consul_impl.rb b/lib/ldclient-rb/impl/integrations/consul_impl.rb index d3e8a1a2..e7ee6a8d 100644 --- a/lib/ldclient-rb/impl/integrations/consul_impl.rb +++ b/lib/ldclient-rb/impl/integrations/consul_impl.rb @@ -92,11 +92,8 @@ def upsert_internal(kind, new_item) mod_index = 0 else old_item = Model.deserialize(kind, old_value[0]["Value"]) - # Check whether the item is stale. If so, don't do the update (and return the existing item to - # FeatureStoreWrapper so it can be cached) - if old_item[:version] >= new_item[:version] - return old_item - end + # Check whether the item is stale. If so, don't do the update. + return false if old_item[:version] >= new_item[:version] mod_index = old_value[0]["ModifyIndex"] end @@ -104,7 +101,7 @@ def upsert_internal(kind, new_item) # the key's ModifyIndex is still equal to the previous value. If the previous ModifyIndex was zero, # it means the key did not previously exist and the write will only succeed if it still doesn't exist. success = Diplomat::Kv.put(key, json, cas: mod_index) - return new_item if success + return true if success # If we failed, retry the whole shebang @logger.debug { "Concurrent modification detected, retrying" } diff --git a/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb b/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb index 2e0d9898..8ca35d15 100644 --- a/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +++ b/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb @@ -140,11 +140,10 @@ def upsert_internal(kind, new_item) ":version" => new_item[:version], }, }) - new_item + true rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException # The item was not updated because there's a newer item in the database. - # We must now read the item that's in the database and return it, so CachingStoreWrapper can cache it. - get_internal(kind, new_item[:key]) + false end end diff --git a/lib/ldclient-rb/impl/integrations/redis_impl.rb b/lib/ldclient-rb/impl/integrations/redis_impl.rb index 83fa7dd9..836ca6ab 100644 --- a/lib/ldclient-rb/impl/integrations/redis_impl.rb +++ b/lib/ldclient-rb/impl/integrations/redis_impl.rb @@ -217,7 +217,7 @@ def upsert_internal(kind, new_item) base_key = items_key(kind) key = new_item[:key] try_again = true - final_item = new_item + updated = true while try_again try_again = false with_connection do |redis| @@ -233,7 +233,7 @@ def upsert_internal(kind, new_item) try_again = true end else - final_item = old_item + updated = false action = new_item[:deleted] ? "delete" : "update" # rubocop:disable Layout/LineLength @logger.warn { "RedisFeatureStore: attempted to #{action} #{key} version: #{old_item[:version]} in '#{kind[:namespace]}' with a version that is the same or older: #{new_item[:version]}" } @@ -242,7 +242,7 @@ def upsert_internal(kind, new_item) end end end - final_item + updated end def initialized_internal? diff --git a/lib/ldclient-rb/in_memory_store.rb b/lib/ldclient-rb/in_memory_store.rb index 0c6cb2d7..c0cd583e 100644 --- a/lib/ldclient-rb/in_memory_store.rb +++ b/lib/ldclient-rb/in_memory_store.rb @@ -47,10 +47,10 @@ def delete(kind, key, version) @items[kind] = coll end old = coll[key.to_sym] + return false unless old.nil? || old[:version] < version - if old.nil? || old[:version] < version - coll[key.to_sym] = { deleted: true, version: version } - end + coll[key.to_sym] = { deleted: true, version: version } + true end end @@ -69,10 +69,10 @@ def upsert(kind, item) @items[kind] = coll end old = coll[item[:key].to_sym] + return false unless old.nil? || old[:version] < item[:version] - if old.nil? || old[:version] < item[:version] - coll[item[:key].to_sym] = item - end + coll[item[:key].to_sym] = item + true end end diff --git a/lib/ldclient-rb/integrations/util/store_wrapper.rb b/lib/ldclient-rb/integrations/util/store_wrapper.rb index bbb600aa..ea885047 100644 --- a/lib/ldclient-rb/integrations/util/store_wrapper.rb +++ b/lib/ldclient-rb/integrations/util/store_wrapper.rb @@ -98,13 +98,16 @@ def all(kind) end def upsert(kind, item) - new_state = @core.upsert_internal(kind, item) + updated = @core.upsert_internal(kind, item) cache = @cache unless cache.nil? + new_state = updated ? item : @core.get_internal(kind, item[:key]) cache[item_cache_key(kind, item[:key])] = [new_state] cache.delete(all_cache_key(kind)) end + + updated end def delete(kind, key, version) @@ -225,17 +228,15 @@ def get_all_internal(kind) # # Attempts to add or update an entity. This is the same as {LaunchDarkly::Interfaces::FeatureStore#upsert} - # except that 1. the wrapper will take care of updating the cache if caching is enabled, and 2. - # the method is expected to return the final state of the entity (i.e. either the `item` - # parameter if the update succeeded, or the previously existing entity in the store if the - # update failed; this is used for the caching logic). + # except that the wrapper updates the cache if caching is enabled. # # Note that FeatureStoreCore does not have a `delete` method. This is because {CachingStoreWrapper} # implements `delete` by simply calling `upsert` with an item whose `:deleted` property is true. # # @param kind [Object] the kind of entity to add or update # @param item [Hash] the entity to add or update - # @return [Hash] the entity as it now exists in the store after the update + # @return [Boolean] true if the store applied the update; false if the store already had an + # item with the same or a higher version # def upsert_internal(kind, item) end diff --git a/lib/ldclient-rb/interfaces/feature_store.rb b/lib/ldclient-rb/interfaces/feature_store.rb index 65f6e7b3..707af583 100644 --- a/lib/ldclient-rb/interfaces/feature_store.rb +++ b/lib/ldclient-rb/interfaces/feature_store.rb @@ -68,9 +68,12 @@ def all(kind) # should only succeed if the new item's `:version` is greater than the old one; # otherwise, the method should do nothing. # + # The SDK uses the return value to decide whether to send flag change events. + # # @param kind [Object] the kind of entity to add or update # @param item [Hash] the entity to add or update - # @return [void] + # @return [Boolean] true if the store applied the update; false if the store already + # had an item with the same or a higher version # def upsert(kind, item) end @@ -80,10 +83,13 @@ def upsert(kind, item) # `version` parameter is greater than the existing entity's `:version`; otherwise, the # method should do nothing. # + # The SDK uses the return value to decide whether to send flag change events. + # # @param kind [Object] the kind of entity to delete # @param key [String] the unique key of the entity # @param version [Integer] the entity must have a lower version than this to be deleted - # @return [void] + # @return [Boolean] true if the store applied the deletion; false if the store already + # had an item with the same or a higher version # def delete(kind, key, version) end diff --git a/spec/feature_store_spec_base.rb b/spec/feature_store_spec_base.rb index ea24bc84..4eb7b0a5 100644 --- a/spec/feature_store_spec_base.rb +++ b/spec/feature_store_spec_base.rb @@ -137,7 +137,7 @@ def new_version_plus(f, delta_version, attrs = {}) it "can add new item" do with_inited_store([]) do |store| - store.upsert($things_kind, $thing1) + expect(store.upsert($things_kind, $thing1)).to be true expect(store.get($things_kind, $key1)).to eq $thing1 end end @@ -145,7 +145,7 @@ def new_version_plus(f, delta_version, attrs = {}) it "can update item with newer version" do with_inited_store([ $thing1 ]) do |store| $thing1_mod = new_version_plus($thing1, 1, { name: $thing1[:name] + ' updated' }) - store.upsert($things_kind, $thing1_mod) + expect(store.upsert($things_kind, $thing1_mod)).to be true expect(store.get($things_kind, $key1)).to eq $thing1_mod end end @@ -153,7 +153,7 @@ def new_version_plus(f, delta_version, attrs = {}) it "cannot update item with same version" do with_inited_store([ $thing1 ]) do |store| $thing1_mod = $thing1.clone.merge({ name: $thing1[:name] + ' updated' }) - store.upsert($things_kind, $thing1_mod) + expect(store.upsert($things_kind, $thing1_mod)).to be false expect(store.get($things_kind, $key1)).to eq $thing1 end end @@ -161,28 +161,28 @@ def new_version_plus(f, delta_version, attrs = {}) it "cannot update feature with older version" do with_inited_store([ $thing1 ]) do |store| $thing1_mod = new_version_plus($thing1, -1, { name: $thing1[:name] + ' updated' }) - store.upsert($things_kind, $thing1_mod) + expect(store.upsert($things_kind, $thing1_mod)).to be false expect(store.get($things_kind, $key1)).to eq $thing1 end end it "can delete item with newer version" do with_inited_store([ $thing1 ]) do |store| - store.delete($things_kind, $key1, $thing1[:version] + 1) + expect(store.delete($things_kind, $key1, $thing1[:version] + 1)).to be true expect(store.get($things_kind, $key1)).to be_nil end end it "cannot delete item with same version" do with_inited_store([ $thing1 ]) do |store| - store.delete($things_kind, $key1, $thing1[:version]) + expect(store.delete($things_kind, $key1, $thing1[:version])).to be false expect(store.get($things_kind, $key1)).to eq $thing1 end end it "cannot delete item with older version" do with_inited_store([ $thing1 ]) do |store| - store.delete($things_kind, $key1, $thing1[:version] - 1) + expect(store.delete($things_kind, $key1, $thing1[:version] - 1)).to be false expect(store.get($things_kind, $key1)).to eq $thing1 end end diff --git a/spec/impl/data_source_spec.rb b/spec/impl/data_source_spec.rb index 05ff23ec..5e381300 100644 --- a/spec/impl/data_source_spec.rb +++ b/spec/impl/data_source_spec.rb @@ -161,9 +161,8 @@ module Impl flag_change_broadcaster.add_listener(listener) sink.upsert(LaunchDarkly::Impl::DataStore::FEATURES, LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag1', version: 2 })) - # TODO(sc-197908): Once the store starts returning a success status on upsert, the flag change notification - # can start ignoring duplicate requests like this. - # sink.upsert(LaunchDarkly::Impl::DataStore::FEATURES, LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag1', version: 2 })) + # A duplicate update does not change the store, so it sends no event. + sink.upsert(LaunchDarkly::Impl::DataStore::FEATURES, LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag1', version: 2 })) expect(listener.statuses.count).to eq(1) expect(listener.statuses[0].key).to eq('flag1') @@ -176,9 +175,8 @@ module Impl flag_change_broadcaster.add_listener(listener) sink.delete(LaunchDarkly::Impl::DataStore::FEATURES, "flag1", 2) - # TODO(sc-197908): Once the store starts returning a success status on delete, the flag change notification - # can start ignoring duplicate requests like this. - # sink.delete(LaunchDarkly::Impl::DataStore::FEATURES, :flag1, 2) + # A duplicate update does not change the store, so it sends no event. + sink.delete(LaunchDarkly::Impl::DataStore::FEATURES, "flag1", 2) expect(listener.statuses.count).to eq(1) expect(listener.statuses[0].key).to eq("flag1") @@ -191,9 +189,8 @@ module Impl flag_change_broadcaster.add_listener(listener) sink.upsert(LaunchDarkly::Impl::DataStore::SEGMENTS, LaunchDarkly::Impl::Model::Segment.new({ key: 'segment2', version: 2 })) - # TODO(sc-197908): Once the store starts returning a success status on upsert, the flag change notification - # can start ignoring duplicate requests like this. - # sink.upsert(LaunchDarkly::Impl::Model::Segment.new({ key: 'segment2', version: 2 })) + # A duplicate update does not change the store, so it sends no event. + sink.upsert(LaunchDarkly::Impl::DataStore::SEGMENTS, LaunchDarkly::Impl::Model::Segment.new({ key: 'segment2', version: 2 })) expect(listener.statuses.count).to eq(1) expect(listener.statuses[0].key).to eq('flag3') @@ -324,9 +321,8 @@ module Impl flag_change_broadcaster.add_listener(listener) sink.upsert(LaunchDarkly::Impl::DataStore::SEGMENTS, LaunchDarkly::Impl::Model::Segment.new({ key: 'segment1', version: 2 })) - # TODO(sc-197908): Once the store starts returning a success status on upsert, the flag change notification - # can start ignoring duplicate requests like this. - # sink.upsert(LaunchDarkly::Impl::DataStore::SEGMENTS, LaunchDarkly::Impl::Model::Segment.new({ key: 'segment1', version: 2 })) + # A duplicate update does not change the store, so it sends no event. + sink.upsert(LaunchDarkly::Impl::DataStore::SEGMENTS, LaunchDarkly::Impl::Model::Segment.new({ key: 'segment1', version: 2 })) expect(listener.statuses.count).to eq(3) expect(listener.statuses[0].key).to eq('flag6') @@ -341,9 +337,8 @@ module Impl flag_change_broadcaster.add_listener(listener) sink.delete(LaunchDarkly::Impl::DataStore::SEGMENTS, 'segment2', 2) - # TODO(sc-197908): Once the store starts returning a success status on upsert, the flag change notification - # can start ignoring duplicate requests like this. - # sink.delete(LaunchDarkly::Impl::DataStore::SEGMENTS, 'segment2', 2) + # A duplicate update does not change the store, so it sends no event. + sink.delete(LaunchDarkly::Impl::DataStore::SEGMENTS, 'segment2', 2) expect(listener.statuses.count).to eq(3) expect(listener.statuses[0].key).to eq('flag6') diff --git a/spec/integrations/util/store_wrapper_spec.rb b/spec/integrations/util/store_wrapper_spec.rb index b1e0c487..004003af 100644 --- a/spec/integrations/util/store_wrapper_spec.rb +++ b/spec/integrations/util/store_wrapper_spec.rb @@ -101,10 +101,10 @@ module Integrations itemv1 = { key: key, version: 1 } itemv2 = { key: key, version: 2 } - wrapper.upsert(THINGS, itemv1) + expect(wrapper.upsert(THINGS, itemv1)).to be true expect(core.data[THINGS][key]).to eq itemv1 - wrapper.upsert(THINGS, itemv2) + expect(wrapper.upsert(THINGS, itemv2)).to be true expect(core.data[THINGS][key]).to eq itemv2 # if we have a cache, verify that the new item is now cached by writing a different value @@ -128,13 +128,34 @@ module Integrations core.force_set(THINGS, itemv1) expect(wrapper.get(THINGS, key)).to eq itemv1 - wrapper.delete(THINGS, key, 2) + expect(wrapper.delete(THINGS, key, 2)).to be true expect(core.data[THINGS][key]).to eq itemv2 core.force_set(THINGS, itemv3) # make a change that bypasses the cache expect(wrapper.get(THINGS, key)).to eq(cached ? nil : itemv3) end + + it "returns false when upsert has a version that is not newer" do + core = MockCore.new + wrapper = subject.new(core, opts) + itemv2 = { key: "flag", version: 2 } + core.force_set(THINGS, itemv2) + + expect(wrapper.upsert(THINGS, { key: "flag", version: 2 })).to be false + expect(wrapper.upsert(THINGS, { key: "flag", version: 1 })).to be false + expect(core.data[THINGS]["flag"]).to eq itemv2 + end + + it "returns false when delete has a version that is not newer" do + core = MockCore.new + wrapper = subject.new(core, opts) + itemv2 = { key: "flag", version: 2 } + core.force_set(THINGS, itemv2) + + expect(wrapper.delete(THINGS, "flag", 2)).to be false + expect(core.data[THINGS]["flag"]).to eq itemv2 + end end context "cached" do @@ -188,6 +209,21 @@ module Integrations expect(wrapper.get(THINGS, key)).to eq itemv2 end + it "upsert caches the item from the store if unsuccessful" do + core = MockCore.new + wrapper = subject.new(core, cached_opts) + key = "flag" + itemv1 = { key: key, version: 1 } + itemv3 = { key: key, version: 3 } + + wrapper.upsert(THINGS, itemv1) + core.force_set(THINGS, itemv3) # another process writes a newer version + + expect(wrapper.upsert(THINGS, { key: key, version: 2 })).to be false + core.force_remove(THINGS, key) # bypasses cache so we can verify that itemv3 is in the cache + expect(wrapper.get(THINGS, key)).to eq itemv3 + end + it "initialized? can cache false result" do core = MockCore.new wrapper = subject.new(core, { expiration: 0.2 }) # use a shorter cache TTL for this test @@ -344,9 +380,9 @@ def get_all_internal(kind) def upsert_internal(kind, item) @data[kind] = {} unless @data.has_key?(kind) old_item = @data[kind][item[:key]] - return old_item if !old_item.nil? && old_item[:version] >= item[:version] + return false if !old_item.nil? && old_item[:version] >= item[:version] @data[kind][item[:key]] = item - item + true end def initialized_internal?