diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index 6e5b71f3..8c8727b6 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -708,7 +708,7 @@ class DataSystemConfig # The (optional) builder proc for FDv1-compatible fallback synchronizer # def initialize(initializers: nil, primary_synchronizer: nil, secondary_synchronizer: nil, - data_store_mode: LaunchDarkly::Interfaces::DataStoreMode::READ_ONLY, data_store: nil, fdv1_fallback_synchronizer: nil) + data_store_mode: LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY, data_store: nil, fdv1_fallback_synchronizer: nil) @initializers = initializers @primary_synchronizer = primary_synchronizer @secondary_synchronizer = secondary_synchronizer diff --git a/lib/ldclient-rb/data_system.rb b/lib/ldclient-rb/data_system.rb index f175bd4b..22d2004a 100644 --- a/lib/ldclient-rb/data_system.rb +++ b/lib/ldclient-rb/data_system.rb @@ -19,7 +19,7 @@ def initialize @primary_synchronizer = nil @secondary_synchronizer = nil @fdv1_fallback_synchronizer = nil - @data_store_mode = LaunchDarkly::Interfaces::DataStoreMode::READ_ONLY + @data_store_mode = LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY @data_store = nil end @@ -205,7 +205,7 @@ def self.custom # @return [ConfigBuilder] # def self.daemon(store) - custom.data_store(store, LaunchDarkly::Interfaces::DataStoreMode::READ_ONLY) + custom.data_store(store, LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY) end # @@ -219,7 +219,7 @@ def self.daemon(store) # @return [ConfigBuilder] # def self.persistent_store(store) - default.data_store(store, LaunchDarkly::Interfaces::DataStoreMode::READ_WRITE) + default.data_store(store, LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_WRITE) end end end diff --git a/lib/ldclient-rb/impl/data_store/store.rb b/lib/ldclient-rb/impl/data_store/store.rb index 1784108d..2aa86daa 100644 --- a/lib/ldclient-rb/impl/data_store/store.rb +++ b/lib/ldclient-rb/impl/data_store/store.rb @@ -328,7 +328,7 @@ def get_data_store_status_provider private def send_change_events(affected_items) affected_items.each do |item| if item[:kind] == FEATURES - @flag_change_broadcaster.broadcast(item[:key]) + @flag_change_broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(item[:key])) end end end diff --git a/lib/ldclient-rb/impl/integrations/test_data/test_data_source_v2.rb b/lib/ldclient-rb/impl/integrations/test_data/test_data_source_v2.rb new file mode 100644 index 00000000..cc40c8ef --- /dev/null +++ b/lib/ldclient-rb/impl/integrations/test_data/test_data_source_v2.rb @@ -0,0 +1,288 @@ +require 'concurrent/atomics' +require 'ldclient-rb/impl/data_system' +require 'ldclient-rb/interfaces/data_system' +require 'ldclient-rb/util' +require 'thread' + +module LaunchDarkly + module Impl + module Integrations + module TestData + # + # Internal implementation of both Initializer and Synchronizer protocols for TestDataV2. + # + # This component bridges the test data management in TestDataV2 with the FDv2 protocol + # interfaces. Each instance implements both Initializer and Synchronizer protocols + # and receives change notifications for dynamic updates. + # + class TestDataSourceV2 + include LaunchDarkly::Interfaces::DataSystem::Initializer + include LaunchDarkly::Interfaces::DataSystem::Synchronizer + + # @api private + # + # @param test_data [LaunchDarkly::Integrations::TestDataV2] the test data instance + # + def initialize(test_data) + @test_data = test_data + @closed = false + @update_queue = Queue.new + @lock = Mutex.new + + # Always register for change notifications + @test_data.add_instance(self) + end + + # + # Return the name of this data source. + # + # @return [String] + # + def name + 'TestDataV2' + end + + # + # Implementation of the Initializer.fetch method. + # + # Returns the current test data as a Basis for initial data loading. + # + # @param selector_store [LaunchDarkly::Interfaces::DataSystem::SelectorStore] Provides the Selector (unused for test data) + # @return [LaunchDarkly::Result] A Result containing either a Basis or an error message + # + def fetch(selector_store) + begin + @lock.synchronize do + if @closed + return LaunchDarkly::Result.fail('TestDataV2 source has been closed') + end + + # Get all current flags and segments from test data + init_data = @test_data.make_init_data + version = @test_data.get_version + + # Build a full transfer changeset + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + + # Add all flags to the changeset + init_data[:flags].each do |key, flag_data| + builder.add_put( + LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + key, + flag_data[:version] || 1, + flag_data + ) + end + + # Add all segments to the changeset + init_data[:segments].each do |key, segment_data| + builder.add_put( + LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT, + key, + segment_data[:version] || 1, + segment_data + ) + end + + # Create selector for this version + selector = LaunchDarkly::Interfaces::DataSystem::Selector.new_selector(version.to_s, version) + change_set = builder.finish(selector) + + basis = LaunchDarkly::Interfaces::DataSystem::Basis.new(change_set: change_set, persist: false, environment_id: nil) + + LaunchDarkly::Result.success(basis) + end + rescue => e + LaunchDarkly::Result.fail("Error fetching test data: #{e.message}", e) + end + end + + # + # Implementation of the Synchronizer.sync method. + # + # Yields updates as test data changes occur. + # + # @param selector_store [LaunchDarkly::Interfaces::DataSystem::SelectorStore] Provides the Selector (unused for test data) + # @yield [LaunchDarkly::Interfaces::DataSystem::Update] Yields Update objects as synchronization progresses + # @return [void] + # + def sync(selector_store) + # First yield initial data + initial_result = fetch(selector_store) + unless initial_result.success? + yield LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::OFF, + error: LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( + LaunchDarkly::Interfaces::DataSource::ErrorInfo::STORE_ERROR, + 0, + initial_result.error, + Time.now + ) + ) + return + end + + # Yield the initial successful state + yield LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::VALID, + change_set: initial_result.value.change_set + ) + + # Continue yielding updates as they arrive + until @closed + begin + # stop() will push nil to the queue to wake us up when shutting down + update = @update_queue.pop + + # Handle nil sentinel for shutdown + break if update.nil? + + # Yield the actual update + yield update + rescue => e + yield LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::OFF, + error: LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( + LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN, + 0, + "Error in test data synchronizer: #{e.message}", + Time.now + ) + ) + break + end + end + end + + # + # Stop the data source and clean up resources + # + # @return [void] + # + def stop + @lock.synchronize do + return if @closed + @closed = true + end + + @test_data.closed_instance(self) + # Signal shutdown to sync generator + @update_queue.push(nil) + end + + # + # Called by TestDataV2 when a flag is updated. + # + # This method converts the flag update into an FDv2 changeset and + # queues it for delivery through the sync() generator. + # + # @param flag_data [Hash] the flag data + # @return [void] + # + def upsert_flag(flag_data) + @lock.synchronize do + return if @closed + + begin + version = @test_data.get_version + + # Build a changes transfer changeset + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES) + + # Add the updated flag + builder.add_put( + LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + flag_data[:key], + flag_data[:version] || 1, + flag_data + ) + + # Create selector for this version + selector = LaunchDarkly::Interfaces::DataSystem::Selector.new_selector(version.to_s, version) + change_set = builder.finish(selector) + + # Queue the update + update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::VALID, + change_set: change_set + ) + + @update_queue.push(update) + rescue => e + # Queue an error update + error_update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::OFF, + error: LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( + LaunchDarkly::Interfaces::DataSource::ErrorInfo::STORE_ERROR, + 0, + "Error processing flag update: #{e.message}", + Time.now + ) + ) + @update_queue.push(error_update) + end + end + end + + # + # Called by TestDataV2 when a segment is updated. + # + # This method converts the segment update into an FDv2 changeset and + # queues it for delivery through the sync() generator. + # + # @param segment_data [Hash] the segment data + # @return [void] + # + def upsert_segment(segment_data) + @lock.synchronize do + return if @closed + + begin + version = @test_data.get_version + + # Build a changes transfer changeset + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES) + + # Add the updated segment + builder.add_put( + LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT, + segment_data[:key], + segment_data[:version] || 1, + segment_data + ) + + # Create selector for this version + selector = LaunchDarkly::Interfaces::DataSystem::Selector.new_selector(version.to_s, version) + change_set = builder.finish(selector) + + # Queue the update + update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::VALID, + change_set: change_set + ) + + @update_queue.push(update) + rescue => e + # Queue an error update + error_update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::OFF, + error: LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( + LaunchDarkly::Interfaces::DataSource::ErrorInfo::STORE_ERROR, + 0, + "Error processing segment update: #{e.message}", + Time.now + ) + ) + @update_queue.push(error_update) + end + end + end + end + end + end + end +end + diff --git a/lib/ldclient-rb/integrations/test_data_v2.rb b/lib/ldclient-rb/integrations/test_data_v2.rb new file mode 100644 index 00000000..267c6359 --- /dev/null +++ b/lib/ldclient-rb/integrations/test_data_v2.rb @@ -0,0 +1,248 @@ +require 'ldclient-rb/impl/integrations/test_data/test_data_source_v2' +require 'ldclient-rb/impl/model/feature_flag' +require 'ldclient-rb/integrations/test_data_v2/flag_builder_v2' +require 'concurrent/atomics' + +module LaunchDarkly + module Integrations + # + # A mechanism for providing dynamically updatable feature flag state in a + # simplified form to an SDK client in test scenarios using the FDv2 protocol. + # + # This type is not stable, and not subject to any backwards + # compatibility guarantees or semantic versioning. It is not suitable for production usage. + # + # Do not use it. + # You have been warned. + # + # Unlike {LaunchDarkly::Integrations::FileData}, this mechanism does not use any external resources. It + # provides only the data that the application has put into it using the {#update} method. + # + # @example + # require 'ldclient-rb/integrations/test_data_v2' + # + # td = LaunchDarkly::Integrations::TestDataV2.data_source + # td.update(td.flag('flag-key-1').variation_for_all(true)) + # + # # Configure the data system with TestDataV2 as both initializer and synchronizer + # # Note: This example assumes FDv2 data system configuration is available + # # data_config = LaunchDarkly::Impl::DataSystem::Config.custom + # # data_config.initializers([td.method(:build_initializer)]) + # # data_config.synchronizers(td.method(:build_synchronizer)) + # + # # config = LaunchDarkly::Config.new( + # # sdk_key, + # # datasystem_config: data_config.build + # # ) + # + # # flags can be updated at any time: + # td.update(td.flag('flag-key-1') + # .variation_for_user('some-user-key', true) + # .fallthrough_variation(false)) + # + # The above example uses a simple boolean flag, but more complex configurations are possible using + # the methods of the {FlagBuilderV2} that is returned by {#flag}. {FlagBuilderV2} + # supports many of the ways a flag can be configured on the LaunchDarkly dashboard, but does not + # currently support 1. rule operators other than "in" and "not in", or 2. percentage rollouts. + # + # If the same `TestDataV2` instance is used to configure multiple `LDClient` instances, + # any changes made to the data will propagate to all of the `LDClient` instances. + # + class TestDataV2 + # Creates a new instance of the test data source. + # + # @return [TestDataV2] a new configurable test data source + def self.data_source + self.new + end + + # @api private + def initialize + @flag_builders = Hash.new + @current_flags = Hash.new + @current_segments = Hash.new + @lock = Concurrent::ReadWriteLock.new + @instances = Array.new + @version = 0 + end + + # + # Creates or copies a {FlagBuilderV2} for building a test flag configuration. + # + # If this flag key has already been defined in this `TestDataV2` instance, then the builder + # starts with the same configuration that was last provided for this flag. + # + # Otherwise, it starts with a new default configuration in which the flag has `true` and + # `false` variations, is `true` for all contexts when targeting is turned on and + # `false` otherwise, and currently has targeting turned on. You can change any of those + # properties, and provide more complex behavior, using the {FlagBuilderV2} methods. + # + # Once you have set the desired configuration, pass the builder to {#update}. + # + # @param key [String] the flag key + # @return [FlagBuilderV2] a flag configuration builder + # + def flag(key) + existing_builder = @lock.with_read_lock do + if @flag_builders.key?(key) && !@flag_builders[key].nil? + @flag_builders[key] + else + nil + end + end + + if existing_builder.nil? + LaunchDarkly::Integrations::TestDataV2::FlagBuilderV2.new(key).boolean_flag + else + existing_builder.clone + end + end + + # + # Updates the test data with the specified flag configuration. + # + # This has the same effect as if a flag were added or modified on the LaunchDarkly dashboard. + # It immediately propagates the flag change to any `LDClient` instance(s) that you have + # already configured to use this `TestDataV2`. If no `LDClient` has been started yet, + # it simply adds this flag to the test data which will be provided to any `LDClient` that + # you subsequently configure. + # + # Any subsequent changes to this {FlagBuilderV2} instance do not affect the test data, + # unless you call {#update} again. + # + # @param flag_builder [FlagBuilderV2] a flag configuration builder + # @return [TestDataV2] the TestDataV2 instance + # + def update(flag_builder) + instances_copy = [] + new_flag = nil + @lock.with_write_lock do + old_flag = @current_flags[flag_builder._key] + old_version = old_flag ? old_flag[:version] : 0 + + new_flag = flag_builder.build(old_version + 1) + + @current_flags[flag_builder._key] = new_flag + @flag_builders[flag_builder._key] = flag_builder.clone + + # Create a copy of instances while holding the lock to avoid race conditions + instances_copy = @instances.dup + end + + instances_copy.each do |instance| + instance.upsert_flag(new_flag) + end + + self + end + + # @api private + def make_init_data + @lock.with_read_lock do + { + flags: @current_flags.dup, + segments: @current_segments.dup, + } + end + end + + # @api private + def get_version + @lock.with_write_lock do + version = @version + @version += 1 + version + end + end + + # @api private + # @param instance [LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2] the TestDataSourceV2 instance to remove + def closed_instance(instance) + @lock.with_write_lock do + @instances.delete(instance) if @instances.include?(instance) + end + end + + # @api private + # @param instance [LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2] the TestDataSourceV2 instance to add + def add_instance(instance) + @lock.with_write_lock do + @instances.push(instance) + end + end + + # + # Copies a full segment data model object into the test data. + # + # It immediately propagates the change to any `LDClient` instance(s) that you have already + # configured to use this `TestDataV2`. If no `LDClient` has been started yet, it simply adds + # this segment to the test data which will be provided to any LDClient that you subsequently + # configure. + # + # This method is currently the only way to inject segment data, since there is no builder + # API for segments. It is mainly intended for the SDK's own tests of segment functionality, + # since application tests that need to produce a desired evaluation state could do so more easily + # by just setting flag values. + # + # @param segment [Hash, LaunchDarkly::Impl::Model::Segment] the segment configuration as a hash or + # a Segment model object. + # @return [TestDataV2] the TestDataV2 instance + # + def use_preconfigured_segment(segment) + instances_copy = [] + segment_key = nil + updated_segment = nil + + @lock.with_write_lock do + # Convert to hash and normalize keys to symbols + segment_hash = if segment.is_a?(Hash) + segment.transform_keys(&:to_sym) + else + segment.as_json + end + segment_key = segment_hash[:key] + + old_segment = @current_segments[segment_key] + old_version = old_segment ? old_segment[:version] : 0 + + updated_segment = segment_hash.dup + updated_segment[:version] = old_version + 1 + + @current_segments[segment_key] = updated_segment + + # Create a copy of instances while holding the lock to avoid race conditions + instances_copy = @instances.dup + end + + instances_copy.each do |instance| + instance.upsert_segment(updated_segment) + end + + self + end + + # + # Creates an initializer that can be used with the FDv2 data system. + # + # @param sdk_key [String] the SDK key + # @param config [LaunchDarkly::Config] the SDK configuration + # @return [LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2] a test data initializer + # + def build_initializer(sdk_key, config) + LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2.new(self) + end + + # + # Creates a synchronizer that can be used with the FDv2 data system. + # + # @param sdk_key [String] the SDK key + # @param config [LaunchDarkly::Config] the SDK configuration + # @return [LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2] a test data synchronizer + # + def build_synchronizer(sdk_key, config) + LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2.new(self) + end + end + end +end + diff --git a/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb b/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb new file mode 100644 index 00000000..26919a54 --- /dev/null +++ b/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb @@ -0,0 +1,582 @@ +require 'ldclient-rb/util' +require 'ldclient-rb/context' +require 'set' + +module LaunchDarkly + module Integrations + class TestDataV2 + # Constants for boolean flag variation indices + TRUE_VARIATION_INDEX = 0 + FALSE_VARIATION_INDEX = 1 + + # @api private + def self.variation_for_boolean(variation) + variation ? TRUE_VARIATION_INDEX : FALSE_VARIATION_INDEX + end + + # + # A builder for feature flag configurations to be used with {TestDataV2}. + # + # @see TestDataV2#flag + # @see TestDataV2#update + # + class FlagBuilderV2 + # @api private + attr_reader :_key + + # @api private + def initialize(key) + @_key = key + @_on = true + @_variations = [] + @_off_variation = nil + @_fallthrough_variation = nil + @_targets = {} + @_rules = [] + end + + # Creates a deep copy of the flag builder when the object is duplicated or cloned. + # Subsequent updates to the original `FlagBuilderV2` object will not update the + # copy and vice versa. + # + # This method is automatically invoked by Ruby's `dup` and `clone` methods. + # Immutable instance variables (strings, numbers, booleans, nil) are automatically + # copied by the `super` call. Only mutable collections need explicit deep copying. + # + # @api private + def initialize_copy(other) + super(other) + @_variations = @_variations.clone + @_targets = deep_copy_targets + @_rules = deep_copy_rules + end + + # + # Sets targeting to be on or off for this flag. + # + # The effect of this depends on the rest of the flag configuration, just as it does on the + # real LaunchDarkly dashboard. In the default configuration that you get from calling + # {TestDataV2#flag} with a new flag key, the flag will return `false` + # whenever targeting is off, and `true` when targeting is on. + # + # @param on [Boolean] true if targeting should be on + # @return [FlagBuilderV2] the flag builder + # + def on(on) + @_on = on + self + end + + # + # Specifies the fallthrough variation. The fallthrough is the value + # that is returned if targeting is on and the context was not matched by a more specific + # target or rule. + # + # If the flag was previously configured with other variations and the variation specified is a boolean, + # this also changes it to a boolean flag. + # + # @param variation [Boolean, Integer] true or false or the desired fallthrough variation index: + # 0 for the first, 1 for the second, etc. + # @return [FlagBuilderV2] the flag builder + # + def fallthrough_variation(variation) + if LaunchDarkly::Impl::Util.bool?(variation) + boolean_flag.fallthrough_variation(TestDataV2.variation_for_boolean(variation)) + else + @_fallthrough_variation = variation + self + end + end + + # + # Specifies the off variation. This is the variation that is returned + # whenever targeting is off. + # + # If the flag was previously configured with other variations and the variation specified is a boolean, + # this also changes it to a boolean flag. + # + # @param variation [Boolean, Integer] true or false or the desired off variation index: + # 0 for the first, 1 for the second, etc. + # @return [FlagBuilderV2] the flag builder + # + def off_variation(variation) + if LaunchDarkly::Impl::Util.bool?(variation) + boolean_flag.off_variation(TestDataV2.variation_for_boolean(variation)) + else + @_off_variation = variation + self + end + end + + # + # A shortcut for setting the flag to use the standard boolean configuration. + # + # This is the default for all new flags created with {TestDataV2#flag}. + # + # The flag will have two variations, `true` and `false` (in that order); + # it will return `false` whenever targeting is off, and `true` when targeting is on + # if no other settings specify otherwise. + # + # @return [FlagBuilderV2] the flag builder + # + def boolean_flag + return self if boolean_flag? + + variations(true, false).fallthrough_variation(TRUE_VARIATION_INDEX).off_variation(FALSE_VARIATION_INDEX) + end + + # + # Changes the allowable variation values for the flag. + # + # The value may be of any valid JSON type. For instance, a boolean flag + # normally has `true, false`; a string-valued flag might have + # `'red', 'green'`; etc. + # + # @example A single variation + # td.flag('new-flag').variations(true) + # + # @example Multiple variations + # td.flag('new-flag').variations('red', 'green', 'blue') + # + # @param variations [Array] the desired variations + # @return [FlagBuilderV2] the flag builder + # + def variations(*variations) + @_variations = variations + self + end + + # + # Sets the flag to always return the specified variation for all contexts. + # + # The variation is specified, targeting is switched on, and any existing targets or rules are removed. + # The fallthrough variation is set to the specified value. The off variation is left unchanged. + # + # If the flag was previously configured with other variations and the variation specified is a boolean, + # this also changes it to a boolean flag. + # + # @param variation [Boolean, Integer] true or false or the desired variation index to return: + # 0 for the first, 1 for the second, etc. + # @return [FlagBuilderV2] the flag builder + # + def variation_for_all(variation) + if LaunchDarkly::Impl::Util.bool?(variation) + return boolean_flag.variation_for_all(TestDataV2.variation_for_boolean(variation)) + end + + clear_rules.clear_targets.on(true).fallthrough_variation(variation) + end + + # + # Sets the flag to always return the specified variation value for all contexts. + # + # The value may be of any valid JSON type. This method changes the flag to have only + # a single variation, which is this value, and to return the same variation + # regardless of whether targeting is on or off. Any existing targets or rules + # are removed. + # + # @param value [Object] the desired value to be returned for all contexts + # @return [FlagBuilderV2] the flag builder + # + def value_for_all(value) + variations(value).variation_for_all(0) + end + + # + # Sets the flag to return the specified variation for a specific user key when targeting + # is on. + # + # This is a shortcut for calling {#variation_for_key} with + # `LaunchDarkly::LDContext::KIND_DEFAULT` as the context kind. + # + # This has no effect when targeting is turned off for the flag. + # + # If the flag was previously configured with other variations and the variation specified is a boolean, + # this also changes it to a boolean flag. + # + # @param user_key [String] a user key + # @param variation [Boolean, Integer] true or false or the desired variation index to return: + # 0 for the first, 1 for the second, etc. + # @return [FlagBuilderV2] the flag builder + # + def variation_for_user(user_key, variation) + variation_for_key(LaunchDarkly::LDContext::KIND_DEFAULT, user_key, variation) + end + + # + # Sets the flag to return the specified variation for a specific context, identified + # by context kind and key, when targeting is on. + # + # This has no effect when targeting is turned off for the flag. + # + # If the flag was previously configured with other variations and the variation specified is a boolean, + # this also changes it to a boolean flag. + # + # @param context_kind [String] the context kind + # @param context_key [String] the context key + # @param variation [Boolean, Integer] true or false or the desired variation index to return: + # 0 for the first, 1 for the second, etc. + # @return [FlagBuilderV2] the flag builder + # + def variation_for_key(context_kind, context_key, variation) + if LaunchDarkly::Impl::Util.bool?(variation) + return boolean_flag.variation_for_key(context_kind, context_key, TestDataV2.variation_for_boolean(variation)) + end + + targets = @_targets[context_kind] + if targets.nil? + targets = {} + @_targets[context_kind] = targets + end + + @_variations.each_index do |idx| + if idx == variation + (targets[idx] ||= Set.new).add(context_key) + elsif targets.key?(idx) + targets[idx].delete(context_key) + end + end + + self + end + + # + # Starts defining a flag rule, using the "is one of" operator. + # + # This is a shortcut for calling {#if_match_context} with + # `LaunchDarkly::LDContext::KIND_DEFAULT` as the context kind. + # + # @example create a rule that returns `true` if the name is "Patsy" or "Edina" + # td.flag("flag") + # .if_match('name', 'Patsy', 'Edina') + # .then_return(true) + # + # @param attribute [String] the user attribute to match against + # @param values [Array] values to compare to + # @return [FlagRuleBuilderV2] the flag rule builder + # + def if_match(attribute, *values) + if_match_context(LaunchDarkly::LDContext::KIND_DEFAULT, attribute, *values) + end + + # + # Starts defining a flag rule, using the "is one of" operator. This matching expression only + # applies to contexts of a specific kind. + # + # @example create a rule that returns `true` if the name attribute for the + # "company" context is "Ella" or "Monsoon": + # td.flag("flag") + # .if_match_context('company', 'name', 'Ella', 'Monsoon') + # .then_return(True) + # + # @param context_kind [String] the context kind + # @param attribute [String] the context attribute to match against + # @param values [Array] values to compare to + # @return [FlagRuleBuilderV2] the flag rule builder + # + def if_match_context(context_kind, attribute, *values) + flag_rule_builder = FlagRuleBuilderV2.new(self) + flag_rule_builder.and_match_context(context_kind, attribute, *values) + end + + # + # Starts defining a flag rule, using the "is not one of" operator. + # + # This is a shortcut for calling {#if_not_match_context} with + # `LaunchDarkly::LDContext::KIND_DEFAULT` as the context kind. + # + # @example create a rule that returns `true` if the name is neither "Saffron" nor "Bubble" + # td.flag("flag") + # .if_not_match('name', 'Saffron', 'Bubble') + # .then_return(true) + # + # @param attribute [String] the user attribute to match against + # @param values [Array] values to compare to + # @return [FlagRuleBuilderV2] the flag rule builder + # + def if_not_match(attribute, *values) + if_not_match_context(LaunchDarkly::LDContext::KIND_DEFAULT, attribute, *values) + end + + # + # Starts defining a flag rule, using the "is not one of" operator. This matching expression only + # applies to contexts of a specific kind. + # + # @example create a rule that returns `true` if the name attribute for the + # "company" context is neither "Pendant" nor "Sterling Cooper": + # td.flag("flag") + # .if_not_match_context('company', 'name', 'Pendant', 'Sterling Cooper') + # .then_return(true) + # + # @param context_kind [String] the context kind + # @param attribute [String] the context attribute to match against + # @param values [Array] values to compare to + # @return [FlagRuleBuilderV2] the flag rule builder + # + def if_not_match_context(context_kind, attribute, *values) + flag_rule_builder = FlagRuleBuilderV2.new(self) + flag_rule_builder.and_not_match_context(context_kind, attribute, *values) + end + + # + # Removes any existing rules from the flag. + # This undoes the effect of methods like {#if_match}. + # + # @return [FlagBuilderV2] the same flag builder + # + def clear_rules + @_rules = [] + self + end + + # + # Removes any existing targets from the flag. + # This undoes the effect of methods like {#variation_for_user}. + # + # @return [FlagBuilderV2] the same flag builder + # + def clear_targets + @_targets = {} + self + end + + # Note that build is private by convention, because we don't want developers to + # consider it part of the public API, but it is still called from TestDataV2. + # + # Creates a dictionary representation of the flag + # + # @api private + # @param version [Integer] the version number of the flag + # @return [Hash] the dictionary representation of the flag + # + def build(version) + base_flag_object = { + key: @_key, + version: version, + on: @_on, + variations: @_variations, + prerequisites: [], + salt: '', + } + + base_flag_object[:offVariation] = @_off_variation unless @_off_variation.nil? + base_flag_object[:fallthrough] = { variation: @_fallthrough_variation } unless @_fallthrough_variation.nil? + + targets = [] + context_targets = [] + @_targets.each do |target_context_kind, target_variations| + target_variations.each do |var_index, target_keys| + if target_context_kind == LaunchDarkly::LDContext::KIND_DEFAULT + targets << { variation: var_index, values: target_keys.to_a.sort } # sorting just for test determinacy + context_targets << { contextKind: target_context_kind, variation: var_index, values: [] } + else + context_targets << { contextKind: target_context_kind, variation: var_index, values: target_keys.to_a.sort } # sorting just for test determinacy + end + end + end + base_flag_object[:targets] = targets unless targets.empty? + base_flag_object[:contextTargets] = context_targets unless context_targets.empty? + + rules = [] + @_rules.each_with_index do |rule, idx| + rules << rule.build(idx.to_s) + end + base_flag_object[:rules] = rules unless rules.empty? + + base_flag_object + end + + # @api private + def add_rule(flag_rule_builder) + @_rules << flag_rule_builder + end + + private def boolean_flag? + @_variations.length == 2 && + @_variations[TRUE_VARIATION_INDEX] == true && + @_variations[FALSE_VARIATION_INDEX] == false + end + + private def deep_copy_targets + to = {} + @_targets.each do |k, v| + to[k] = {} + v.each do |var_idx, keys| + to[k][var_idx] = keys.clone + end + end + to + end + + private def deep_copy_rules + @_rules.map(&:clone) + end + end + + # + # A builder for feature flag rules to be used with {FlagBuilderV2}. + # + # In the LaunchDarkly model, a flag can have any number of rules, and a rule can have any number of + # clauses. A clause is an individual test such as "name is 'X'". A rule matches a context if all of the + # rule's clauses match the context. + # + # To start defining a rule, use one of the flag builder's matching methods such as + # {FlagBuilderV2#if_match}. This defines the first clause for the rule. + # Optionally, you may add more clauses with the rule builder's methods such as + # {#and_match} or {#and_not_match}. + # Finally, call {#then_return} to finish defining the rule. + # + class FlagRuleBuilderV2 + # @api private + # + # @param flag_builder [FlagBuilderV2] the flag builder instance + # + def initialize(flag_builder) + @_flag_builder = flag_builder + @_clauses = [] + @_variation = nil + end + + # @api private + def initialize_copy(other) + super(other) + @_clauses = @_clauses.map(&:clone) + end + + # + # Adds another clause, using the "is one of" operator. + # + # This is a shortcut for calling {#and_match_context} with + # `LaunchDarkly::LDContext::KIND_DEFAULT` as the context kind. + # + # @example create a rule that returns `true` if the name is "Patsy" and the country is "gb" + # td.flag('flag') + # .if_match('name', 'Patsy') + # .and_match('country', 'gb') + # .then_return(true) + # + # @param attribute [String] the user attribute to match against + # @param values [Array] values to compare to + # @return [FlagRuleBuilderV2] the flag rule builder + # + def and_match(attribute, *values) + and_match_context(LaunchDarkly::LDContext::KIND_DEFAULT, attribute, *values) + end + + # + # Adds another clause, using the "is one of" operator. This matching expression only + # applies to contexts of a specific kind. + # + # @example create a rule that returns `true` if the name attribute for the + # "company" context is "Ella", and the country attribute for the "company" context is "gb": + # td.flag('flag') + # .if_match_context('company', 'name', 'Ella') + # .and_match_context('company', 'country', 'gb') + # .then_return(true) + # + # @param context_kind [String] the context kind + # @param attribute [String] the context attribute to match against + # @param values [Array] values to compare to + # @return [FlagRuleBuilderV2] the flag rule builder + # + def and_match_context(context_kind, attribute, *values) + @_clauses << { + contextKind: context_kind, + attribute: attribute, + op: 'in', + values: values.to_a, + negate: false, + } + self + end + + # + # Adds another clause, using the "is not one of" operator. + # + # This is a shortcut for calling {#and_not_match_context} with + # `LaunchDarkly::LDContext::KIND_DEFAULT` as the context kind. + # + # @example create a rule that returns `true` if the name is "Patsy" and the country is not "gb" + # td.flag('flag') + # .if_match('name', 'Patsy') + # .and_not_match('country', 'gb') + # .then_return(true) + # + # @param attribute [String] the user attribute to match against + # @param values [Array] values to compare to + # @return [FlagRuleBuilderV2] the flag rule builder + # + def and_not_match(attribute, *values) + and_not_match_context(LaunchDarkly::LDContext::KIND_DEFAULT, attribute, *values) + end + + # + # Adds another clause, using the "is not one of" operator. This matching expression only + # applies to contexts of a specific kind. + # + # @example create a rule that returns `true` if the name attribute for the + # "company" context is "Ella", and the country attribute for the "company" context is not "gb": + # td.flag('flag') + # .if_match_context('company', 'name', 'Ella') + # .and_not_match_context('company', 'country', 'gb') + # .then_return(true) + # + # @param context_kind [String] the context kind + # @param attribute [String] the context attribute to match against + # @param values [Array] values to compare to + # @return [FlagRuleBuilderV2] the flag rule builder + # + def and_not_match_context(context_kind, attribute, *values) + @_clauses << { + contextKind: context_kind, + attribute: attribute, + op: 'in', + values: values.to_a, + negate: true, + } + self + end + + # + # Finishes defining the rule, specifying the result as either a boolean + # or a variation index. + # + # If the flag was previously configured with other variations and the variation specified is a boolean, + # this also changes it to a boolean flag. + # + # @param variation [Boolean, Integer] true or false or the desired variation index: + # 0 for the first, 1 for the second, etc. + # @return [FlagBuilderV2] the flag builder with this rule added + # + def then_return(variation) + if LaunchDarkly::Impl::Util.bool?(variation) + @_flag_builder.boolean_flag + return then_return(TestDataV2.variation_for_boolean(variation)) + end + + @_variation = variation + @_flag_builder.add_rule(self) + @_flag_builder + end + + # Note that build is private by convention, because we don't want developers to + # consider it part of the public API, but it is still called from FlagBuilderV2. + # + # Creates a dictionary representation of the rule + # + # @api private + # @param id [String] the rule id + # @return [Hash] the dictionary representation of the rule + # + def build(id) + { + id: 'rule' + id, + variation: @_variation, + clauses: @_clauses, + } + end + end + end + end +end + diff --git a/spec/impl/data_system/fdv2_datasystem_spec.rb b/spec/impl/data_system/fdv2_datasystem_spec.rb new file mode 100644 index 00000000..bf7177b0 --- /dev/null +++ b/spec/impl/data_system/fdv2_datasystem_spec.rb @@ -0,0 +1,463 @@ +require "spec_helper" +require "ldclient-rb/impl/data_system/fdv2" +require "ldclient-rb/integrations/test_data_v2" +require "ldclient-rb/data_system" +require "ldclient-rb/impl/data_system" + +module LaunchDarkly + module Impl + module DataSystem + describe FDv2 do + let(:sdk_key) { "test-sdk-key" } + let(:config) { LaunchDarkly::Config.new(logger: $null_log) } + + describe "two-phase initialization" do + it "initializes from initializer then syncs from synchronizer" do + td_initializer = LaunchDarkly::Integrations::TestDataV2.data_source + td_initializer.update(td_initializer.flag("feature-flag").on(true)) + + td_synchronizer = LaunchDarkly::Integrations::TestDataV2.data_source + # Set this to true, and then to false to ensure the version number exceeded + # the initializer version number. Otherwise, they start as the same version + # and the latest value is ignored. + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(true)) + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(false)) + + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers([td_initializer.method(:build_initializer)]) + .synchronizers(td_synchronizer.method(:build_synchronizer)) + .build + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + + initialized = Concurrent::Event.new + modified = Concurrent::Event.new + changes = [] + count = 0 + + listener = Object.new + listener.define_singleton_method(:update) do |flag_change| + count += 1 + changes << flag_change + + initialized.set if count == 2 + modified.set if count == 3 + end + + fdv2.flag_change_broadcaster.add_listener(listener) + + ready_event = fdv2.start + expect(ready_event.wait(2)).to be true + expect(initialized.wait(1)).to be true + + td_synchronizer.update(td_synchronizer.flag("feature-flag").on(true)) + expect(modified.wait(1)).to be true + + expect(changes.length).to eq(3) + expect(changes[0].key).to eq("feature-flag") + expect(changes[1].key).to eq("feature-flag") + expect(changes[2].key).to eq("feature-flag") + + fdv2.stop + end + end + + describe "stopping FDv2" do + it "prevents flag updates after stop" do + td = LaunchDarkly::Integrations::TestDataV2.data_source + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers(nil) + .synchronizers(td.method(:build_synchronizer)) + .build + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + + changed = Concurrent::Event.new + changes = [] + + listener = Object.new + listener.define_singleton_method(:update) do |flag_change| + changes << flag_change + changed.set + end + + fdv2.flag_change_broadcaster.add_listener(listener) + + ready_event = fdv2.start + expect(ready_event.wait(1)).to be true + + fdv2.stop + + td.update(td.flag("feature-flag").on(false)) + expect(changed.wait(1)).to be_falsey, "Flag change listener was erroneously called" + expect(changes.length).to eq(0) + end + end + + describe "data availability" do + it "reports refreshed availability when data is loaded" do + td = LaunchDarkly::Integrations::TestDataV2.data_source + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers(nil) + .synchronizers(td.method(:build_synchronizer)) + .build + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + + ready_event = fdv2.start + expect(ready_event.wait(1)).to be true + + expect(DataAvailability.at_least?(fdv2.data_availability, DataAvailability::REFRESHED)).to be true + expect(DataAvailability.at_least?(fdv2.target_availability, DataAvailability::REFRESHED)).to be true + + fdv2.stop + end + end + + describe "secondary synchronizer fallback" do + it "falls back to secondary synchronizer when primary fails" do + mock_primary = double("primary_synchronizer") + allow(mock_primary).to receive(:name).and_return("mock-primary") + allow(mock_primary).to receive(:stop) + # Return empty - sync yields nothing (synchronizer fails) + allow(mock_primary).to receive(:sync) + + td = LaunchDarkly::Integrations::TestDataV2.data_source + td.update(td.flag("feature-flag").on(true)) + + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers([td.method(:build_initializer)]) + .synchronizers( + lambda { |_, _| mock_primary }, + td.method(:build_synchronizer) + ) + .build + + changed = Concurrent::Event.new + changes = [] + count = 0 + + listener = Object.new + listener.define_singleton_method(:update) do |flag_change| + count += 1 + changes << flag_change + changed.set if count == 2 + end + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + fdv2.flag_change_broadcaster.add_listener(listener) + + ready_event = fdv2.start + expect(ready_event.wait(2)).to be true + + td.update(td.flag("feature-flag").on(false)) + expect(changed.wait(2)).to be true + + expect(changes.length).to eq(2) + expect(changes[0].key).to eq("feature-flag") + expect(changes[1].key).to eq("feature-flag") + + fdv2.stop + end + end + + describe "shutdown when both synchronizers fail" do + it "shuts down data source when both primary and secondary fail" do + mock_primary = double("primary_synchronizer") + allow(mock_primary).to receive(:name).and_return("mock-primary") + allow(mock_primary).to receive(:stop) + # Return empty - sync yields nothing (synchronizer fails) + allow(mock_primary).to receive(:sync) + + mock_secondary = double("secondary_synchronizer") + allow(mock_secondary).to receive(:name).and_return("mock-secondary") + allow(mock_secondary).to receive(:stop) + # Return empty - sync yields nothing (synchronizer fails) + allow(mock_secondary).to receive(:sync) + + td = LaunchDarkly::Integrations::TestDataV2.data_source + td.update(td.flag("feature-flag").on(true)) + + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers([td.method(:build_initializer)]) + .synchronizers( + lambda { |_, _| mock_primary }, + lambda { |_, _| mock_secondary } + ) + .build + + changed = Concurrent::Event.new + + listener = Object.new + listener.define_singleton_method(:update) do |status| + changed.set if status.state == LaunchDarkly::Interfaces::DataSource::Status::OFF + end + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + fdv2.data_source_status_provider.add_listener(listener) + + ready_event = fdv2.start + expect(ready_event.wait(2)).to be true + + expect(changed.wait(5)).to be true + expect(fdv2.data_source_status_provider.status.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) + + fdv2.stop + end + end + + describe "FDv1 fallback on polling error with header" do + it "falls back to FDv1 when synchronizer signals revert_to_fdv1" do + mock_primary = double("primary_synchronizer") + allow(mock_primary).to receive(:name).and_return("mock-primary") + allow(mock_primary).to receive(:stop) + + # Simulate a synchronizer that yields an OFF state with revert_to_fdv1=true + update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::OFF, + revert_to_fdv1: true + ) + allow(mock_primary).to receive(:sync).and_yield(update) + + # Create FDv1 fallback data source with actual data + td_fdv1 = LaunchDarkly::Integrations::TestDataV2.data_source + td_fdv1.update(td_fdv1.flag("fdv1-flag").on(true)) + + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers(nil) + .synchronizers(lambda { |_, _| mock_primary }) + .fdv1_compatible_synchronizer(td_fdv1.method(:build_synchronizer)) + .build + + changed = Concurrent::Event.new + changes = [] + + listener = Object.new + listener.define_singleton_method(:update) do |flag_change| + changes << flag_change + changed.set + end + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + fdv2.flag_change_broadcaster.add_listener(listener) + + ready_event = fdv2.start + expect(ready_event.wait(1)).to be true + + # Update flag in FDv1 data source to verify it's being used + td_fdv1.update(td_fdv1.flag("fdv1-flag").on(false)) + expect(changed.wait(10)).to be true + + # Verify we got flag changes from FDv1 + expect(changes.length).to be > 0 + expect(changes.any? { |change| change.key == "fdv1-flag" }).to be true + + fdv2.stop + end + end + + describe "FDv1 fallback on polling success with header" do + it "falls back to FDv1 even when primary yields valid data with revert_to_fdv1" do + mock_primary = double("primary_synchronizer") + allow(mock_primary).to receive(:name).and_return("mock-primary") + allow(mock_primary).to receive(:stop) + + update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::VALID, + revert_to_fdv1: true + ) + allow(mock_primary).to receive(:sync).and_yield(update) + + # Create FDv1 fallback data source + td_fdv1 = LaunchDarkly::Integrations::TestDataV2.data_source + td_fdv1.update(td_fdv1.flag("fdv1-fallback-flag").on(true)) + + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers(nil) + .synchronizers(lambda { |_, _| mock_primary }) + .fdv1_compatible_synchronizer(td_fdv1.method(:build_synchronizer)) + .build + + changed = Concurrent::Event.new + changes = [] + count = 0 + + listener = Object.new + listener.define_singleton_method(:update) do |flag_change| + count += 1 + changes << flag_change + changed.set + end + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + fdv2.flag_change_broadcaster.add_listener(listener) + + ready_event = fdv2.start + expect(ready_event.wait(2)).to be true + + # Wait for first flag change (from FDv1 synchronizer starting) + expect(changed.wait(3)).to be true + changed = Concurrent::Event.new # Reset for second change + + # Trigger a flag update in FDv1 + td_fdv1.update(td_fdv1.flag("fdv1-fallback-flag").on(false)) + expect(changed.wait(2)).to be true + + # Verify FDv1 is active and we got both changes + expect(changes.length).to eq(2) + expect(changes.all? { |change| change.key == "fdv1-fallback-flag" }).to be true + + fdv2.stop + end + end + + describe "FDv1 fallback with initializer" do + it "falls back to FDv1 and replaces initialized data" do + # Initialize with some data + td_initializer = LaunchDarkly::Integrations::TestDataV2.data_source + td_initializer.update(td_initializer.flag("initial-flag").on(true)) + + # Create mock primary that signals fallback + mock_primary = double("primary_synchronizer") + allow(mock_primary).to receive(:name).and_return("mock-primary") + allow(mock_primary).to receive(:stop) + + update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::OFF, + revert_to_fdv1: true + ) + allow(mock_primary).to receive(:sync).and_yield(update) + + # Create FDv1 fallback with different data + td_fdv1 = LaunchDarkly::Integrations::TestDataV2.data_source + td_fdv1.update(td_fdv1.flag("fdv1-replacement-flag").on(true)) + + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers([td_initializer.method(:build_initializer)]) + .synchronizers(lambda { |_, _| mock_primary }) + .fdv1_compatible_synchronizer(td_fdv1.method(:build_synchronizer)) + .build + + changed = Concurrent::Event.new + changes = [] + + listener = Object.new + listener.define_singleton_method(:update) do |flag_change| + changes << flag_change + changed.set if changes.length >= 2 + end + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + fdv2.flag_change_broadcaster.add_listener(listener) + + ready_event = fdv2.start + expect(ready_event.wait(2)).to be true + expect(changed.wait(3)).to be true + + # Verify we got changes for both flags + flag_keys = changes.map { |change| change.key } + expect(flag_keys).to include("initial-flag") + expect(flag_keys).to include("fdv1-replacement-flag") + + fdv2.stop + end + end + + describe "no fallback without header" do + it "does not fall back to FDv1 when revert_to_fdv1 is false" do + mock_primary = double("primary_synchronizer") + allow(mock_primary).to receive(:name).and_return("mock-primary") + allow(mock_primary).to receive(:stop) + + update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, + revert_to_fdv1: false + ) + allow(mock_primary).to receive(:sync).and_yield(update) + + # Create mock secondary + mock_secondary = double("secondary_synchronizer") + allow(mock_secondary).to receive(:name).and_return("mock-secondary") + allow(mock_secondary).to receive(:stop) + + valid_update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::VALID, + revert_to_fdv1: false + ) + allow(mock_secondary).to receive(:sync).and_yield(valid_update) + + # Create FDv1 fallback (should not be used) + td_fdv1 = LaunchDarkly::Integrations::TestDataV2.data_source + td_fdv1.update(td_fdv1.flag("fdv1-should-not-appear").on(true)) + + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers(nil) + .synchronizers( + lambda { |_, _| mock_primary }, + lambda { |_, _| mock_secondary } + ) + .fdv1_compatible_synchronizer(td_fdv1.method(:build_synchronizer)) + .build + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + + ready_event = fdv2.start + expect(ready_event.wait(2)).to be true + + # Give it a moment to process + sleep 0.5 + + # The primary should have been called, then secondary + expect(mock_primary).to have_received(:sync) + expect(mock_secondary).to have_received(:sync) + + fdv2.stop + end + end + + describe "stays on FDv1 after fallback" do + it "does not retry FDv2 after falling back to FDv1" do + mock_primary = double("primary_synchronizer") + allow(mock_primary).to receive(:name).and_return("mock-primary") + allow(mock_primary).to receive(:stop) + + update = LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::OFF, + revert_to_fdv1: true + ) + allow(mock_primary).to receive(:sync).and_yield(update) + + # Create FDv1 fallback + td_fdv1 = LaunchDarkly::Integrations::TestDataV2.data_source + td_fdv1.update(td_fdv1.flag("fdv1-flag").on(true)) + + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .initializers(nil) + .synchronizers(lambda { |_, _| mock_primary }) + .fdv1_compatible_synchronizer(td_fdv1.method(:build_synchronizer)) + .build + + fdv2 = FDv2.new(sdk_key, config, data_system_config) + + ready_event = fdv2.start + expect(ready_event.wait(2)).to be true + + # Give it time to settle + sleep 1.0 + + # Primary should only be called once (not retried after fallback) + expect(mock_primary).to have_received(:sync).once + + # Verify FDv1 is serving data + store = fdv2.store + flag = store.get(LaunchDarkly::Impl::DataStore::FEATURES, "fdv1-flag") + expect(flag).not_to be_nil + + fdv2.stop + end + end + end + end + end +end + diff --git a/spec/integrations/test_data_v2_spec.rb b/spec/integrations/test_data_v2_spec.rb new file mode 100644 index 00000000..a15c7d86 --- /dev/null +++ b/spec/integrations/test_data_v2_spec.rb @@ -0,0 +1,116 @@ +require "ldclient-rb/integrations/test_data_v2" +require "ldclient-rb/impl/integrations/test_data/test_data_source_v2" +require "spec_helper" + +module LaunchDarkly + module Integrations + describe 'TestDataV2' do + it 'initializes with empty flags and segments' do + td = TestDataV2.data_source + init_data = td.make_init_data + expect(init_data[:flags]).to eq({}) + expect(init_data[:segments]).to eq({}) + end + + it 'stores flags' do + td = TestDataV2.data_source + td.update(td.flag('my-flag').variation_for_all(true)) + init_data = td.make_init_data + expect(init_data[:flags].keys).to include('my-flag') + expect(init_data[:flags]['my-flag'][:key]).to eq('my-flag') + end + + it 'stores preconfigured segments' do + td = TestDataV2.data_source + td.use_preconfigured_segment({ key: 'my-segment', version: 100, included: ['user1'] }) + init_data = td.make_init_data + expect(init_data[:segments].keys).to include('my-segment') + expect(init_data[:segments]['my-segment'][:key]).to eq('my-segment') + expect(init_data[:segments]['my-segment'][:version]).to eq(1) + expect(init_data[:segments]['my-segment'][:included]).to eq(['user1']) + end + + it 'handles segments with string-keyed hashes' do + td = TestDataV2.data_source + # Use string keys instead of symbol keys + td.use_preconfigured_segment({ 'key' => 'my-segment', 'version' => 100, 'included' => ['user1'], 'excluded' => ['user2'] }) + init_data = td.make_init_data + expect(init_data[:segments].keys).to include('my-segment') + expect(init_data[:segments]['my-segment'][:key]).to eq('my-segment') + expect(init_data[:segments]['my-segment'][:version]).to eq(1) + expect(init_data[:segments]['my-segment'][:included]).to eq(['user1']) + expect(init_data[:segments]['my-segment'][:excluded]).to eq(['user2']) + end + + it 'increments segment version on update' do + td = TestDataV2.data_source + td.use_preconfigured_segment({ key: 'my-segment', version: 100 }) + td.use_preconfigured_segment({ key: 'my-segment', included: ['user2'] }) + init_data = td.make_init_data + expect(init_data[:segments]['my-segment'][:version]).to eq(2) + expect(init_data[:segments]['my-segment'][:included]).to eq(['user2']) + end + + describe 'TestDataSourceV2' do + it 'includes both flags and segments in fetch' do + td = TestDataV2.data_source + td.update(td.flag('my-flag').variation_for_all(true)) + td.use_preconfigured_segment({ key: 'my-segment', included: ['user1'] }) + + source = LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2.new(td) + result = source.fetch(nil) + + expect(result.success?).to be true + basis = result.value + change_set = basis.change_set + + # Verify the changeset contains both flags and segments + expect(change_set.changes.length).to eq(2) + + flag_change = change_set.changes.detect { |c| c.kind == LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG } + segment_change = change_set.changes.detect { |c| c.kind == LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT } + + expect(flag_change).not_to be_nil + expect(flag_change.key).to eq('my-flag') + + expect(segment_change).not_to be_nil + expect(segment_change.key).to eq('my-segment') + end + + it 'propagates segment updates' do + td = TestDataV2.data_source + source = LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2.new(td) + + updates = [] + sync_thread = Thread.new do + source.sync(nil) do |update| + updates << update + # Stop after receiving 2 updates (initial + one segment update) + break if updates.length >= 2 + end + end + + # Wait for initial sync + sleep 0.1 + + # Add a segment + td.use_preconfigured_segment({ key: 'test-segment', included: ['user1'] }) + + # Wait for the update to propagate + sync_thread.join(1) + source.stop + + expect(updates.length).to eq(2) + expect(updates[0].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(updates[1].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + + # Check that the second update contains the segment + segment_change = updates[1].change_set.changes.detect { |c| c.kind == LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT } + expect(segment_change).not_to be_nil + expect(segment_change.key).to eq('test-segment') + end + end + end + end +end +