From 5003b432f3c402bfda2fd8e94764cfef433ad3f9 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Wed, 7 Jan 2026 20:29:37 +0000 Subject: [PATCH 01/13] chore: Add FDv2 compatible data source for testing --- .../test_data/test_data_source_v2.rb | 223 +++++++ lib/ldclient-rb/integrations/test_data_v2.rb | 189 ++++++ .../test_data_v2/flag_builder_v2.rb | 580 ++++++++++++++++++ 3 files changed, 992 insertions(+) create mode 100644 lib/ldclient-rb/impl/integrations/test_data/test_data_source_v2.rb create mode 100644 lib/ldclient-rb/integrations/test_data_v2.rb create mode 100644 lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb 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..1db07bd6 --- /dev/null +++ b/lib/ldclient-rb/impl/integrations/test_data/test_data_source_v2.rb @@ -0,0 +1,223 @@ +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 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.each do |key, flag_data| + builder.add_put( + LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + key, + flag_data[:version] || 1, + flag_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 + 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..9892de20 --- /dev/null +++ b/lib/ldclient-rb/integrations/test_data_v2.rb @@ -0,0 +1,189 @@ +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 + @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.copy + 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 = [] + @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.copy + + # 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 + @current_flags.dup + end + end + + # @api private + def get_version + @lock.with_write_lock do + version = @version + @version += 1 + version + end + end + + # @api private + def closed_instance(instance) + @lock.with_write_lock do + @instances.delete(instance) if @instances.include?(instance) + end + end + + # @api private + def add_instance(instance) + @lock.with_write_lock do + @instances.push(instance) + end + end + + # + # Creates an initializer that can be used with the FDv2 data system. + # + # @param config [LaunchDarkly::Config] the SDK configuration + # @return [LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2] a test data initializer + # + def build_initializer(config) + LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2.new(self) + end + + # + # Creates a synchronizer that can be used with the FDv2 data system. + # + # @param config [LaunchDarkly::Config] the SDK configuration + # @return [LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2] a test data synchronizer + # + def build_synchronizer(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..63aa686b --- /dev/null +++ b/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb @@ -0,0 +1,580 @@ +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 + + # + # 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 + + # Note that copy 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 deep copy of the flag builder. Subsequent updates to the + # original `FlagBuilderV2` object will not update the copy and vise versa. + # + # @api private + # @return [FlagBuilderV2] a copy of the flag builder object + # + def copy + to = FlagBuilderV2.new(@_key) + + to.instance_variable_set(:@_on, @_on) + to.instance_variable_set(:@_variations, @_variations.dup) + to.instance_variable_set(:@_off_variation, @_off_variation) + to.instance_variable_set(:@_fallthrough_variation, @_fallthrough_variation) + to.instance_variable_set(:@_targets, deep_copy_targets) + to.instance_variable_set(:@_rules, @_rules.dup) + + to + 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(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(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(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, 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 + + private def variation_for_boolean(variation) + variation ? TRUE_VARIATION_INDEX : FALSE_VARIATION_INDEX + end + + private def boolean_flag? + @_variations.length == 2 && + @_variations[TRUE_VARIATION_INDEX] == true && + @_variations[FALSE_VARIATION_INDEX] == false + end + + private def add_rule(flag_rule_builder) + @_rules << flag_rule_builder + 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.dup + end + end + to + 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 + + # + # 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(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 + + private def variation_for_boolean(variation) + variation ? TRUE_VARIATION_INDEX : FALSE_VARIATION_INDEX + end + end + end + end +end + From 065f066a190068e28c6549bcf5d18ae060ad021b Mon Sep 17 00:00:00 2001 From: Jason Bailey Date: Mon, 12 Jan 2026 09:32:49 -0600 Subject: [PATCH 02/13] Update lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb Co-authored-by: Matthew M. Keeler --- lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 63aa686b..0eef34b9 100644 --- a/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb +++ b/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb @@ -34,7 +34,7 @@ def initialize(key) # consider it part of the public API, but it is still called from TestDataV2. # # Creates a deep copy of the flag builder. Subsequent updates to the - # original `FlagBuilderV2` object will not update the copy and vise versa. + # original `FlagBuilderV2` object will not update the copy and vice versa. # # @api private # @return [FlagBuilderV2] a copy of the flag builder object From e99c7fc5528d0683b27440a00561518e7966e933 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Mon, 12 Jan 2026 18:45:51 +0000 Subject: [PATCH 03/13] adding tests and including fixes identified during tests --- lib/ldclient-rb/config.rb | 2 +- lib/ldclient-rb/data_system.rb | 6 +- lib/ldclient-rb/impl/data_store/store.rb | 2 +- .../test_data/test_data_source_v2.rb | 69 ++- lib/ldclient-rb/integrations/test_data_v2.rb | 60 ++- spec/impl/data_system/fdv2_datasystem_spec.rb | 463 ++++++++++++++++++ spec/integrations/test_data_v2_spec.rb | 104 ++++ 7 files changed, 696 insertions(+), 10 deletions(-) create mode 100644 spec/impl/data_system/fdv2_datasystem_spec.rb create mode 100644 spec/integrations/test_data_v2_spec.rb 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 index 1db07bd6..cc40c8ef 100644 --- 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 @@ -57,7 +57,7 @@ def fetch(selector_store) return LaunchDarkly::Result.fail('TestDataV2 source has been closed') end - # Get all current flags from test data + # Get all current flags and segments from test data init_data = @test_data.make_init_data version = @test_data.get_version @@ -66,7 +66,7 @@ def fetch(selector_store) builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) # Add all flags to the changeset - init_data.each do |key, flag_data| + init_data[:flags].each do |key, flag_data| builder.add_put( LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, key, @@ -75,6 +75,16 @@ def fetch(selector_store) ) 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) @@ -215,6 +225,61 @@ def upsert_flag(flag_data) 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 diff --git a/lib/ldclient-rb/integrations/test_data_v2.rb b/lib/ldclient-rb/integrations/test_data_v2.rb index 9892de20..87ad8709 100644 --- a/lib/ldclient-rb/integrations/test_data_v2.rb +++ b/lib/ldclient-rb/integrations/test_data_v2.rb @@ -60,6 +60,7 @@ def self.data_source def initialize @flag_builders = Hash.new @current_flags = Hash.new + @current_segments = Hash.new @lock = Concurrent::ReadWriteLock.new @instances = Array.new @version = 0 @@ -114,6 +115,7 @@ def flag(key) # 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 @@ -137,7 +139,10 @@ def update(flag_builder) # @api private def make_init_data @lock.with_read_lock do - @current_flags.dup + { + flags: @current_flags.dup, + segments: @current_segments.dup, + } end end @@ -151,6 +156,7 @@ def get_version 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) @@ -158,29 +164,77 @@ def closed_instance(instance) 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] the segment configuration + # @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 if needed + segment_hash = segment.is_a?(Hash) ? segment : segment.as_json + 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(config) + 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(config) + def build_synchronizer(sdk_key, config) LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2.new(self) 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..9678d480 --- /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(1)).to be true + + td.update(td.flag("feature-flag").on(false)) + expect(changed.wait(1)).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(1)).to be true + + expect(changed.wait(2)).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(1)).to be true + + # Wait for first flag change (from FDv1 synchronizer starting) + expect(changed.wait(2)).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(1)).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(1)).to be true + expect(changed.wait(2)).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(1)).to be true + + # Give it a moment to process + sleep 0.2 + + # 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(1)).to be true + + # Give it time to settle + sleep 0.5 + + # 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..5333dc26 --- /dev/null +++ b/spec/integrations/test_data_v2_spec.rb @@ -0,0 +1,104 @@ +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 '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.find { |c| c.kind == LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG } + segment_change = change_set.changes.find { |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.find { |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 + From 8a8b29446ec81a0935adb296d3d9cd3e7246b1a2 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Mon, 12 Jan 2026 21:53:10 +0000 Subject: [PATCH 04/13] fix tests --- .../test_data_v2/flag_builder_v2.rb | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) 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 index 0eef34b9..b2a0ff03 100644 --- a/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb +++ b/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb @@ -9,6 +9,11 @@ class TestDataV2 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}. # @@ -82,7 +87,7 @@ def on(on) # def fallthrough_variation(variation) if LaunchDarkly::Impl::Util.bool?(variation) - boolean_flag.fallthrough_variation(variation_for_boolean(variation)) + boolean_flag.fallthrough_variation(TestDataV2.variation_for_boolean(variation)) else @_fallthrough_variation = variation self @@ -102,7 +107,7 @@ def fallthrough_variation(variation) # def off_variation(variation) if LaunchDarkly::Impl::Util.bool?(variation) - boolean_flag.off_variation(variation_for_boolean(variation)) + boolean_flag.off_variation(TestDataV2.variation_for_boolean(variation)) else @_off_variation = variation self @@ -162,7 +167,7 @@ def variations(*variations) # def variation_for_all(variation) if LaunchDarkly::Impl::Util.bool?(variation) - return boolean_flag.variation_for_all(variation_for_boolean(variation)) + return boolean_flag.variation_for_all(TestDataV2.variation_for_boolean(variation)) end clear_rules.clear_targets.on(true).fallthrough_variation(variation) @@ -221,7 +226,7 @@ def variation_for_user(user_key, variation) # 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, variation_for_boolean(variation)) + return boolean_flag.variation_for_key(context_kind, context_key, TestDataV2.variation_for_boolean(variation)) end targets = @_targets[context_kind] @@ -387,8 +392,9 @@ def build(version) base_flag_object end - private def variation_for_boolean(variation) - variation ? TRUE_VARIATION_INDEX : FALSE_VARIATION_INDEX + # @api private + def add_rule(flag_rule_builder) + @_rules << flag_rule_builder end private def boolean_flag? @@ -397,10 +403,6 @@ def build(version) @_variations[FALSE_VARIATION_INDEX] == false end - private def add_rule(flag_rule_builder) - @_rules << flag_rule_builder - end - private def deep_copy_targets to = {} @_targets.each do |k, v| @@ -545,7 +547,7 @@ def and_not_match_context(context_kind, attribute, *values) def then_return(variation) if LaunchDarkly::Impl::Util.bool?(variation) @_flag_builder.boolean_flag - return then_return(variation_for_boolean(variation)) + return then_return(TestDataV2.variation_for_boolean(variation)) end @_variation = variation @@ -569,10 +571,6 @@ def build(id) clauses: @_clauses, } end - - private def variation_for_boolean(variation) - variation ? TRUE_VARIATION_INDEX : FALSE_VARIATION_INDEX - end end end end From 13cd0a4401e3be4af3018197769fe24289ec3138 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 13 Jan 2026 02:42:12 +0000 Subject: [PATCH 05/13] fix deep copy issue --- lib/ldclient-rb/integrations/test_data_v2.rb | 4 +- .../test_data_v2/flag_builder_v2.rb | 40 ++++++++++--------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/lib/ldclient-rb/integrations/test_data_v2.rb b/lib/ldclient-rb/integrations/test_data_v2.rb index 87ad8709..a99220c6 100644 --- a/lib/ldclient-rb/integrations/test_data_v2.rb +++ b/lib/ldclient-rb/integrations/test_data_v2.rb @@ -94,7 +94,7 @@ def flag(key) if existing_builder.nil? LaunchDarkly::Integrations::TestDataV2::FlagBuilderV2.new(key).boolean_flag else - existing_builder.copy + existing_builder.clone end end @@ -123,7 +123,7 @@ def update(flag_builder) new_flag = flag_builder.build(old_version + 1) @current_flags[flag_builder._key] = new_flag - @flag_builders[flag_builder._key] = flag_builder.copy + @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 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 index b2a0ff03..26919a54 100644 --- a/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb +++ b/lib/ldclient-rb/integrations/test_data_v2/flag_builder_v2.rb @@ -35,26 +35,20 @@ def initialize(key) @_rules = [] end - # Note that copy 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 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. # - # Creates a deep copy of the flag builder. 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 - # @return [FlagBuilderV2] a copy of the flag builder object - # - def copy - to = FlagBuilderV2.new(@_key) - - to.instance_variable_set(:@_on, @_on) - to.instance_variable_set(:@_variations, @_variations.dup) - to.instance_variable_set(:@_off_variation, @_off_variation) - to.instance_variable_set(:@_fallthrough_variation, @_fallthrough_variation) - to.instance_variable_set(:@_targets, deep_copy_targets) - to.instance_variable_set(:@_rules, @_rules.dup) - - to + def initialize_copy(other) + super(other) + @_variations = @_variations.clone + @_targets = deep_copy_targets + @_rules = deep_copy_rules end # @@ -408,11 +402,15 @@ def add_rule(flag_rule_builder) @_targets.each do |k, v| to[k] = {} v.each do |var_idx, keys| - to[k][var_idx] = keys.dup + to[k][var_idx] = keys.clone end end to end + + private def deep_copy_rules + @_rules.map(&:clone) + end end # @@ -439,6 +437,12 @@ def initialize(flag_builder) @_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. # From 2fc9fa0fc7f9ccc73046c939bc0ec9c8e64b1872 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 13 Jan 2026 02:51:02 +0000 Subject: [PATCH 06/13] fix lint error --- spec/integrations/test_data_v2_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/integrations/test_data_v2_spec.rb b/spec/integrations/test_data_v2_spec.rb index 5333dc26..f797fb66 100644 --- a/spec/integrations/test_data_v2_spec.rb +++ b/spec/integrations/test_data_v2_spec.rb @@ -55,8 +55,8 @@ module Integrations # Verify the changeset contains both flags and segments expect(change_set.changes.length).to eq(2) - flag_change = change_set.changes.find { |c| c.kind == LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG } - segment_change = change_set.changes.find { |c| c.kind == LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT } + 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') @@ -93,7 +93,7 @@ module Integrations 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.find { |c| c.kind == LaunchDarkly::Interfaces::DataSystem::ObjectKind::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 From 1077e2d5484c2f2336c36fe8f8d80848188d4156 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 13 Jan 2026 03:11:05 +0000 Subject: [PATCH 07/13] because of thread.join with timeouts increase waits to avoid flaky tests --- spec/impl/data_system/fdv2_datasystem_spec.rb | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/spec/impl/data_system/fdv2_datasystem_spec.rb b/spec/impl/data_system/fdv2_datasystem_spec.rb index 9678d480..bf7177b0 100644 --- a/spec/impl/data_system/fdv2_datasystem_spec.rb +++ b/spec/impl/data_system/fdv2_datasystem_spec.rb @@ -148,10 +148,10 @@ module DataSystem fdv2.flag_change_broadcaster.add_listener(listener) ready_event = fdv2.start - expect(ready_event.wait(1)).to be true + expect(ready_event.wait(2)).to be true td.update(td.flag("feature-flag").on(false)) - expect(changed.wait(1)).to be true + expect(changed.wait(2)).to be true expect(changes.length).to eq(2) expect(changes[0].key).to eq("feature-flag") @@ -197,9 +197,9 @@ module DataSystem fdv2.data_source_status_provider.add_listener(listener) ready_event = fdv2.start - expect(ready_event.wait(1)).to be true + expect(ready_event.wait(2)).to be true - expect(changed.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 @@ -293,15 +293,15 @@ module DataSystem fdv2.flag_change_broadcaster.add_listener(listener) ready_event = fdv2.start - expect(ready_event.wait(1)).to be true + expect(ready_event.wait(2)).to be true # Wait for first flag change (from FDv1 synchronizer starting) - expect(changed.wait(2)).to be true + 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(1)).to be true + expect(changed.wait(2)).to be true # Verify FDv1 is active and we got both changes expect(changes.length).to eq(2) @@ -351,8 +351,8 @@ module DataSystem fdv2.flag_change_broadcaster.add_listener(listener) ready_event = fdv2.start - expect(ready_event.wait(1)).to be true - expect(changed.wait(2)).to be true + 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 } @@ -402,10 +402,10 @@ module DataSystem fdv2 = FDv2.new(sdk_key, config, data_system_config) ready_event = fdv2.start - expect(ready_event.wait(1)).to be true + expect(ready_event.wait(2)).to be true # Give it a moment to process - sleep 0.2 + sleep 0.5 # The primary should have been called, then secondary expect(mock_primary).to have_received(:sync) @@ -440,10 +440,10 @@ module DataSystem fdv2 = FDv2.new(sdk_key, config, data_system_config) ready_event = fdv2.start - expect(ready_event.wait(1)).to be true + expect(ready_event.wait(2)).to be true # Give it time to settle - sleep 0.5 + sleep 1.0 # Primary should only be called once (not retried after fallback) expect(mock_primary).to have_received(:sync).once From 7066680f171c6a04cdfb0a79990b8e0b6661451b Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 13 Jan 2026 06:35:52 +0000 Subject: [PATCH 08/13] chore: Create FDv2 and fallback polling data source --- lib/ldclient-rb/data_system.rb | 35 +- lib/ldclient-rb/impl/data_system/polling.rb | 561 ++++++++++++++++++ lib/ldclient-rb/util.rb | 13 +- .../data_system/polling_initializer_spec.rb | 157 +++++ .../polling_payload_parsing_spec.rb | 317 ++++++++++ .../data_system/polling_synchronizer_spec.rb | 542 +++++++++++++++++ 6 files changed, 1613 insertions(+), 12 deletions(-) create mode 100644 lib/ldclient-rb/impl/data_system/polling.rb create mode 100644 spec/impl/data_system/polling_initializer_spec.rb create mode 100644 spec/impl/data_system/polling_payload_parsing_spec.rb create mode 100644 spec/impl/data_system/polling_synchronizer_spec.rb diff --git a/lib/ldclient-rb/data_system.rb b/lib/ldclient-rb/data_system.rb index 22d2004a..d613e30d 100644 --- a/lib/ldclient-rb/data_system.rb +++ b/lib/ldclient-rb/data_system.rb @@ -2,6 +2,7 @@ require 'ldclient-rb/interfaces/data_system' require 'ldclient-rb/config' +require 'ldclient-rb/impl/data_system/polling' module LaunchDarkly # @@ -97,23 +98,39 @@ def build end end - # @private + # + # Returns a builder proc for creating a polling data source. + # This is a building block that can be used with {ConfigBuilder#initializers} + # or {ConfigBuilder#synchronizers} to create custom data system configurations. + # + # @return [Proc] A proc that takes (sdk_key, config) and returns a polling data source + # def self.polling_ds_builder - # TODO(fdv2): Implement polling data source builder - lambda do |_sdk_key, _config| - raise NotImplementedError, "Polling data source not yet implemented for FDv2" + lambda do |sdk_key, config| + LaunchDarkly::Impl::DataSystem::PollingDataSourceBuilder.new(sdk_key, config).build end end - # @private + # + # Returns a builder proc for creating an FDv1 fallback polling data source. + # This is a building block that can be used with {ConfigBuilder#fdv1_compatible_synchronizer} + # to provide FDv1 compatibility in custom data system configurations. + # + # @return [Proc] A proc that takes (sdk_key, config) and returns an FDv1 polling data source + # def self.fdv1_fallback_ds_builder - # TODO(fdv2): Implement FDv1 fallback polling data source builder - lambda do |_sdk_key, _config| - raise NotImplementedError, "FDv1 fallback data source not yet implemented for FDv2" + lambda do |sdk_key, config| + LaunchDarkly::Impl::DataSystem::FDv1PollingDataSourceBuilder.new(sdk_key, config).build end end - # @private + # + # Returns a builder proc for creating a streaming data source. + # This is a building block that can be used with {ConfigBuilder#synchronizers} + # to create custom data system configurations. + # + # @return [Proc] A proc that takes (sdk_key, config) and returns a streaming data source + # def self.streaming_ds_builder # TODO(fdv2): Implement streaming data source builder lambda do |_sdk_key, _config| diff --git a/lib/ldclient-rb/impl/data_system/polling.rb b/lib/ldclient-rb/impl/data_system/polling.rb new file mode 100644 index 00000000..ed8acb75 --- /dev/null +++ b/lib/ldclient-rb/impl/data_system/polling.rb @@ -0,0 +1,561 @@ +# frozen_string_literal: true + +require "ldclient-rb/interfaces" +require "ldclient-rb/interfaces/data_system" +require "ldclient-rb/impl/data_system" +require "ldclient-rb/impl/data_system/protocolv2" +require "ldclient-rb/impl/data_source/requestor" +require "ldclient-rb/impl/util" +require "concurrent" +require "json" +require "uri" +require "http" + +module LaunchDarkly + module Impl + module DataSystem + POLLING_ENDPOINT = "/sdk/poll" + LATEST_ALL_URI = "/sdk/latest-all" + + LD_ENVID_HEADER = "x-launchdarkly-env-id" + LD_FD_FALLBACK_HEADER = "x-launchdarkly-fd-fallback" + + # + # Requester protocol for polling data source + # + module Requester + # + # Fetches the data for the given selector. + # Returns a Result containing a tuple of [ChangeSet, headers], + # or an error if the data could not be retrieved. + # + # @param selector [LaunchDarkly::Interfaces::DataSystem::Selector, nil] + # @return [Result] + # + def fetch(selector) + raise NotImplementedError + end + end + + # + # PollingDataSource is a data source that can retrieve information from + # LaunchDarkly either as an Initializer or as a Synchronizer. + # + class PollingDataSource + include LaunchDarkly::Interfaces::DataSystem::Initializer + include LaunchDarkly::Interfaces::DataSystem::Synchronizer + + attr_reader :name + + # + # @param poll_interval [Float] Polling interval in seconds + # @param requester [Requester] The requester to use for fetching data + # @param logger [Logger] The logger + # + def initialize(poll_interval, requester, logger) + @requester = requester + @poll_interval = poll_interval + @logger = logger + @event = Concurrent::Event.new + @stop = Concurrent::Event.new + @name = "PollingDataSourceV2" + end + + # + # Fetch returns a Basis, or an error if the Basis could not be retrieved. + # + # @param ss [LaunchDarkly::Interfaces::DataSystem::SelectorStore] + # @return [LaunchDarkly::Interfaces::DataSystem::Basis, nil] + # + def fetch(ss) + poll(ss) + end + + # + # sync begins the synchronization process for the data source, yielding + # Update objects until the connection is closed or an unrecoverable error + # occurs. + # + # @param ss [LaunchDarkly::Interfaces::DataSystem::SelectorStore] + # @yieldparam update [LaunchDarkly::Interfaces::DataSystem::Update] + # + def sync(ss) + @logger.info { "[LDClient] Starting PollingDataSourceV2 synchronizer" } + @stop.reset + + until @stop.set? + result = @requester.fetch(ss.selector) + + if !result.success? + fallback = false + envid = nil + + if result.headers + fallback = result.headers[LD_FD_FALLBACK_HEADER] == 'true' + envid = result.headers[LD_ENVID_HEADER] + end + + if result.exception.is_a?(LaunchDarkly::Impl::DataSource::UnexpectedResponseError) + error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( + LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE, + result.exception.status, + Impl::Util.http_error_message( + result.exception.status, "polling request", "will retry" + ), + Time.now + ) + + if fallback + yield LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::OFF, + error: error_info, + revert_to_fdv1: true, + environment_id: envid + ) + break + end + + status_code = result.exception.status + if Impl::Util.http_error_recoverable?(status_code) + yield LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, + error: error_info, + environment_id: envid + ) + next + end + + yield LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::OFF, + error: error_info, + environment_id: envid + ) + break + end + + error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( + LaunchDarkly::Interfaces::DataSource::ErrorInfo::NETWORK_ERROR, + 0, + result.error, + Time.now + ) + + yield LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, + error: error_info, + environment_id: envid + ) + else + change_set, headers = result.value + yield LaunchDarkly::Interfaces::DataSystem::Update.new( + state: LaunchDarkly::Interfaces::DataSource::Status::VALID, + change_set: change_set, + environment_id: headers[LD_ENVID_HEADER], + revert_to_fdv1: headers[LD_FD_FALLBACK_HEADER] == 'true' + ) + end + + break if @event.wait(@poll_interval) + end + end + + # + # Stops the synchronizer. + # + def stop + @logger.info { "[LDClient] Stopping PollingDataSourceV2 synchronizer" } + @event.set + @stop.set + end + + # + # @param ss [LaunchDarkly::Interfaces::DataSystem::SelectorStore] + # @return [LaunchDarkly::Result] + # + private def poll(ss) + result = @requester.fetch(ss.selector) + + unless result.success? + if result.exception.is_a?(LaunchDarkly::Impl::DataSource::UnexpectedResponseError) + status_code = result.exception.status + http_error_message_result = Impl::Util.http_error_message( + status_code, "polling request", "will retry" + ) + @logger.warn { "[LDClient] #{http_error_message_result}" } if Impl::Util.http_error_recoverable?(status_code) + return LaunchDarkly::Result.fail(http_error_message_result, result.exception) + end + + return LaunchDarkly::Result.fail(result.error || 'Failed to request payload', result.exception) + end + + change_set, headers = result.value + + env_id = headers[LD_ENVID_HEADER] + env_id = nil unless env_id.is_a?(String) + + basis = LaunchDarkly::Interfaces::DataSystem::Basis.new( + change_set: change_set, + persist: !change_set.selector.nil?, + environment_id: env_id + ) + + LaunchDarkly::Result.success(basis) + rescue => e + msg = "Error: Exception encountered when updating flags. #{e}" + @logger.error { "[LDClient] #{msg}" } + @logger.debug { "[LDClient] Exception trace: #{e.backtrace}" } + LaunchDarkly::Result.fail(msg, e) + end + end + + # + # HTTPPollingRequester is a Requester that uses HTTP to make + # requests to the FDv2 polling endpoint. + # + class HTTPPollingRequester + include Requester + + # + # @param sdk_key [String] + # @param config [LaunchDarkly::Config] + # + def initialize(sdk_key, config) + @etag = nil + @config = config + @sdk_key = sdk_key + @poll_uri = config.base_uri + POLLING_ENDPOINT + @http_client = Impl::Util.new_http_client(config.base_uri, config) + .use(:auto_inflate) + .headers("Accept-Encoding" => "gzip") + end + + # + # @param selector [LaunchDarkly::Interfaces::DataSystem::Selector, nil] + # @return [Result] + # + def fetch(selector) + query_params = [] + query_params << ["filter", @config.payload_filter_key] unless @config.payload_filter_key.nil? + + if selector && selector.defined? + query_params << ["selector", selector.state] + end + + uri = @poll_uri + if query_params.any? + filter_query = URI.encode_www_form(query_params) + uri = "#{uri}?#{filter_query}" + end + + headers = {} + Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| headers[k] = v } + headers["If-None-Match"] = @etag unless @etag.nil? + + begin + response = @http_client.request("GET", uri, headers: headers) + status = response.status.code + response_headers = response.headers.to_h.transform_keys(&:downcase) + + if status >= 400 + return LaunchDarkly::Result.fail( + "HTTP error #{status}", + LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(status), + response_headers + ) + end + + if status == 304 + return LaunchDarkly::Result.success([LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.no_changes, response_headers]) + end + + body = response.to_s + data = JSON.parse(body, symbolize_names: true) + etag = response_headers["etag"] + @etag = etag unless etag.nil? + + @config.logger.debug { "[LDClient] #{uri} response status:[#{status}] ETag:[#{etag}]" } + + changeset_result = polling_payload_to_changeset(data) + if changeset_result.success? + LaunchDarkly::Result.success([changeset_result.value, response_headers]) + else + LaunchDarkly::Result.fail(changeset_result.error, changeset_result.exception, response_headers) + end + rescue JSON::ParserError => e + LaunchDarkly::Result.fail("Failed to parse JSON: #{e.message}", e) + rescue => e + LaunchDarkly::Result.fail("Network error: #{e.message}", e) + end + end + + def stop + begin + @http_client.close + rescue + end + end + end + + # + # HTTPFDv1PollingRequester is a Requester that uses HTTP to make + # requests to the FDv1 polling endpoint. + # + class HTTPFDv1PollingRequester + include Requester + + # + # @param sdk_key [String] + # @param config [LaunchDarkly::Config] + # + def initialize(sdk_key, config) + @etag = nil + @config = config + @sdk_key = sdk_key + @poll_uri = config.base_uri + LATEST_ALL_URI + @http_client = Impl::Util.new_http_client(config.base_uri, config) + .use(:auto_inflate) + .headers("Accept-Encoding" => "gzip") + end + + # + # @param selector [LaunchDarkly::Interfaces::DataSystem::Selector, nil] + # @return [Result] + # + def fetch(selector) + query_params = [] + query_params << ["filter", @config.payload_filter_key] unless @config.payload_filter_key.nil? + + uri = @poll_uri + if query_params.any? + filter_query = URI.encode_www_form(query_params) + uri = "#{uri}?#{filter_query}" + end + + headers = {} + Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| headers[k] = v } + headers["If-None-Match"] = @etag unless @etag.nil? + + begin + response = @http_client.request("GET", uri, headers: headers) + status = response.status.code + response_headers = response.headers.to_h.transform_keys(&:downcase) + + if status >= 400 + return LaunchDarkly::Result.fail( + "HTTP error #{status}", + LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(status), + response_headers + ) + end + + if status == 304 + return LaunchDarkly::Result.success([LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.no_changes, response_headers]) + end + + body = response.to_s + data = JSON.parse(body, symbolize_names: true) + etag = response_headers["etag"] + @etag = etag unless etag.nil? + + @config.logger.debug { "[LDClient] #{uri} response status:[#{status}] ETag:[#{etag}]" } + + changeset_result = fdv1_polling_payload_to_changeset(data) + if changeset_result.success? + LaunchDarkly::Result.success([changeset_result.value, response_headers]) + else + LaunchDarkly::Result.fail(changeset_result.error, changeset_result.exception, response_headers) + end + rescue JSON::ParserError => e + LaunchDarkly::Result.fail("Failed to parse JSON: #{e.message}", e) + rescue => e + LaunchDarkly::Result.fail("Network error: #{e.message}", e) + end + end + + def stop + begin + @http_client.close + rescue + end + end + end + + # + # Converts a polling payload into a ChangeSet. + # + # @param data [Hash] The polling payload + # @return [LaunchDarkly::Result] Result containing ChangeSet on success, or error message on failure + # + def self.polling_payload_to_changeset(data) + unless data[:events].is_a?(Array) + return LaunchDarkly::Result.fail("Invalid payload: 'events' key is missing or not a list") + end + + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + + data[:events].each do |event| + unless event.is_a?(Hash) + return LaunchDarkly::Result.fail("Invalid payload: 'events' must be a list of objects") + end + + next unless event[:event] + + case event[:event] + when LaunchDarkly::Interfaces::DataSystem::EventName::SERVER_INTENT + begin + server_intent = LaunchDarkly::Interfaces::DataSystem::ServerIntent.from_h(event[:data]) + rescue ArgumentError => e + return LaunchDarkly::Result.fail("Invalid JSON in server intent", e) + end + + if server_intent.payload.code == LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_NONE + return LaunchDarkly::Result.success(LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.no_changes) + end + + builder.start(server_intent.payload.code) + + when LaunchDarkly::Interfaces::DataSystem::EventName::PUT_OBJECT + begin + put = LaunchDarkly::Impl::DataSystem::ProtocolV2::PutObject.from_h(event[:data]) + rescue ArgumentError => e + return LaunchDarkly::Result.fail("Invalid JSON in put object", e) + end + + builder.add_put(put.kind, put.key, put.version, put.object) + + when LaunchDarkly::Interfaces::DataSystem::EventName::DELETE_OBJECT + begin + delete_object = LaunchDarkly::Impl::DataSystem::ProtocolV2::DeleteObject.from_h(event[:data]) + rescue ArgumentError => e + return LaunchDarkly::Result.fail("Invalid JSON in delete object", e) + end + + builder.add_delete(delete_object.kind, delete_object.key, delete_object.version) + + when LaunchDarkly::Interfaces::DataSystem::EventName::PAYLOAD_TRANSFERRED + begin + selector = LaunchDarkly::Interfaces::DataSystem::Selector.from_h(event[:data]) + changeset = builder.finish(selector) + return LaunchDarkly::Result.success(changeset) + rescue ArgumentError, RuntimeError => e + return LaunchDarkly::Result.fail("Invalid JSON in payload transferred object", e) + end + end + end + + LaunchDarkly::Result.fail("didn't receive any known protocol events in polling payload") + end + + # + # Converts an FDv1 polling payload into a ChangeSet. + # + # @param data [Hash] The FDv1 polling payload + # @return [LaunchDarkly::Result] Result containing ChangeSet on success, or error message on failure + # + def self.fdv1_polling_payload_to_changeset(data) + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + selector = LaunchDarkly::Interfaces::DataSystem::Selector.no_selector + + kind_mappings = [ + [LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, :flags], + [LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT, :segments], + ] + + kind_mappings.each do |kind, fdv1_key| + kind_data = data[fdv1_key] + next if kind_data.nil? + + unless kind_data.is_a?(Hash) + return LaunchDarkly::Result.fail("Invalid format: #{fdv1_key} is not an object") + end + + kind_data.each do |key, flag_or_segment| + unless flag_or_segment.is_a?(Hash) + return LaunchDarkly::Result.fail("Invalid format: #{key} is not an object") + end + + version = flag_or_segment[:version] + return LaunchDarkly::Result.fail("Invalid format: #{key} does not have a version set") if version.nil? + + builder.add_put(kind, key.to_s, version, flag_or_segment) + end + end + + LaunchDarkly::Result.success(builder.finish(selector)) + end + + # + # Builder for a PollingDataSource. + # + class PollingDataSourceBuilder + # + # @param sdk_key [String] + # @param config [LaunchDarkly::Config] + # + def initialize(sdk_key, config) + @sdk_key = sdk_key + @config = config + @requester = nil + end + + # + # Sets a custom Requester for the PollingDataSource. + # + # @param requester [Requester] + # @return [PollingDataSourceBuilder] + # + def requester(requester) + @requester = requester + self + end + + # + # Builds the PollingDataSource with the configured parameters. + # + # @return [PollingDataSource] + # + def build + requester = @requester || HTTPPollingRequester.new(@sdk_key, @config) + PollingDataSource.new(@config.poll_interval, requester, @config.logger) + end + end + + # + # Builder for an FDv1 PollingDataSource. + # + class FDv1PollingDataSourceBuilder + # + # @param sdk_key [String] + # @param config [LaunchDarkly::Config] + # + def initialize(sdk_key, config) + @sdk_key = sdk_key + @config = config + @requester = nil + end + + # + # Sets a custom Requester for the PollingDataSource. + # + # @param requester [Requester] + # @return [FDv1PollingDataSourceBuilder] + # + def requester(requester) + @requester = requester + self + end + + # + # Builds the PollingDataSource with the configured parameters. + # + # @return [PollingDataSource] + # + def build + requester = @requester || HTTPFDv1PollingRequester.new(@sdk_key, @config) + PollingDataSource.new(@config.poll_interval, requester, @config.logger) + end + end + end + end +end diff --git a/lib/ldclient-rb/util.rb b/lib/ldclient-rb/util.rb index 242e5447..206e4788 100644 --- a/lib/ldclient-rb/util.rb +++ b/lib/ldclient-rb/util.rb @@ -27,10 +27,11 @@ def self.success(value) # # @param error [String] # @param exception [Exception, nil] + # @param headers [Hash, nil] # @return [Result] # - def self.fail(error, exception = nil) - Result.new(nil, error, exception) + def self.fail(error, exception = nil, headers = nil) + Result.new(nil, error, exception, headers) end # @@ -57,10 +58,16 @@ def success? # attr_reader :exception - private def initialize(value, error = nil, exception = nil) + # + # @return [Hash] Optional headers associated with the result + # + attr_reader :headers + + private def initialize(value, error = nil, exception = nil, headers = nil) @value = value @error = error @exception = exception + @headers = headers || {} end end end diff --git a/spec/impl/data_system/polling_initializer_spec.rb b/spec/impl/data_system/polling_initializer_spec.rb new file mode 100644 index 00000000..c94d9fdb --- /dev/null +++ b/spec/impl/data_system/polling_initializer_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require "spec_helper" +require "ldclient-rb/impl/data_system/polling" +require "ldclient-rb/interfaces" + +module LaunchDarkly + module Impl + module DataSystem + RSpec.describe PollingDataSource do + let(:logger) { double("Logger", info: nil, warn: nil, error: nil, debug: nil) } + + class MockExceptionThrowingPollingRequester + include Requester + + def fetch(selector) + raise "This is a mock exception for testing purposes." + end + end + + class MockPollingRequester + include Requester + + def initialize(result) + @result = result + end + + def fetch(selector) + @result + end + end + + class MockSelectorStore + include LaunchDarkly::Interfaces::DataSystem::SelectorStore + + def initialize(selector) + @selector = selector + end + + def selector + @selector + end + end + + describe "#fetch" do + it "polling has a name" do + mock_requester = MockPollingRequester.new(LaunchDarkly::Result.fail("failure message")) + ds = PollingDataSource.new(1.0, mock_requester, logger) + + expect(ds.name).to eq("PollingDataSourceV2") + end + + it "error is returned on failure" do + mock_requester = MockPollingRequester.new(LaunchDarkly::Result.fail("failure message")) + ds = PollingDataSource.new(1.0, mock_requester, logger) + + result = ds.fetch(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) + + expect(result).not_to be_nil + expect(result.success?).to eq(false) + expect(result.error).to eq("failure message") + end + + it "error is recoverable" do + mock_requester = MockPollingRequester.new( + LaunchDarkly::Result.fail( + "failure message", + LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(408) + ) + ) + ds = PollingDataSource.new(1.0, mock_requester, logger) + + result = ds.fetch(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) + + expect(result).not_to be_nil + expect(result.success?).to eq(false) + end + + it "error is unrecoverable" do + mock_requester = MockPollingRequester.new( + LaunchDarkly::Result.fail( + "failure message", + LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(401) + ) + ) + ds = PollingDataSource.new(1.0, mock_requester, logger) + + result = ds.fetch(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) + + expect(result).not_to be_nil + expect(result.success?).to eq(false) + end + + it "handles transfer none" do + mock_requester = MockPollingRequester.new( + LaunchDarkly::Result.success([LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.no_changes, {}]) + ) + ds = PollingDataSource.new(1.0, mock_requester, logger) + + result = ds.fetch(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) + + expect(result).not_to be_nil + expect(result.success?).to eq(true) + expect(result.value.change_set.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_NONE) + expect(result.value.change_set.changes).to eq([]) + expect(result.value.persist).to eq(false) + end + + it "handles uncaught exception" do + mock_requester = MockExceptionThrowingPollingRequester.new + ds = PollingDataSource.new(1.0, mock_requester, logger) + + result = ds.fetch(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) + + expect(result).not_to be_nil + expect(result.success?).to eq(false) + expect(result.error).to include("Exception encountered when updating flags") + end + + it "handles transfer full" do + payload_str = '{"events":[ {"event":"server-intent","data":{"payloads":[ {"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":461,"intentCode":"xfer-full","reason":"payload-missing"}]}},{"event": "put-object","data": {"key":"sample-feature","kind":"flag","version":461,"object":{"key":"sample-feature","on":false,"prerequisites":[],"targets":[],"contextTargets":[],"rules":[],"fallthrough":{"variation":0},"offVariation":1,"variations":[true,false],"clientSideAvailability":{"usingMobileKey":false,"usingEnvironmentId":false},"clientSide":false,"salt":"9945e63a79a44787805b79728fee1926","trackEvents":false,"trackEventsFallthrough":false,"debugEventsUntilDate":null,"version":112,"deleted":false}}},{"event":"payload-transferred","data":{"state":"(p:5A46PZ79FQ9D08YYKT79DECDNV:461)","id":"5A46PZ79FQ9D08YYKT79DECDNV","version":461}}]}' # rubocop:disable Layout/LineLength + change_set_result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + expect(change_set_result.success?).to eq(true) + + mock_requester = MockPollingRequester.new(LaunchDarkly::Result.success([change_set_result.value, {}])) + ds = PollingDataSource.new(1.0, mock_requester, logger) + + result = ds.fetch(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) + + expect(result).not_to be_nil + expect(result.success?).to eq(true) + expect(result.value.change_set.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + expect(result.value.change_set.changes.length).to eq(1) + expect(result.value.persist).to eq(true) + end + + it "handles transfer changes" do + payload_str = '{"events":[{"event": "server-intent","data": {"payloads":[{"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":462,"intentCode":"xfer-changes","reason":"stale"}]}},{"event": "put-object","data": {"key":"sample-feature","kind":"flag","version":462,"object":{"key":"sample-feature","on":true,"prerequisites":[],"targets":[],"contextTargets":[],"rules":[],"fallthrough":{"variation":0},"offVariation":1,"variations":[true,false],"clientSideAvailability":{"usingMobileKey":false,"usingEnvironmentId":false},"clientSide":false,"salt":"9945e63a79a44787805b79728fee1926","trackEvents":false,"trackEventsFallthrough":false,"debugEventsUntilDate":null,"version":113,"deleted":false}}},{"event": "payload-transferred","data": {"state":"(p:5A46PZ79FQ9D08YYKT79DECDNV:462)","id":"5A46PZ79FQ9D08YYKT79DECDNV","version":462}}]}' # rubocop:disable Layout/LineLength + change_set_result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + expect(change_set_result.success?).to eq(true) + + mock_requester = MockPollingRequester.new(LaunchDarkly::Result.success([change_set_result.value, {}])) + ds = PollingDataSource.new(1.0, mock_requester, logger) + + result = ds.fetch(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) + + expect(result).not_to be_nil + expect(result.success?).to eq(true) + expect(result.value.change_set.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_CHANGES) + expect(result.value.change_set.changes.length).to eq(1) + expect(result.value.persist).to eq(true) + end + end + end + end + end +end diff --git a/spec/impl/data_system/polling_payload_parsing_spec.rb b/spec/impl/data_system/polling_payload_parsing_spec.rb new file mode 100644 index 00000000..7f4db603 --- /dev/null +++ b/spec/impl/data_system/polling_payload_parsing_spec.rb @@ -0,0 +1,317 @@ +# frozen_string_literal: true + +require "spec_helper" +require "ldclient-rb/impl/data_system/polling" +require "ldclient-rb/interfaces" +require "json" + +module LaunchDarkly + module Impl + module DataSystem + RSpec.describe ".polling_payload_to_changeset" do + it "payload is missing events key" do + data = {} + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(data) + expect(result.success?).to eq(false) + end + + it "payload events value is invalid" do + data = { events: "not a list" } + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(data) + expect(result.success?).to eq(false) + end + + it "payload event is invalid" do + data = { events: ["this should be a dictionary"] } + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(data) + expect(result.success?).to eq(false) + end + + it "missing protocol events" do + data = { events: [] } + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(data) + expect(result.success?).to eq(false) + end + + it "transfer none" do + payload_str = '{"events":[{"event": "server-intent","data": {"payloads":[{"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":462,"intentCode":"none","reason":"up-to-date"}]}}]}' + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + + expect(result).not_to be_nil + expect(result.value.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_NONE) + expect(result.value.changes.length).to eq(0) + expect(result.value.selector).to be_nil + end + + it "transfer full with empty payload" do + payload_str = '{"events":[ {"event":"server-intent","data":{"payloads":[ {"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":461,"intentCode":"xfer-full","reason":"payload-missing"}]}},{"event":"payload-transferred","data":{"state":"(p:5A46PZ79FQ9D08YYKT79DECDNV:461)","id":"5A46PZ79FQ9D08YYKT79DECDNV","version":461}}]}' # rubocop:disable Layout/LineLength + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + + expect(result).not_to be_nil + expect(result.value.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + expect(result.value.changes.length).to eq(0) + expect(result.value.selector).not_to be_nil + expect(result.value.selector.state).to eq("(p:5A46PZ79FQ9D08YYKT79DECDNV:461)") + expect(result.value.selector.version).to eq(461) + end + + it "server intent decoding fails" do + payload_str = '{"events":[ {"event":"server-intent","data":{}},{"event":"payload-transferred","data":{"state":"(p:5A46PZ79FQ9D08YYKT79DECDNV:461)","id":"5A46PZ79FQ9D08YYKT79DECDNV","version":461}}]}' # rubocop:disable Layout/LineLength + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + expect(result.success?).to eq(false) + end + + it "processes put object" do + payload_str = '{"events":[ {"event":"server-intent","data":{"payloads":[ {"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":461,"intentCode":"xfer-full","reason":"payload-missing"}]}},{"event": "put-object","data": {"key":"sample-feature","kind":"flag","version":461,"object":{"key":"sample-feature","on":false,"prerequisites":[],"targets":[],"contextTargets":[],"rules":[],"fallthrough":{"variation":0},"offVariation":1,"variations":[true,false],"clientSideAvailability":{"usingMobileKey":false,"usingEnvironmentId":false},"clientSide":false,"salt":"9945e63a79a44787805b79728fee1926","trackEvents":false,"trackEventsFallthrough":false,"debugEventsUntilDate":null,"version":112,"deleted":false}}},{"event":"payload-transferred","data":{"state":"(p:5A46PZ79FQ9D08YYKT79DECDNV:461)","id":"5A46PZ79FQ9D08YYKT79DECDNV","version":461}}]}' # rubocop:disable Layout/LineLength + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + expect(result).not_to be_nil + + expect(result.value.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + expect(result.value.changes.length).to eq(1) + + expect(result.value.changes[0].action).to eq(LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT) + expect(result.value.changes[0].kind).to eq(LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG) + expect(result.value.changes[0].key).to eq("sample-feature") + expect(result.value.changes[0].version).to eq(461) + expect(result.value.changes[0].object).to be_a(Hash) + + expect(result.value.selector).not_to be_nil + expect(result.value.selector.state).to eq("(p:5A46PZ79FQ9D08YYKT79DECDNV:461)") + expect(result.value.selector.version).to eq(461) + end + + it "processes delete object" do + payload_str = '{"events":[ {"event":"server-intent","data":{"payloads":[ {"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":461,"intentCode":"xfer-full","reason":"payload-missing"}]}},{"event": "delete-object","data": {"key":"sample-feature","kind":"flag","version":461}},{"event":"payload-transferred","data":{"state":"(p:5A46PZ79FQ9D08YYKT79DECDNV:461)","id":"5A46PZ79FQ9D08YYKT79DECDNV","version":461}}]}' # rubocop:disable Layout/LineLength + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + expect(result).not_to be_nil + + expect(result.value.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + expect(result.value.changes.length).to eq(1) + + expect(result.value.changes[0].action).to eq(LaunchDarkly::Interfaces::DataSystem::ChangeType::DELETE) + expect(result.value.changes[0].kind).to eq(LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG) + expect(result.value.changes[0].key).to eq("sample-feature") + expect(result.value.changes[0].version).to eq(461) + expect(result.value.changes[0].object).to be_nil + + expect(result.value.selector).not_to be_nil + expect(result.value.selector.state).to eq("(p:5A46PZ79FQ9D08YYKT79DECDNV:461)") + expect(result.value.selector.version).to eq(461) + end + + it "handles invalid put object" do + payload_str = '{"events":[ {"event":"server-intent","data":{"payloads":[ {"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":461,"intentCode":"xfer-full","reason":"payload-missing"}]}},{"event": "put-object","data": {}},{"event":"payload-transferred","data":{"state":"(p:5A46PZ79FQ9D08YYKT79DECDNV:461)","id":"5A46PZ79FQ9D08YYKT79DECDNV","version":461}}]}' # rubocop:disable Layout/LineLength + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + expect(result.success?).to eq(false) + end + + it "handles invalid delete object" do + payload_str = '{"events":[ {"event":"server-intent","data":{"payloads":[ {"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":461,"intentCode":"xfer-full","reason":"payload-missing"}]}},{"event": "delete-object","data": {}},{"event":"payload-transferred","data":{"state":"(p:5A46PZ79FQ9D08YYKT79DECDNV:461)","id":"5A46PZ79FQ9D08YYKT79DECDNV","version":461}}]}' # rubocop:disable Layout/LineLength + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + expect(result.success?).to eq(false) + end + + it "handles invalid payload transferred" do + payload_str = '{"events":[ {"event":"server-intent","data":{"payloads":[ {"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":461,"intentCode":"xfer-full","reason":"payload-missing"}]}},{"event":"payload-transferred","data":{}}]}' # rubocop:disable Layout/LineLength + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + expect(result.success?).to eq(false) + end + + it "fails if starts with transferred" do + payload_str = '{"events":[ {"event":"payload-transferred","data":{"state":"(p:5A46PZ79FQ9D08YYKT79DECDNV:461)","id":"5A46PZ79FQ9D08YYKT79DECDNV","version":461}},{"event":"server-intent","data":{"payloads":[ {"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":461,"intentCode":"xfer-full","reason":"payload-missing"}]}},{"event": "put-object","data": {"key":"sample-feature","kind":"flag","version":461,"object":{"key":"sample-feature","on":false,"prerequisites":[],"targets":[],"contextTargets":[],"rules":[],"fallthrough":{"variation":0},"offVariation":1,"variations":[true,false],"clientSideAvailability":{"usingMobileKey":false,"usingEnvironmentId":false},"clientSide":false,"salt":"9945e63a79a44787805b79728fee1926","trackEvents":false,"trackEventsFallthrough":false,"debugEventsUntilDate":null,"version":112,"deleted":false}}}]}' # rubocop:disable Layout/LineLength + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + expect(result.success?).to eq(false) + end + + it "fails if starts with put" do + payload_str = '{"events":[ {"event": "put-object","data": {"key":"sample-feature","kind":"flag","version":461,"object":{"key":"sample-feature","on":false,"prerequisites":[],"targets":[],"contextTargets":[],"rules":[],"fallthrough":{"variation":0},"offVariation":1,"variations":[true,false],"clientSideAvailability":{"usingMobileKey":false,"usingEnvironmentId":false},"clientSide":false,"salt":"9945e63a79a44787805b79728fee1926","trackEvents":false,"trackEventsFallthrough":false,"debugEventsUntilDate":null,"version":112,"deleted":false}}},{"event":"payload-transferred","data":{"state":"(p:5A46PZ79FQ9D08YYKT79DECDNV:461)","id":"5A46PZ79FQ9D08YYKT79DECDNV","version":461}},{"event":"server-intent","data":{"payloads":[ {"id":"5A46PZ79FQ9D08YYKT79DECDNV","target":461,"intentCode":"xfer-full","reason":"payload-missing"}]}}]}' # rubocop:disable Layout/LineLength + result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(JSON.parse(payload_str, symbolize_names: true)) + expect(result.success?).to eq(false) + end + end + + RSpec.describe ".fdv1_polling_payload_to_changeset" do + it "handles empty flags and segments" do + data = { + flags: {}, + segments: {}, + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result).not_to be_nil + + expect(result.value.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + expect(result.value.changes.length).to eq(0) + expect(result.value.selector).not_to be_nil + expect(result.value.selector.defined?).to eq(false) + end + + it "handles single flag" do + data = { + flags: { + "test-flag" => { + key: "test-flag", + version: 1, + on: true, + variations: [true, false], + }, + }, + segments: {}, + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result).not_to be_nil + + expect(result.value.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + expect(result.value.changes.length).to eq(1) + + change = result.value.changes[0] + expect(change.action).to eq(LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT) + expect(change.kind).to eq(LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG) + expect(change.key).to eq("test-flag") + expect(change.version).to eq(1) + end + + it "handles multiple flags" do + data = { + flags: { + "flag-1" => { key: "flag-1", version: 1, on: true }, + "flag-2" => { key: "flag-2", version: 2, on: false }, + "flag-3" => { key: "flag-3", version: 3, on: true }, + }, + segments: {}, + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result).not_to be_nil + + expect(result.value.changes.length).to eq(3) + flag_keys = result.value.changes.map(&:key).to_set + expect(flag_keys).to eq(Set["flag-1", "flag-2", "flag-3"]) + end + + it "handles single segment" do + data = { + flags: {}, + segments: { + "test-segment" => { + key: "test-segment", + version: 5, + included: ["user1", "user2"], + }, + }, + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result).not_to be_nil + + expect(result.value.changes.length).to eq(1) + change = result.value.changes[0] + expect(change.action).to eq(LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT) + expect(change.kind).to eq(LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT) + expect(change.key).to eq("test-segment") + expect(change.version).to eq(5) + end + + it "handles flags and segments" do + data = { + flags: { + "flag-1" => { key: "flag-1", version: 1, on: true }, + "flag-2" => { key: "flag-2", version: 2, on: false }, + }, + segments: { + "segment-1" => { key: "segment-1", version: 10 }, + "segment-2" => { key: "segment-2", version: 20 }, + }, + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result).not_to be_nil + + expect(result.value.changes.length).to eq(4) + + flag_changes = result.value.changes.select { |c| c.kind == LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG } + segment_changes = result.value.changes.select { |c| c.kind == LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT } + + expect(flag_changes.length).to eq(2) + expect(segment_changes.length).to eq(2) + end + + it "fails when flags is not dict" do + data = { + flags: "not a dict", + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result.success?).to eq(false) + end + + it "fails when segments is not dict" do + data = { + flags: {}, + segments: "not a dict", + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result.success?).to eq(false) + end + + it "fails when flag value is not dict" do + data = { + flags: { + "bad-flag" => "not a dict", + }, + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result.success?).to eq(false) + end + + it "fails when flag missing version" do + data = { + flags: { + "no-version-flag" => { + key: "no-version-flag", + on: true, + }, + }, + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result.success?).to eq(false) + end + + it "fails when segment missing version" do + data = { + flags: {}, + segments: { + "no-version-segment" => { + key: "no-version-segment", + included: [], + }, + }, + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result.success?).to eq(false) + end + + it "works with only flags, no segments key" do + data = { + flags: { + "test-flag" => { key: "test-flag", version: 1, on: true }, + }, + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result).not_to be_nil + + expect(result.value.changes.length).to eq(1) + expect(result.value.changes[0].key).to eq("test-flag") + end + + it "works with only segments, no flags key" do + data = { + segments: { + "test-segment" => { key: "test-segment", version: 1 }, + }, + } + result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) + expect(result).not_to be_nil + + expect(result.value.changes.length).to eq(1) + expect(result.value.changes[0].key).to eq("test-segment") + end + end + end + end +end diff --git a/spec/impl/data_system/polling_synchronizer_spec.rb b/spec/impl/data_system/polling_synchronizer_spec.rb new file mode 100644 index 00000000..948f5955 --- /dev/null +++ b/spec/impl/data_system/polling_synchronizer_spec.rb @@ -0,0 +1,542 @@ +# frozen_string_literal: true + +require "spec_helper" +require "ldclient-rb/impl/data_system/polling" +require "ldclient-rb/interfaces" + +module LaunchDarkly + module Impl + module DataSystem + RSpec.describe PollingDataSource do + let(:logger) { double("Logger", info: nil, warn: nil, error: nil, debug: nil) } + + class ListBasedRequester + include Requester + + def initialize(results) + @results = results + @index = 0 + end + + def fetch(selector) + @results[@index].tap { @index += 1 } + end + end + + class MockSelectorStore + include LaunchDarkly::Interfaces::DataSystem::SelectorStore + + def initialize(selector) + @selector = selector + end + + def selector + @selector + end + end + + describe "#sync" do + it "handles no changes" do + change_set = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.no_changes + headers = {} + polling_result = LaunchDarkly::Result.success([change_set, headers]) + + synchronizer = PollingDataSource.new(0.01, ListBasedRequester.new([polling_result]), logger) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break + end + end + + thread.join(1) + synchronizer.stop + + expect(updates.length).to eq(1) + valid = updates[0] + + expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(valid.error).to be_nil + expect(valid.revert_to_fdv1).to eq(false) + expect(valid.environment_id).to be_nil + expect(valid.change_set).not_to be_nil + expect(valid.change_set.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_NONE) + expect(valid.change_set.changes.length).to eq(0) + end + + it "handles empty changeset" do + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + change_set = builder.finish(LaunchDarkly::Interfaces::DataSystem::Selector.new(state: "p:SOMETHING:300", version: 300)) + headers = {} + polling_result = LaunchDarkly::Result.success([change_set, headers]) + + synchronizer = PollingDataSource.new(0.01, ListBasedRequester.new([polling_result]), logger) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break + end + end + + thread.join(1) + synchronizer.stop + + expect(updates.length).to eq(1) + valid = updates[0] + + expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(valid.error).to be_nil + expect(valid.revert_to_fdv1).to eq(false) + expect(valid.environment_id).to be_nil + expect(valid.change_set).not_to be_nil + expect(valid.change_set.changes.length).to eq(0) + expect(valid.change_set.selector).not_to be_nil + expect(valid.change_set.selector.version).to eq(300) + expect(valid.change_set.selector.state).to eq("p:SOMETHING:300") + expect(valid.change_set.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + end + + it "handles put objects" do + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + builder.add_put( + LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + "flag-key", + 100, + { key: "flag-key" } + ) + change_set = builder.finish(LaunchDarkly::Interfaces::DataSystem::Selector.new(state: "p:SOMETHING:300", version: 300)) + headers = {} + polling_result = LaunchDarkly::Result.success([change_set, headers]) + + synchronizer = PollingDataSource.new(0.01, ListBasedRequester.new([polling_result]), logger) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break + end + end + + thread.join(1) + synchronizer.stop + + expect(updates.length).to eq(1) + valid = updates[0] + + expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(valid.error).to be_nil + expect(valid.revert_to_fdv1).to eq(false) + expect(valid.environment_id).to be_nil + expect(valid.change_set).not_to be_nil + expect(valid.change_set.changes.length).to eq(1) + expect(valid.change_set.changes[0].action).to eq(LaunchDarkly::Interfaces::DataSystem::ChangeType::PUT) + expect(valid.change_set.changes[0].kind).to eq(LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG) + expect(valid.change_set.changes[0].key).to eq("flag-key") + expect(valid.change_set.changes[0].object).to eq({ key: "flag-key" }) + expect(valid.change_set.changes[0].version).to eq(100) + expect(valid.change_set.selector).not_to be_nil + expect(valid.change_set.selector.version).to eq(300) + expect(valid.change_set.selector.state).to eq("p:SOMETHING:300") + expect(valid.change_set.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + end + + it "handles delete objects" do + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + builder.add_delete(LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, "flag-key", 101) + change_set = builder.finish(LaunchDarkly::Interfaces::DataSystem::Selector.new(state: "p:SOMETHING:300", version: 300)) + headers = {} + polling_result = LaunchDarkly::Result.success([change_set, headers]) + + synchronizer = PollingDataSource.new(0.01, ListBasedRequester.new([polling_result]), logger) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break + end + end + + thread.join(1) + synchronizer.stop + + expect(updates.length).to eq(1) + valid = updates[0] + + expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(valid.error).to be_nil + expect(valid.revert_to_fdv1).to eq(false) + expect(valid.environment_id).to be_nil + expect(valid.change_set).not_to be_nil + expect(valid.change_set.changes.length).to eq(1) + expect(valid.change_set.changes[0].action).to eq(LaunchDarkly::Interfaces::DataSystem::ChangeType::DELETE) + expect(valid.change_set.changes[0].kind).to eq(LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG) + expect(valid.change_set.changes[0].key).to eq("flag-key") + expect(valid.change_set.changes[0].version).to eq(101) + expect(valid.change_set.selector).not_to be_nil + expect(valid.change_set.selector.version).to eq(300) + expect(valid.change_set.selector.state).to eq("p:SOMETHING:300") + expect(valid.change_set.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + end + + it "generic error interrupts and recovers" do + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + builder.add_delete(LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, "flag-key", 101) + change_set = builder.finish(LaunchDarkly::Interfaces::DataSystem::Selector.new(state: "p:SOMETHING:300", version: 300)) + headers = {} + polling_result = LaunchDarkly::Result.success([change_set, headers]) + + synchronizer = PollingDataSource.new( + 0.01, + ListBasedRequester.new([ + LaunchDarkly::Result.fail("error for test"), + polling_result, + ]), + logger + ) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break if updates.length >= 2 + end + end + + thread.join(2) + synchronizer.stop + + expect(updates.length).to eq(2) + interrupted = updates[0] + valid = updates[1] + + expect(interrupted.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + expect(interrupted.error).not_to be_nil + expect(interrupted.error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::NETWORK_ERROR) + expect(interrupted.error.status_code).to eq(0) + expect(interrupted.error.message).to eq("error for test") + expect(interrupted.revert_to_fdv1).to eq(false) + expect(interrupted.environment_id).to be_nil + + expect(valid.change_set).not_to be_nil + expect(valid.change_set.changes.length).to eq(1) + expect(valid.change_set.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + expect(valid.change_set.changes[0].action).to eq(LaunchDarkly::Interfaces::DataSystem::ChangeType::DELETE) + end + + it "recoverable error continues" do + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + builder.add_delete(LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, "flag-key", 101) + change_set = builder.finish(LaunchDarkly::Interfaces::DataSystem::Selector.new(state: "p:SOMETHING:300", version: 300)) + headers = {} + polling_result = LaunchDarkly::Result.success([change_set, headers]) + + failure = LaunchDarkly::Result.fail( + "error for test", + LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(408) + ) + + synchronizer = PollingDataSource.new( + 0.01, + ListBasedRequester.new([failure, polling_result]), + logger + ) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break if updates.length >= 2 + end + end + + thread.join(2) + synchronizer.stop + + expect(updates.length).to eq(2) + interrupted = updates[0] + valid = updates[1] + + expect(interrupted.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + expect(interrupted.error).not_to be_nil + expect(interrupted.error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE) + expect(interrupted.error.status_code).to eq(408) + expect(interrupted.revert_to_fdv1).to eq(false) + expect(interrupted.environment_id).to be_nil + + expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(valid.error).to be_nil + expect(valid.revert_to_fdv1).to eq(false) + expect(valid.environment_id).to be_nil + + expect(valid.change_set).not_to be_nil + expect(valid.change_set.changes.length).to eq(1) + expect(valid.change_set.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + expect(valid.change_set.changes[0].action).to eq(LaunchDarkly::Interfaces::DataSystem::ChangeType::DELETE) + end + + it "unrecoverable error shuts down" do + failure = LaunchDarkly::Result.fail( + "error for test", + LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(401) + ) + + synchronizer = PollingDataSource.new( + 0.01, + ListBasedRequester.new([failure]), + logger + ) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + end + end + + thread.join(1) + synchronizer.stop + + expect(updates.length).to eq(1) + off = updates[0] + expect(off.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) + expect(off.error).not_to be_nil + expect(off.error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE) + expect(off.error.status_code).to eq(401) + expect(off.revert_to_fdv1).to eq(false) + expect(off.environment_id).to be_nil + expect(off.change_set).to be_nil + end + + it "captures envid from success headers" do + change_set = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.no_changes + headers = { LD_ENVID_HEADER => 'test-env-polling-123' } + polling_result = LaunchDarkly::Result.success([change_set, headers]) + + synchronizer = PollingDataSource.new(0.01, ListBasedRequester.new([polling_result]), logger) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break + end + end + + thread.join(1) + synchronizer.stop + + expect(updates.length).to eq(1) + valid = updates[0] + + expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(valid.error).to be_nil + expect(valid.revert_to_fdv1).to eq(false) + expect(valid.environment_id).to eq('test-env-polling-123') + end + + it "captures envid and fallback from success with changeset" do + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + builder.add_put( + LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + "flag-key", + 100, + { key: "flag-key" } + ) + change_set = builder.finish(LaunchDarkly::Interfaces::DataSystem::Selector.new(state: "p:SOMETHING:300", version: 300)) + headers = { + LD_ENVID_HEADER => 'test-env-456', + LD_FD_FALLBACK_HEADER => 'true', + } + polling_result = LaunchDarkly::Result.success([change_set, headers]) + + synchronizer = PollingDataSource.new(0.01, ListBasedRequester.new([polling_result]), logger) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break + end + end + + thread.join(1) + synchronizer.stop + + expect(updates.length).to eq(1) + valid = updates[0] + + expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(valid.environment_id).to eq('test-env-456') + expect(valid.revert_to_fdv1).to eq(true) + expect(valid.change_set).not_to be_nil + expect(valid.change_set.changes.length).to eq(1) + end + + it "captures envid from error headers recoverable" do + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + builder.add_delete(LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, "flag-key", 101) + change_set = builder.finish(LaunchDarkly::Interfaces::DataSystem::Selector.new(state: "p:SOMETHING:300", version: 300)) + headers_success = { LD_ENVID_HEADER => 'test-env-success' } + polling_result = LaunchDarkly::Result.success([change_set, headers_success]) + + headers_error = { LD_ENVID_HEADER => 'test-env-408' } + failure = LaunchDarkly::Result.fail( + "error for test", + LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(408), + headers_error + ) + + synchronizer = PollingDataSource.new( + 0.01, + ListBasedRequester.new([failure, polling_result]), + logger + ) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break if updates.length >= 2 + end + end + + thread.join(2) + synchronizer.stop + + expect(updates.length).to eq(2) + interrupted = updates[0] + valid = updates[1] + + expect(interrupted.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + expect(interrupted.environment_id).to eq('test-env-408') + expect(interrupted.error).not_to be_nil + expect(interrupted.error.status_code).to eq(408) + + expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(valid.environment_id).to eq('test-env-success') + end + + it "captures envid from error headers unrecoverable" do + headers_error = { LD_ENVID_HEADER => 'test-env-401' } + failure = LaunchDarkly::Result.fail( + "error for test", + LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(401), + headers_error + ) + + synchronizer = PollingDataSource.new( + 0.01, + ListBasedRequester.new([failure]), + logger + ) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + end + end + + thread.join(1) + synchronizer.stop + + expect(updates.length).to eq(1) + off = updates[0] + + expect(off.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) + expect(off.environment_id).to eq('test-env-401') + expect(off.error).not_to be_nil + expect(off.error.status_code).to eq(401) + end + + it "captures envid and fallback from error with fallback" do + headers_error = { + LD_ENVID_HEADER => 'test-env-503', + LD_FD_FALLBACK_HEADER => 'true', + } + failure = LaunchDarkly::Result.fail( + "error for test", + LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(503), + headers_error + ) + + synchronizer = PollingDataSource.new( + 0.01, + ListBasedRequester.new([failure]), + logger + ) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + end + end + + thread.join(1) + synchronizer.stop + + expect(updates.length).to eq(1) + off = updates[0] + + expect(off.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) + expect(off.revert_to_fdv1).to eq(true) + expect(off.environment_id).to eq('test-env-503') + end + + it "captures envid from generic error with headers" do + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + change_set = builder.finish(LaunchDarkly::Interfaces::DataSystem::Selector.new(state: "p:SOMETHING:300", version: 300)) + headers_success = {} + polling_result = LaunchDarkly::Result.success([change_set, headers_success]) + + headers_error = { LD_ENVID_HEADER => 'test-env-generic' } + failure = LaunchDarkly::Result.fail("generic error for test", nil, headers_error) + + synchronizer = PollingDataSource.new( + 0.01, + ListBasedRequester.new([failure, polling_result]), + logger + ) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break if updates.length >= 2 + end + end + + thread.join(2) + synchronizer.stop + + expect(updates.length).to eq(2) + interrupted = updates[0] + valid = updates[1] + + expect(interrupted.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + expect(interrupted.environment_id).to eq('test-env-generic') + expect(interrupted.error).not_to be_nil + expect(interrupted.error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::NETWORK_ERROR) + + expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + end + end + end + end + end +end From 4f75f75f6b3b0dd3f32660898936c6c1c22a8f51 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 13 Jan 2026 15:56:29 +0000 Subject: [PATCH 09/13] address feedback --- lib/ldclient-rb/impl/data_system/polling.rb | 33 +++++++-------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/lib/ldclient-rb/impl/data_system/polling.rb b/lib/ldclient-rb/impl/data_system/polling.rb index ed8acb75..124953e3 100644 --- a/lib/ldclient-rb/impl/data_system/polling.rb +++ b/lib/ldclient-rb/impl/data_system/polling.rb @@ -14,8 +14,8 @@ module LaunchDarkly module Impl module DataSystem - POLLING_ENDPOINT = "/sdk/poll" - LATEST_ALL_URI = "/sdk/latest-all" + FDV2_POLLING_ENDPOINT = "/sdk/poll" + FDV1_POLLING_ENDPOINT = "/sdk/latest-all" LD_ENVID_HEADER = "x-launchdarkly-env-id" LD_FD_FALLBACK_HEADER = "x-launchdarkly-fd-fallback" @@ -56,7 +56,7 @@ def initialize(poll_interval, requester, logger) @requester = requester @poll_interval = poll_interval @logger = logger - @event = Concurrent::Event.new + @wake_event = Concurrent::Event.new @stop = Concurrent::Event.new @name = "PollingDataSourceV2" end @@ -82,6 +82,7 @@ def fetch(ss) def sync(ss) @logger.info { "[LDClient] Starting PollingDataSourceV2 synchronizer" } @stop.reset + @wake_event.reset until @stop.set? result = @requester.fetch(ss.selector) @@ -155,7 +156,7 @@ def sync(ss) ) end - break if @event.wait(@poll_interval) + break if @wake_event.wait(@poll_interval) end end @@ -164,7 +165,7 @@ def sync(ss) # def stop @logger.info { "[LDClient] Stopping PollingDataSourceV2 synchronizer" } - @event.set + @wake_event.set @stop.set end @@ -223,7 +224,7 @@ def initialize(sdk_key, config) @etag = nil @config = config @sdk_key = sdk_key - @poll_uri = config.base_uri + POLLING_ENDPOINT + @poll_uri = config.base_uri + FDV2_POLLING_ENDPOINT @http_client = Impl::Util.new_http_client(config.base_uri, config) .use(:auto_inflate) .headers("Accept-Encoding" => "gzip") @@ -275,7 +276,7 @@ def fetch(selector) @config.logger.debug { "[LDClient] #{uri} response status:[#{status}] ETag:[#{etag}]" } - changeset_result = polling_payload_to_changeset(data) + changeset_result = LaunchDarkly::Impl::DataSystem.polling_payload_to_changeset(data) if changeset_result.success? LaunchDarkly::Result.success([changeset_result.value, response_headers]) else @@ -287,13 +288,6 @@ def fetch(selector) LaunchDarkly::Result.fail("Network error: #{e.message}", e) end end - - def stop - begin - @http_client.close - rescue - end - end end # @@ -311,7 +305,7 @@ def initialize(sdk_key, config) @etag = nil @config = config @sdk_key = sdk_key - @poll_uri = config.base_uri + LATEST_ALL_URI + @poll_uri = config.base_uri + FDV1_POLLING_ENDPOINT @http_client = Impl::Util.new_http_client(config.base_uri, config) .use(:auto_inflate) .headers("Accept-Encoding" => "gzip") @@ -359,7 +353,7 @@ def fetch(selector) @config.logger.debug { "[LDClient] #{uri} response status:[#{status}] ETag:[#{etag}]" } - changeset_result = fdv1_polling_payload_to_changeset(data) + changeset_result = LaunchDarkly::Impl::DataSystem.fdv1_polling_payload_to_changeset(data) if changeset_result.success? LaunchDarkly::Result.success([changeset_result.value, response_headers]) else @@ -371,13 +365,6 @@ def fetch(selector) LaunchDarkly::Result.fail("Network error: #{e.message}", e) end end - - def stop - begin - @http_client.close - rescue - end - end end # From 4bf0b8f73f1a3e25721eb3cdc13302becddcec96 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 13 Jan 2026 16:05:16 +0000 Subject: [PATCH 10/13] one more name change --- lib/ldclient-rb/impl/data_system/polling.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/ldclient-rb/impl/data_system/polling.rb b/lib/ldclient-rb/impl/data_system/polling.rb index 124953e3..7b53f955 100644 --- a/lib/ldclient-rb/impl/data_system/polling.rb +++ b/lib/ldclient-rb/impl/data_system/polling.rb @@ -56,7 +56,7 @@ def initialize(poll_interval, requester, logger) @requester = requester @poll_interval = poll_interval @logger = logger - @wake_event = Concurrent::Event.new + @interrupt_event = Concurrent::Event.new @stop = Concurrent::Event.new @name = "PollingDataSourceV2" end @@ -82,7 +82,7 @@ def fetch(ss) def sync(ss) @logger.info { "[LDClient] Starting PollingDataSourceV2 synchronizer" } @stop.reset - @wake_event.reset + @interrupt_event.reset until @stop.set? result = @requester.fetch(ss.selector) @@ -156,7 +156,7 @@ def sync(ss) ) end - break if @wake_event.wait(@poll_interval) + break if @interrupt_event.wait(@poll_interval) end end @@ -165,7 +165,7 @@ def sync(ss) # def stop @logger.info { "[LDClient] Stopping PollingDataSourceV2 synchronizer" } - @wake_event.set + @interrupt_event.set @stop.set end From b97a23dd8949cb49e0e571045b53bf4d035c73c7 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 13 Jan 2026 16:25:45 +0000 Subject: [PATCH 11/13] prevent immediate retry --- lib/ldclient-rb/impl/data_system/polling.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ldclient-rb/impl/data_system/polling.rb b/lib/ldclient-rb/impl/data_system/polling.rb index 7b53f955..1ec0ae18 100644 --- a/lib/ldclient-rb/impl/data_system/polling.rb +++ b/lib/ldclient-rb/impl/data_system/polling.rb @@ -123,6 +123,7 @@ def sync(ss) error: error_info, environment_id: envid ) + @interrupt_event.wait(@poll_interval) next end From fa0bb87ec80a0ec9b0dd5ea771778523328e6293 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Tue, 13 Jan 2026 17:15:35 +0000 Subject: [PATCH 12/13] always set selector in changeset builder --- lib/ldclient-rb/impl/data_system/polling.rb | 2 +- lib/ldclient-rb/interfaces/data_system.rb | 2 +- spec/impl/data_system/polling_payload_parsing_spec.rb | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/ldclient-rb/impl/data_system/polling.rb b/lib/ldclient-rb/impl/data_system/polling.rb index 1ec0ae18..e16ed0be 100644 --- a/lib/ldclient-rb/impl/data_system/polling.rb +++ b/lib/ldclient-rb/impl/data_system/polling.rb @@ -197,7 +197,7 @@ def stop basis = LaunchDarkly::Interfaces::DataSystem::Basis.new( change_set: change_set, - persist: !change_set.selector.nil?, + persist: change_set.selector.defined?, environment_id: env_id ) diff --git a/lib/ldclient-rb/interfaces/data_system.rb b/lib/ldclient-rb/interfaces/data_system.rb index 00fdcc23..00ccba25 100644 --- a/lib/ldclient-rb/interfaces/data_system.rb +++ b/lib/ldclient-rb/interfaces/data_system.rb @@ -464,7 +464,7 @@ def initialize def self.no_changes ChangeSet.new( intent_code: IntentCode::TRANSFER_NONE, - selector: nil, + selector: Selector.no_selector, changes: [] ) end diff --git a/spec/impl/data_system/polling_payload_parsing_spec.rb b/spec/impl/data_system/polling_payload_parsing_spec.rb index 7f4db603..93688f5b 100644 --- a/spec/impl/data_system/polling_payload_parsing_spec.rb +++ b/spec/impl/data_system/polling_payload_parsing_spec.rb @@ -40,7 +40,8 @@ module DataSystem expect(result).not_to be_nil expect(result.value.intent_code).to eq(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_NONE) expect(result.value.changes.length).to eq(0) - expect(result.value.selector).to be_nil + expect(result.value.selector).not_to be_nil + expect(result.value.selector.defined?).to eq(false) end it "transfer full with empty payload" do From 85bc1600d4149f424e35963ee0c406a5c02e46bd Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Wed, 14 Jan 2026 20:11:21 +0000 Subject: [PATCH 13/13] send fallback signal value in all updates --- lib/ldclient-rb/impl/data_system/polling.rb | 29 ++-- .../data_system/polling_synchronizer_spec.rb | 134 +++++++++++++++++- 2 files changed, 143 insertions(+), 20 deletions(-) diff --git a/lib/ldclient-rb/impl/data_system/polling.rb b/lib/ldclient-rb/impl/data_system/polling.rb index e16ed0be..15ff1ff2 100644 --- a/lib/ldclient-rb/impl/data_system/polling.rb +++ b/lib/ldclient-rb/impl/data_system/polling.rb @@ -106,23 +106,16 @@ def sync(ss) Time.now ) - if fallback - yield LaunchDarkly::Interfaces::DataSystem::Update.new( - state: LaunchDarkly::Interfaces::DataSource::Status::OFF, - error: error_info, - revert_to_fdv1: true, - environment_id: envid - ) - break - end - status_code = result.exception.status if Impl::Util.http_error_recoverable?(status_code) yield LaunchDarkly::Interfaces::DataSystem::Update.new( state: LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error: error_info, - environment_id: envid + environment_id: envid, + revert_to_fdv1: fallback ) + # Stop polling if fallback is set; caller will handle shutdown + break if fallback @interrupt_event.wait(@poll_interval) next end @@ -130,7 +123,8 @@ def sync(ss) yield LaunchDarkly::Interfaces::DataSystem::Update.new( state: LaunchDarkly::Interfaces::DataSource::Status::OFF, error: error_info, - environment_id: envid + environment_id: envid, + revert_to_fdv1: fallback ) break end @@ -145,18 +139,21 @@ def sync(ss) yield LaunchDarkly::Interfaces::DataSystem::Update.new( state: LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error: error_info, - environment_id: envid + environment_id: envid, + revert_to_fdv1: fallback ) else change_set, headers = result.value + fallback = headers[LD_FD_FALLBACK_HEADER] == 'true' yield LaunchDarkly::Interfaces::DataSystem::Update.new( state: LaunchDarkly::Interfaces::DataSource::Status::VALID, change_set: change_set, environment_id: headers[LD_ENVID_HEADER], - revert_to_fdv1: headers[LD_FD_FALLBACK_HEADER] == 'true' + revert_to_fdv1: fallback ) end + break if fallback break if @interrupt_event.wait(@poll_interval) end end @@ -284,7 +281,7 @@ def fetch(selector) LaunchDarkly::Result.fail(changeset_result.error, changeset_result.exception, response_headers) end rescue JSON::ParserError => e - LaunchDarkly::Result.fail("Failed to parse JSON: #{e.message}", e) + LaunchDarkly::Result.fail("Failed to parse JSON: #{e.message}", e, response_headers) rescue => e LaunchDarkly::Result.fail("Network error: #{e.message}", e) end @@ -361,7 +358,7 @@ def fetch(selector) LaunchDarkly::Result.fail(changeset_result.error, changeset_result.exception, response_headers) end rescue JSON::ParserError => e - LaunchDarkly::Result.fail("Failed to parse JSON: #{e.message}", e) + LaunchDarkly::Result.fail("Failed to parse JSON: #{e.message}", e, response_headers) rescue => e LaunchDarkly::Result.fail("Network error: #{e.message}", e) end diff --git a/spec/impl/data_system/polling_synchronizer_spec.rb b/spec/impl/data_system/polling_synchronizer_spec.rb index 948f5955..dcf551d6 100644 --- a/spec/impl/data_system/polling_synchronizer_spec.rb +++ b/spec/impl/data_system/polling_synchronizer_spec.rb @@ -490,11 +490,12 @@ def selector synchronizer.stop expect(updates.length).to eq(1) - off = updates[0] + interrupted = updates[0] - expect(off.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) - expect(off.revert_to_fdv1).to eq(true) - expect(off.environment_id).to eq('test-env-503') + # 503 is recoverable, so status is INTERRUPTED with fallback flag + expect(interrupted.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + expect(interrupted.revert_to_fdv1).to eq(true) + expect(interrupted.environment_id).to eq('test-env-503') end it "captures envid from generic error with headers" do @@ -535,6 +536,131 @@ def selector expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) end + + it "preserves fallback header on JSON parse error" do + headers_with_fallback = { + LD_ENVID_HEADER => 'test-env-parse-error', + LD_FD_FALLBACK_HEADER => 'true', + } + # Simulate a JSON parse error with fallback header + parse_error_result = LaunchDarkly::Result.fail( + "Failed to parse JSON: unexpected token", + JSON::ParserError.new("unexpected token"), + headers_with_fallback + ) + + synchronizer = PollingDataSource.new( + 0.01, + ListBasedRequester.new([parse_error_result]), + logger + ) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break # Break after first update to avoid polling again + end + end + + thread.join(1) + synchronizer.stop + + expect(updates.length).to eq(1) + interrupted = updates[0] + + # Verify the update signals INTERRUPTED state with fallback flag + # Caller (FDv2) will handle shutdown based on revert_to_fdv1 flag + expect(interrupted.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + expect(interrupted.revert_to_fdv1).to eq(true) + expect(interrupted.environment_id).to eq('test-env-parse-error') + expect(interrupted.error).not_to be_nil + expect(interrupted.error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::NETWORK_ERROR) + end + + it "signals fallback on recoverable HTTP error with fallback header" do + headers_with_fallback = { + LD_ENVID_HEADER => 'test-env-408', + LD_FD_FALLBACK_HEADER => 'true', + } + # 408 is a recoverable error + error_result = LaunchDarkly::Result.fail( + "error for test", + LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(408), + headers_with_fallback + ) + + synchronizer = PollingDataSource.new( + 0.01, + ListBasedRequester.new([error_result]), + logger + ) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + break # Break after first update to avoid polling again + end + end + + sleep 0.1 # Give thread time to process first update + synchronizer.stop + thread.join(1) + + expect(updates.length).to eq(1) + interrupted = updates[0] + + # Should be INTERRUPTED (recoverable) with fallback flag set + # Caller will handle shutdown based on revert_to_fdv1 flag + expect(interrupted.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + expect(interrupted.revert_to_fdv1).to eq(true) + expect(interrupted.environment_id).to eq('test-env-408') + expect(interrupted.error).not_to be_nil + expect(interrupted.error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE) + expect(interrupted.error.status_code).to eq(408) + end + + it "uses data but signals fallback on successful response with fallback header" do + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + change_set = builder.finish(LaunchDarkly::Interfaces::DataSystem::Selector.new(state: "p:SOMETHING:300", version: 300)) + + headers_with_fallback = { + LD_ENVID_HEADER => 'test-env-success-fallback', + LD_FD_FALLBACK_HEADER => 'true', + } + + # Server sends successful response with valid data but also signals fallback + success_result = LaunchDarkly::Result.success([change_set, headers_with_fallback]) + + synchronizer = PollingDataSource.new( + 0.01, + ListBasedRequester.new([success_result]), + logger + ) + updates = [] + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + end + end + + sleep 0.1 # Give thread time to process first update + synchronizer.stop + thread.join(1) + + expect(updates.length).to eq(1) + valid = updates[0] + + # Should use the data (VALID state) but signal future fallback + expect(valid.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(valid.revert_to_fdv1).to eq(true) + expect(valid.environment_id).to eq('test-env-success-fallback') + expect(valid.error).to be_nil + expect(valid.change_set).not_to be_nil # Data is provided + end end end end