From 0627ceb34ecf26f945c10cd519b86ca4c709c30f Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 5 Nov 2026 09:32:04 -0500 Subject: [PATCH 1/7] feat!: Remove payload filter support from the FDv2 data system The FDv2 streaming and polling requests, and the FDv1 fallback polling request, no longer send the filter query parameter. FDv2 already logged that payload filtering was unsupported and that the payload_filter_key option had no effect, but the requests still sent it. The behavior now matches the warning. The FDv1 data system keeps its payload filter support until the FDv1 removal deletes it. The contract tests no longer map the payloadFilter data system setting. The filtering capabilities stay, because only harness v2 tests them, against FDv1. --- contract-tests/client_entity.rb | 4 ---- lib/ldclient-rb/impl/data_system/polling.rb | 21 +++---------------- lib/ldclient-rb/impl/data_system/streaming.rb | 1 - .../data_system/streaming_headers_spec.rb | 1 - .../streaming_synchronizer_spec.rb | 1 - 5 files changed, 3 insertions(+), 25 deletions(-) diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb index 5df1f56a..8d061005 100644 --- a/contract-tests/client_entity.rb +++ b/contract-tests/client_entity.rb @@ -52,10 +52,6 @@ def initialize(log, config) data_system.fdv1_compatible_synchronizer(build_fdv1_fallback_builder(fdv1_fallback_config)) end - if data_system_config[:payloadFilter] - opts[:payload_filter_key] = data_system_config[:payloadFilter] - end - opts[:data_system_config] = data_system.build elsif config[:streaming] streaming = config[:streaming] diff --git a/lib/ldclient-rb/impl/data_system/polling.rb b/lib/ldclient-rb/impl/data_system/polling.rb index 078352ef..9613a752 100644 --- a/lib/ldclient-rb/impl/data_system/polling.rb +++ b/lib/ldclient-rb/impl/data_system/polling.rb @@ -309,17 +309,9 @@ def initialize(sdk_key, http_config, config) # @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 << ["basis", selector.state] - end - uri = @poll_uri - if query_params.any? - filter_query = URI.encode_www_form(query_params) - uri = "#{uri}?#{filter_query}" + if selector && selector.defined? + uri = "#{uri}?#{URI.encode_www_form(basis: selector.state)}" end headers = {} @@ -384,7 +376,7 @@ class HTTPFDv1PollingRequester # # @param sdk_key [String] # @param http_config [HttpConfigOptions] HTTP connection settings - # @param config [LaunchDarkly::Config] Used for global header settings and payload_filter_key + # @param config [LaunchDarkly::Config] Used for global header settings # def initialize(sdk_key, http_config, config) @etag = nil @@ -401,14 +393,7 @@ def initialize(sdk_key, http_config, config) # @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 } diff --git a/lib/ldclient-rb/impl/data_system/streaming.rb b/lib/ldclient-rb/impl/data_system/streaming.rb index d3c9a5f4..ad00531b 100644 --- a/lib/ldclient-rb/impl/data_system/streaming.rb +++ b/lib/ldclient-rb/impl/data_system/streaming.rb @@ -182,7 +182,6 @@ def sync(ss) client.query_params do selector = ss.selector { - "filter" => @config.payload_filter_key, "basis" => (selector.state if selector&.defined?), }.compact end diff --git a/spec/impl/data_system/streaming_headers_spec.rb b/spec/impl/data_system/streaming_headers_spec.rb index 707db3a2..f7c79417 100644 --- a/spec/impl/data_system/streaming_headers_spec.rb +++ b/spec/impl/data_system/streaming_headers_spec.rb @@ -15,7 +15,6 @@ module DataSystem "Config", logger: logger, stream_uri: "https://stream.example.com", - payload_filter_key: nil, socket_factory: nil, initial_reconnect_delay: 1, instance_id: nil diff --git a/spec/impl/data_system/streaming_synchronizer_spec.rb b/spec/impl/data_system/streaming_synchronizer_spec.rb index 6a7ba32e..261b7368 100644 --- a/spec/impl/data_system/streaming_synchronizer_spec.rb +++ b/spec/impl/data_system/streaming_synchronizer_spec.rb @@ -17,7 +17,6 @@ module DataSystem "Config", logger: logger, stream_uri: "https://stream.example.com", - payload_filter_key: nil, socket_factory: nil, initial_reconnect_delay: 1, instance_id: nil From 9876aae5a71027f9d4ad65f06899c3fe9836ad5d Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 5 Nov 2026 10:36:47 -0500 Subject: [PATCH 2/7] feat: Add preconfigured flags and event settings to TestDataV2 TestDataV2 gains use_preconfigured_flag, and FlagBuilderV2 gains migration_settings, sampling_ratio, exclude_from_summaries and a nested FlagMigrationSettingsBuilder. The v1 TestData has all of these, and v9 removes v1 TestData with the FDv1 data system, so TestDataV2 must cover them first. A preconfigured flag keeps its own version, as in v1 TestData, because tests use specific versions to check the evaluation and event output. A flag that replaces an existing flag gets the next version. A preconfigured flag also discards any stored builder for its key, so a later flag call starts from a new builder. --- lib/ldclient-rb/integrations/test_data_v2.rb | 47 +++++++++++ .../test_data_v2/flag_builder_v2.rb | 78 ++++++++++++++++++ spec/integrations/test_data_v2_spec.rb | 82 +++++++++++++++++++ 3 files changed, 207 insertions(+) diff --git a/lib/ldclient-rb/integrations/test_data_v2.rb b/lib/ldclient-rb/integrations/test_data_v2.rb index fe2da447..c519d40e 100644 --- a/lib/ldclient-rb/integrations/test_data_v2.rb +++ b/lib/ldclient-rb/integrations/test_data_v2.rb @@ -166,6 +166,53 @@ def add_instance(instance) end end + # + # Copies a full feature flag 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 flag to the test data which will be provided to any LDClient that you subsequently + # configure. + # + # Use this method if you need to use advanced flag configuration properties that are not supported by + # the simplified {FlagBuilderV2} API. Otherwise use the regular {#flag}/{#update} mechanism to avoid + # dependencies on details of the data model. + # + # You cannot make incremental changes with {#flag}/{#update} to a flag that you add in this way. + # You can only replace it with an entirely new flag configuration. + # + # A new flag keeps its own version, or gets version 1 if it has none. A flag that replaces an existing + # flag gets a version one more than the version of the existing flag. + # + # @param flag [Hash, LaunchDarkly::Impl::Model::FeatureFlag] the flag configuration as a hash or + # a FeatureFlag model object. + # @return [TestDataV2] the TestDataV2 instance + # + def use_preconfigured_flag(flag) + instances_copy = [] + updated_flag = nil + + @lock.with_write_lock do + flag_hash = (flag.is_a?(Hash) ? flag : flag.as_json).transform_keys(&:to_sym) + flag_key = flag_hash[:key].to_sym + + updated_flag = flag_hash.dup + old_flag = @current_flags[flag_key] + updated_flag[:version] = old_flag.nil? ? (updated_flag[:version] || 1) : old_flag[:version] + 1 + + @current_flags[flag_key] = updated_flag + @flag_builders.delete(flag_hash[:key]) + + instances_copy = @instances.dup + end + + instances_copy.each do |instance| + instance.upsert_flag(updated_flag) + end + + self + end + # # Copies a full segment data model object into the test data. # 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 26919a54..c11f8782 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 @@ -33,6 +33,9 @@ def initialize(key) @_fallthrough_variation = nil @_targets = {} @_rules = [] + @_migration_settings = nil + @_sampling_ratio = nil + @_exclude_from_summaries = false end # Creates a deep copy of the flag builder when the object is duplicated or cloned. @@ -108,6 +111,45 @@ def off_variation(variation) end end + # + # Sets the migration settings for this flag. + # + # @param settings [Hash, nil] the settings from {FlagMigrationSettingsBuilder#build} + # @return [FlagBuilderV2] the flag builder + # + def migration_settings(settings) + @_migration_settings = settings + self + end + + # + # Sets the sampling ratio for this flag. The ratio controls the emission rate of feature, debug and + # migration op events. + # + # General usage does not require this method. + # + # @param ratio [Integer] + # @return [FlagBuilderV2] the flag builder + # + def sampling_ratio(ratio) + @_sampling_ratio = ratio + self + end + + # + # Sets whether to exclude this flag from summary events. Use this to limit the size of summary events + # when flag payloads are large. + # + # General usage does not require this method. + # + # @param exclude [Boolean] + # @return [FlagBuilderV2] the flag builder + # + def exclude_from_summaries(exclude) + @_exclude_from_summaries = exclude + self + end + # # A shortcut for setting the flag to use the standard boolean configuration. # @@ -361,6 +403,9 @@ def build(version) base_flag_object[:offVariation] = @_off_variation unless @_off_variation.nil? base_flag_object[:fallthrough] = { variation: @_fallthrough_variation } unless @_fallthrough_variation.nil? + base_flag_object[:migration] = @_migration_settings unless @_migration_settings.nil? + base_flag_object[:samplingRatio] = @_sampling_ratio unless @_sampling_ratio.nil? || @_sampling_ratio == 1 + base_flag_object[:excludeFromSummaries] = true if @_exclude_from_summaries targets = [] context_targets = [] @@ -411,6 +456,39 @@ def add_rule(flag_rule_builder) private def deep_copy_rules @_rules.map(&:clone) end + + # + # A builder for the migration settings of a flag, for use with {FlagBuilderV2#migration_settings}. + # + # A migration flag has settings that a standard feature flag does not have. These settings control + # things such as the rate at which the SDK checks reads for consistency between origins. + # + class FlagMigrationSettingsBuilder + def initialize + @check_ratio = nil + end + + # + # @param ratio [Integer] + # @return [FlagMigrationSettingsBuilder] + # + def check_ratio(ratio) + return unless ratio.is_a? Integer + @check_ratio = ratio + self + end + + # + # @return [Hash, nil] the settings, or nil if they are the defaults + # + def build + return nil if @check_ratio.nil? || @check_ratio == 1 + + { + "checkRatio": @check_ratio, + } + end + end end # diff --git a/spec/integrations/test_data_v2_spec.rb b/spec/integrations/test_data_v2_spec.rb index d157df40..41657eb0 100644 --- a/spec/integrations/test_data_v2_spec.rb +++ b/spec/integrations/test_data_v2_spec.rb @@ -51,6 +51,88 @@ module Integrations expect(init_data[:segments][:mysegment][:included]).to eq(['user2']) end + it 'stores preconfigured flags with their own version' do + td = TestDataV2.data_source + td.use_preconfigured_flag({ key: 'myflag', version: 100, on: true, variations: ['a'] }) + flag = td.make_init_data[:flags][:myflag] + expect(flag[:key]).to eq('myflag') + expect(flag[:version]).to eq(100) + expect(flag[:variations]).to eq(['a']) + end + + it 'gives a preconfigured flag with no version version 1' do + td = TestDataV2.data_source + td.use_preconfigured_flag({ 'key' => 'myflag', 'variations' => ['a'] }) + expect(td.make_init_data[:flags][:myflag][:version]).to eq(1) + end + + it 'increments the version when a preconfigured flag replaces a flag' do + td = TestDataV2.data_source + td.use_preconfigured_flag({ key: 'myflag', version: 100, variations: ['a'] }) + td.use_preconfigured_flag({ key: 'myflag', version: 5, variations: ['b'] }) + flag = td.make_init_data[:flags][:myflag] + expect(flag[:version]).to eq(101) + expect(flag[:variations]).to eq(['b']) + end + + it 'accepts a FeatureFlag model as a preconfigured flag' do + td = TestDataV2.data_source + model = LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'myflag', version: 3, variations: ['a'] }) + td.use_preconfigured_flag(model) + expect(td.make_init_data[:flags][:myflag][:version]).to eq(3) + end + + it 'starts a new flag builder after a preconfigured flag replaces a built flag' do + td = TestDataV2.data_source + td.update(td.flag('myflag').variations('x', 'y')) + td.use_preconfigured_flag({ key: 'myflag', variations: ['a'] }) + expect(td.flag('myflag').build(1)[:variations]).to eq([true, false]) + end + + it 'sends a preconfigured flag to a running data source' do + td = TestDataV2.data_source + source = LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2.new(td) + expect(source).to receive(:upsert_flag).with(hash_including(key: 'myflag', version: 7)) + td.use_preconfigured_flag({ key: 'myflag', version: 7, variations: ['a'] }) + end + + describe 'FlagBuilderV2 event and migration settings' do + it 'omits the settings by default' do + flag = TestDataV2.data_source.flag('myflag').build(1) + expect(flag).not_to include(:migration, :samplingRatio, :excludeFromSummaries) + end + + it 'includes migration settings' do + settings = TestDataV2::FlagBuilderV2::FlagMigrationSettingsBuilder.new.check_ratio(10).build + flag = TestDataV2.data_source.flag('myflag').migration_settings(settings).build(1) + expect(flag[:migration]).to eq({ checkRatio: 10 }) + end + + it 'builds no migration settings for the default check ratio' do + expect(TestDataV2::FlagBuilderV2::FlagMigrationSettingsBuilder.new.check_ratio(1).build).to be_nil + end + + it 'includes a sampling ratio other than 1' do + td = TestDataV2.data_source + expect(td.flag('myflag').sampling_ratio(10).build(1)[:samplingRatio]).to eq(10) + expect(td.flag('myflag').sampling_ratio(1).build(1)).not_to include(:samplingRatio) + end + + it 'includes exclude from summaries when set' do + td = TestDataV2.data_source + expect(td.flag('myflag').exclude_from_summaries(true).build(1)[:excludeFromSummaries]).to be true + expect(td.flag('myflag').exclude_from_summaries(false).build(1)).not_to include(:excludeFromSummaries) + end + + it 'copies the settings when the builder is copied' do + td = TestDataV2.data_source + td.update(td.flag('myflag').sampling_ratio(10).exclude_from_summaries(true)) + flag = td.flag('myflag').build(1) + expect(flag[:samplingRatio]).to eq(10) + expect(flag[:excludeFromSummaries]).to be true + end + end + describe 'TestDataSourceV2' do it 'includes both flags and segments in fetch' do td = TestDataV2.data_source From d9979ff4d1d01e0f6364246852966aa34b7834ae Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 5 Nov 2026 10:41:40 -0500 Subject: [PATCH 3/7] test: Run the client specs on the FDv2 data system The shared spec config now gives the client an FDv2 data system with no sources, in place of the FDv1 null data source. Specs that need data use TestDataV2 as a synchronizer, and specs that need a persistent store use a hash-backed MockFeatureStore through the data system config. The end-to-end and environment ID specs poll the FDv2 endpoint with an FDv2 payload. On a 401, FDv2 turns the data source off at once and does not retry, so those specs now expect OFF, a single request and no full start wait, in place of the FDv1 retry behavior. The tracker specs use the V2 flag builder. The FDv2 store specs gain the flag change cases that only the FDv1 data source specs covered: flags that a full data set adds or removes, a deleted prerequisite, and a removed dependency. The nil SDK key and use_ldd fork specs still use FDv1 options, because they test FDv1 logic in LDClient. They change with that logic when FDv1 is removed. --- spec/impl/data_store/store_spec.rb | 26 +++++ spec/impl/migrations/migrator_spec.rb | 36 +++---- spec/impl/migrations/tracker_spec.rb | 15 +-- spec/ldclient_cached_data_warning_spec.rb | 31 +++--- spec/ldclient_end_to_end_spec.rb | 62 ++++++----- spec/ldclient_evaluation_spec.rb | 60 +++++------ spec/ldclient_events_spec.rb | 32 +++--- spec/ldclient_hooks_spec.rb | 22 ++-- spec/ldclient_migration_variation_spec.rb | 20 ++-- spec/ldclient_spec.rb | 7 +- spec/mock_components.rb | 119 ++++++++++++++++++++-- 11 files changed, 269 insertions(+), 161 deletions(-) diff --git a/spec/impl/data_store/store_spec.rb b/spec/impl/data_store/store_spec.rb index b8dcd54e..680e66bb 100644 --- a/spec/impl/data_store/store_spec.rb +++ b/spec/impl/data_store/store_spec.rb @@ -325,6 +325,13 @@ def flag_put(key, version, prerequisite: nil, segment: nil) put(LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, data) end + def flag_delete(key, version) + LaunchDarkly::Interfaces::DataSystem::Change.new( + action: LaunchDarkly::Interfaces::DataSystem::ChangeType::DELETE, + kind: LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, key: key.to_sym, version: version + ) + end + def segment_put(key, version) put(LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT, { key: key, version: version, included: ["user"] }) end @@ -380,6 +387,25 @@ def changed_flags end expect(changed).to eq %w[a b c d] end + + it "notifies the flags that a full data set adds or removes" do + full(flag_put("a", 1), flag_put("b", 1)) + + expect(changed_flags { full(flag_put("a", 1), flag_put("c", 1)) }).to eq %w[b c] + end + + it "notifies each flag that depends on a flag deleted in a delta" do + full(flag_put("a", 1, prerequisite: "b"), flag_put("b", 1, prerequisite: "c"), flag_put("c", 1), flag_put("e", 1)) + + expect(changed_flags { delta(flag_delete("c", 2)) }).to eq %w[a b c] + end + + it "stops following a dependency that a delta removed" do + full(flag_put("a", 1, prerequisite: "b"), flag_put("b", 1)) + delta(flag_put("a", 2)) + + expect(changed_flags { delta(flag_put("b", 2)) }).to eq %w[b] + end end describe "#commit" do diff --git a/spec/impl/migrations/migrator_spec.rb b/spec/impl/migrations/migrator_spec.rb index 0645e7eb..dddf5f61 100644 --- a/spec/impl/migrations/migrator_spec.rb +++ b/spec/impl/migrations/migrator_spec.rb @@ -14,7 +14,7 @@ module Migrations subject { Migrator } let(:default_config) { LaunchDarkly::Config.new({ diagnostic_opt_out: true, logger: $null_log }) } let(:data_source) { - td = LaunchDarkly::Integrations::TestData.data_source + td = LaunchDarkly::Integrations::TestDataV2.data_source [ LaunchDarkly::Migrations::STAGE_OFF, @@ -49,7 +49,7 @@ def default_builder(client) LaunchDarkly::Migrations::OP_WRITE, ].each do |op| it "for #{op}" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| builder = default_builder(client) payload_old = nil @@ -100,7 +100,7 @@ def default_builder(client) {label: "write complete", stage: LaunchDarkly::Migrations::STAGE_COMPLETE, op: LaunchDarkly::Migrations::OP_WRITE, expected: ["new"]}, ].each do |test_param| it "for #{test_param[:label]}" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| with_processor_and_sender(default_config, 0) do |ep, sender| override_client_event_processor(client, ep) @@ -152,7 +152,7 @@ def default_builder(client) {label: "write complete", stage: LaunchDarkly::Migrations::STAGE_COMPLETE, op: LaunchDarkly::Migrations::OP_WRITE, expected: [:new]}, ].each do |test_param| it "for #{test_param[:label]}" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| with_processor_and_sender(default_config, 0) do |ep, sender| override_client_event_processor(client, ep) @@ -200,7 +200,7 @@ def default_builder(client) { stage: LaunchDarkly::Migrations::STAGE_COMPLETE, old: false, new: true }, ].each do |params| it "for #{params[:stage]} stage" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| builder = default_builder(client) called_old = false @@ -237,7 +237,7 @@ def default_builder(client) { stage: LaunchDarkly::Migrations::STAGE_COMPLETE, origin: "new" }, ].each do |params| it "for #{params[:stage]} stage" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| builder = default_builder(client) builder.read(->(_) { raise "old error" }, ->(_) { raise "new error" }) @@ -255,7 +255,7 @@ def default_builder(client) describe "support execution order" do it "parallel" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| builder = default_builder(client) builder.read( @@ -280,7 +280,7 @@ def default_builder(client) end it "serial" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| builder = default_builder(client) builder.read_execution_order(LaunchDarkly::Migrations::MigratorBuilder::EXECUTION_SERIAL) @@ -306,7 +306,7 @@ def default_builder(client) end it "random" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| builder = default_builder(client) builder.read_execution_order(LaunchDarkly::Migrations::MigratorBuilder::EXECUTION_RANDOM) @@ -343,7 +343,7 @@ def default_builder(client) {label: "live when different", stage: LaunchDarkly::Migrations::STAGE_LIVE, old_return: "same", new_return: "different", expected: false}, ].each do |test_param| it "for #{test_param[:label]}" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| with_processor_and_sender(default_config, 0) do |ep, sender| override_client_event_processor(client, ep) @@ -382,7 +382,7 @@ def default_builder(client) {label: "live when different", stage: LaunchDarkly::Migrations::STAGE_LIVE}, ].each do |test_param| it "for #{test_param[:label]} unless there is an error" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| with_processor_and_sender(default_config, 0) do |ep, sender| override_client_event_processor(client, ep) @@ -419,7 +419,7 @@ def default_builder(client) {label: "live", stage: LaunchDarkly::Migrations::STAGE_LIVE}, ].each do |test_param| it "for #{test_param[:label]} when an exception occurs" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| with_processor_and_sender(default_config, 0) do |ep, sender| override_client_event_processor(client, ep) @@ -464,7 +464,7 @@ def default_builder(client) {label: "complete", stage: LaunchDarkly::Migrations::STAGE_COMPLETE, expected: ["new"]}, ].each do |test_param| it "for #{test_param[:label]}" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| with_processor_and_sender(default_config, 0) do |ep, sender| override_client_event_processor(client, ep) @@ -508,7 +508,7 @@ def default_builder(client) { stage: LaunchDarkly::Migrations::STAGE_COMPLETE, old: false, new: true }, ].each do |params| it "for #{params[:stage]}" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| builder = default_builder(client) called_old = false @@ -545,7 +545,7 @@ def default_builder(client) { stage: LaunchDarkly::Migrations::STAGE_COMPLETE, origin: "new" }, ].each do |params| it "for #{params[:stage]} stage" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| builder = default_builder(client) builder.write(->(_) { raise "old error" }, ->(_) { raise "new error" }) @@ -576,7 +576,7 @@ def default_builder(client) # LaunchDarkly::Migrations::STAGE_COMPLETE doesn't run both so we can ignore it. ].each do |params| it "for #{params[:stage]} stage" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| builder = default_builder(client) called_old = false @@ -614,7 +614,7 @@ def default_builder(client) {label: "write complete", stage: LaunchDarkly::Migrations::STAGE_COMPLETE, expected: "new"}, ].each do |test_param| it test_param[:label] do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| with_processor_and_sender(default_config, 0) do |ep, sender| override_client_event_processor(client, ep) @@ -655,7 +655,7 @@ def default_builder(client) {label: "ramp down", stage: LaunchDarkly::Migrations::STAGE_RAMPDOWN, old_fail: true, new_fail: false, expected: "old"}, ].each do |test_param| it "for #{test_param[:label]}" do - with_client(test_config(data_source: data_source)) do |client| + with_client(test_config(data_system_config: test_data_system(data_source))) do |client| with_processor_and_sender(default_config, 0) do |ep, sender| override_client_event_processor(client, ep) diff --git a/spec/impl/migrations/tracker_spec.rb b/spec/impl/migrations/tracker_spec.rb index 28f760d4..94e237de 100644 --- a/spec/impl/migrations/tracker_spec.rb +++ b/spec/impl/migrations/tracker_spec.rb @@ -1,11 +1,12 @@ require 'ldclient-rb/interfaces' require "ldclient-rb" +require "ldclient-rb/integrations/test_data_v2" module LaunchDarkly module Impl module Migrations describe OpTracker do - let(:flag_data) { LaunchDarkly::Integrations::TestData::FlagBuilder.new("feature").build(1) } + let(:flag_data) { LaunchDarkly::Integrations::TestDataV2::FlagBuilderV2.new("feature").build(1) } let(:flag) { LaunchDarkly::Impl::Model::FeatureFlag.new(flag_data) } let(:context) { LaunchDarkly::LDContext.with_key("user-key") } let(:detail) { LaunchDarkly::EvaluationDetail.new(true, 0, LaunchDarkly::EvaluationReason.fallthrough) } @@ -75,10 +76,10 @@ def minimal_tracker() end it "with explicit sampling ratio of 1" do - settings = LaunchDarkly::Integrations::TestData::FlagBuilder::FlagMigrationSettingsBuilder.new + settings = LaunchDarkly::Integrations::TestDataV2::FlagBuilderV2::FlagMigrationSettingsBuilder.new settings.check_ratio(1) - builder = LaunchDarkly::Integrations::TestData::FlagBuilder.new("feature") + builder = LaunchDarkly::Integrations::TestDataV2::FlagBuilderV2.new("feature") builder.migration_settings(settings.build) flag = LaunchDarkly::Impl::Model::FeatureFlag.new(builder.build(1)) context = LaunchDarkly::LDContext.with_key("user-key") @@ -99,10 +100,10 @@ def minimal_tracker() end it "unless disabled with sampling ratio of 0" do - settings = LaunchDarkly::Integrations::TestData::FlagBuilder::FlagMigrationSettingsBuilder.new + settings = LaunchDarkly::Integrations::TestDataV2::FlagBuilderV2::FlagMigrationSettingsBuilder.new settings.check_ratio(0) - builder = LaunchDarkly::Integrations::TestData::FlagBuilder.new("feature") + builder = LaunchDarkly::Integrations::TestDataV2::FlagBuilderV2.new("feature") builder.migration_settings(settings.build) flag = LaunchDarkly::Impl::Model::FeatureFlag.new(builder.build(1)) context = LaunchDarkly::LDContext.with_key("user-key") @@ -122,10 +123,10 @@ def minimal_tracker() end it "when supplied a non-trivial sampling ratio" do - settings = LaunchDarkly::Integrations::TestData::FlagBuilder::FlagMigrationSettingsBuilder.new + settings = LaunchDarkly::Integrations::TestDataV2::FlagBuilderV2::FlagMigrationSettingsBuilder.new settings.check_ratio(10) - builder = LaunchDarkly::Integrations::TestData::FlagBuilder.new("feature") + builder = LaunchDarkly::Integrations::TestDataV2::FlagBuilderV2.new("feature") builder.migration_settings(settings.build) flag = LaunchDarkly::Impl::Model::FeatureFlag.new(builder.build(1)) context = LaunchDarkly::LDContext.with_key("user-key") diff --git a/spec/ldclient_cached_data_warning_spec.rb b/spec/ldclient_cached_data_warning_spec.rb index ec0ecdaa..ac4180a6 100644 --- a/spec/ldclient_cached_data_warning_spec.rb +++ b/spec/ldclient_cached_data_warning_spec.rb @@ -7,26 +7,19 @@ module LaunchDarkly let(:logger) { CapturingLogger.new } let(:context) { basic_context } - # A data source that starts but never reports initialized. With a store that already holds - # data, the client evaluates with cached data. - def uninitialized_source - wait = double - allow(wait).to receive(:wait) - source = double - allow(source).to receive(:start).and_return(wait) - allow(source).to receive(:stop) - allow(source).to receive(:initialized?).and_return(false) - source - end - + # The synchronizer sends no data. The persistent store already holds data, so the client + # evaluates with cached data. def initialized_store - store = InMemoryFeatureStore.new + store = MockFeatureStore.new store.init({ LaunchDarkly::Interfaces::DataStore::FEATURES => {}, LaunchDarkly::Interfaces::DataStore::SEGMENTS => {} }) store end def cached_data_config - test_config(logger: logger, feature_store: initialized_store, data_source: uninitialized_source) + data_system = silent_data_system_builder + .data_store(initialized_store, LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_WRITE) + .build + test_config(logger: logger, data_system_config: data_system) end def evaluation_warnings(logger) @@ -38,7 +31,7 @@ def all_flags_state_warnings(logger) end it "logs the evaluation warning once per client" do - with_client(cached_data_config) do |client| + with_client(cached_data_config, 0) do |client| client.variation("flag", context, false) client.variation("flag", context, false) client.variation_detail("flag", context, false) @@ -49,7 +42,7 @@ def all_flags_state_warnings(logger) # all_flags_state checks LDClient#initialized?, which is true when the store holds data. The # stub makes the client report not initialized so the cached-data branch runs. it "logs the all_flags_state warning once per client" do - with_client(cached_data_config) do |client| + with_client(cached_data_config, 0) do |client| allow(client).to receive(:initialized?).and_return(false) client.all_flags_state(context) client.all_flags_state(context) @@ -58,7 +51,7 @@ def all_flags_state_warnings(logger) end it "logs the evaluation and all_flags_state warnings independently" do - with_client(cached_data_config) do |client| + with_client(cached_data_config, 0) do |client| allow(client).to receive(:initialized?).and_return(false) client.variation("flag", context, false) client.all_flags_state(context) @@ -70,10 +63,10 @@ def all_flags_state_warnings(logger) end it "logs the warning again for a new client" do - with_client(cached_data_config) do |client| + with_client(cached_data_config, 0) do |client| client.variation("flag", context, false) end - with_client(cached_data_config) do |client| + with_client(cached_data_config, 0) do |client| client.variation("flag", context, false) end expect(evaluation_warnings(logger).length).to eq(2) diff --git a/spec/ldclient_end_to_end_spec.rb b/spec/ldclient_end_to_end_spec.rb index f8c7b3c8..6cae27c6 100644 --- a/spec/ldclient_end_to_end_spec.rb +++ b/spec/ldclient_end_to_end_spec.rb @@ -4,11 +4,7 @@ ALWAYS_TRUE_FLAG = { key: 'flagkey', version: 1, on: false, offVariation: 1, variations: [ false, true ] } -DATA_WITH_ALWAYS_TRUE_FLAG = { - flags: { ALWAYS_TRUE_FLAG[:key ].to_sym => ALWAYS_TRUE_FLAG }, - segments: {}, -} -PUT_EVENT_WITH_ALWAYS_TRUE_FLAG = "event: put\ndata:{\"data\":#{DATA_WITH_ALWAYS_TRUE_FLAG.to_json}}\n\n'" +FLAGS_WITH_ALWAYS_TRUE_FLAG = { ALWAYS_TRUE_FLAG[:key].to_sym => ALWAYS_TRUE_FLAG } module LaunchDarkly # Note that we can't do end-to-end tests in streaming mode until we have a test server that can do streaming @@ -17,51 +13,50 @@ module LaunchDarkly describe "LDClient end-to-end", flaky: true do it "starts in polling mode" do with_server do |poll_server| - poll_server.setup_ok_response("/sdk/latest-all", DATA_WITH_ALWAYS_TRUE_FLAG.to_json, "application/json") + poll_server.setup_ok_response("/sdk/poll", fdv2_poll_payload(flags: FLAGS_WITH_ALWAYS_TRUE_FLAG), "application/json") - with_client(test_config(stream: false, data_source: nil, base_uri: poll_server.base_uri.to_s)) do |client| + with_client(test_config(data_system_config: polling_data_system_builder(poll_server.base_uri.to_s).build)) do |client| expect(client.initialized?).to be true expect(client.variation(ALWAYS_TRUE_FLAG[:key], basic_context, false)).to be true end end end - it "waits the full start time and stays uninitialized in polling mode with 401 error" do + it "stops without waiting the full start time and turns off in polling mode with 401 error" do with_server do |poll_server| - poll_server.setup_status_response("/sdk/latest-all", 401) + poll_server.setup_status_response("/sdk/poll", 401) - config = test_config(stream: false, data_source: nil, base_uri: poll_server.base_uri.to_s) + config = test_config(data_system_config: polling_data_system_builder(poll_server.base_uri.to_s).build) started_at = Time.now - ensure_close(LDClient.new(sdk_key, config, 1)) do |client| - expect(Time.now - started_at).to be >= 1 + ensure_close(LDClient.new(sdk_key, config, 5)) do |client| + expect(Time.now - started_at).to be < 5 expect(client.initialized?).to be false status = client.data_source_status_provider.status - expect(status.state).to eq(Interfaces::DataSource::Status::INITIALIZING) + expect(status.state).to eq(Interfaces::DataSource::Status::OFF) expect(status.last_error.status_code).to eq(401) + expect(poll_server.requests.count).to eq(1) expect(client.variation(ALWAYS_TRUE_FLAG[:key], basic_context, false)).to be false end end end - it "waits the full start time, stays uninitialized, and keeps retrying in streaming mode with 401 error" do - # A short retry delay, so that the retries happen within the start wait. - allow(Impl::RetryState).to receive(:for_streaming).and_return( - Impl::RetryState.new(normal_initial_delay: 0.05, normal_ceiling_delay: 0.05, extended_initial_delay: 0.05, - extended_ceiling_delay: 0.05, reset_policy: Impl::RetryState::AfterConsecutiveSuccesses.new(2))) - + it "stops without waiting the full start time and turns off in streaming mode with 401 error" do with_server do |stream_server| - stream_server.setup_status_response("/all", 401) + stream_server.setup_status_response("/sdk/stream", 401) + data_system = DataSystem.custom + .synchronizers([DataSystem.streaming_ds_builder.base_uri(stream_server.base_uri.to_s)]) + .build # The SSE worker thread can log after the example ends, so the logger cannot be a test double. - config = test_config(stream: true, data_source: nil, stream_uri: stream_server.base_uri.to_s, logger: $null_log) + config = test_config(data_system_config: data_system, logger: $null_log) started_at = Time.now - ensure_close(LDClient.new(sdk_key, config, 1)) do |client| - expect(Time.now - started_at).to be >= 1 + ensure_close(LDClient.new(sdk_key, config, 5)) do |client| + expect(Time.now - started_at).to be < 5 expect(client.initialized?).to be false status = client.data_source_status_provider.status - expect(status.state).to eq(Interfaces::DataSource::Status::INITIALIZING) + expect(status.state).to eq(Interfaces::DataSource::Status::OFF) expect(status.last_error.status_code).to eq(401) - expect(stream_server.requests.count).to be >= 3 + expect(stream_server.requests.count).to eq(1) end end end @@ -118,21 +113,22 @@ module LaunchDarkly with_server do |poll_server| with_server do |events_server| events_server.setup_ok_response("/bulk", "") - poll_server.setup_ok_response("/sdk/latest-all", '{"flags":{},"segments":{}}', "application/json") + poll_server.setup_ok_response("/sdk/poll", fdv2_poll_payload, "application/json") + socket_factory = SocketFactoryFromHash.new({ + "fake-polling-server" => poll_server.port, + "fake-events-server" => events_server.port, + }) + polling = DataSystem.polling_ds_builder.base_uri("http://fake-polling-server").socket_factory(socket_factory) config = test_config( - stream: false, - data_source: nil, + data_system_config: DataSystem.custom.synchronizers([polling]).build, send_events: true, - base_uri: "http://fake-polling-server", events_uri: "http://fake-events-server", diagnostic_opt_out: true, - socket_factory: SocketFactoryFromHash.new({ - "fake-polling-server" => poll_server.port, - "fake-events-server" => events_server.port, - }) + socket_factory: socket_factory ) with_client(config) do |client| + expect(client.initialized?).to be true client.identify(basic_context) client.flush diff --git a/spec/ldclient_evaluation_spec.rb b/spec/ldclient_evaluation_spec.rb index 89c73c38..83e03116 100644 --- a/spec/ldclient_evaluation_spec.rb +++ b/spec/ldclient_evaluation_spec.rb @@ -21,29 +21,29 @@ module LaunchDarkly end it "returns the value for an existing feature" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").variation_for_all(0)) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| expect(client.variation("flagkey", basic_context, "default")).to eq "value" end end it "returns the default value if a feature evaluates to nil" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.use_preconfigured_flag({ # TestData normally won't construct a flag with offVariation: nil key: "flagkey", on: false, offVariation: nil, }) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| expect(client.variation("flagkey", basic_context, "default")).to eq "default" end end it "can evaluate a flag that references a segment" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source segment = SegmentBuilder.new("segmentkey").included(basic_context.key).build td.use_preconfigured_segment(segment) td.use_preconfigured_flag( @@ -51,13 +51,13 @@ module LaunchDarkly RuleBuilder.new.variation(0).clause(Clauses.match_segment(segment)) ).build) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| expect(client.variation("flagkey", basic_context, false)).to be true end end it "can evaluate a flag that references a big segment" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source segment = SegmentBuilder.new("segmentkey").unbounded(true).generation(1).build td.use_preconfigured_segment(segment) td.use_preconfigured_flag( @@ -69,7 +69,7 @@ module LaunchDarkly segstore.setup_segment_for_context(basic_context.key, segment, true) big_seg_config = BigSegmentsConfig.new(store: segstore) - with_client(test_config(data_source: td, big_segments: big_seg_config)) do |client| + with_client(test_config(data_system_config: test_data_system(td), big_segments: big_seg_config)) do |client| expect(client.variation("flagkey", basic_context, false)).to be true end end @@ -93,10 +93,10 @@ module LaunchDarkly end it "returns a value for an existing feature" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").on(false).off_variation(0)) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| result = client.variation_detail("flagkey", basic_context, "default") expected = EvaluationDetail.new("value", 0, EvaluationReason::off) expect(result).to eq expected @@ -104,14 +104,14 @@ module LaunchDarkly end it "returns the default value if a feature evaluates to nil" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.use_preconfigured_flag({ # TestData normally won't construct a flag with offVariation: nil key: "flagkey", on: false, offVariation: nil, }) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| result = client.variation_detail("flagkey", basic_context, "default") expected = EvaluationDetail.new("default", nil, EvaluationReason::off) expect(result).to eq expected @@ -120,7 +120,7 @@ module LaunchDarkly end it "includes big segment status in reason when evaluating a flag that references a big segment" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source segment = SegmentBuilder.new("segmentkey").unbounded(true).generation(1).build td.use_preconfigured_segment(segment) td.use_preconfigured_flag( @@ -133,7 +133,7 @@ module LaunchDarkly segstore.setup_metadata(Time.now) big_seg_config = BigSegmentsConfig.new(store: segstore) - with_client(test_config(data_source: td, big_segments: big_seg_config)) do |client| + with_client(test_config(data_system_config: test_data_system(td), big_segments: big_seg_config)) do |client| result = client.variation_detail("flagkey", basic_context, false) expect(result.value).to be true expect(result.reason.big_segments_status).to eq(BigSegmentsStatus::HEALTHY) @@ -145,7 +145,7 @@ module LaunchDarkly let(:flag1) { { key: "key1", version: 100, offVariation: 0, variations: [ 'value1' ], trackEvents: false } } let(:flag2) { { key: "key2", version: 200, offVariation: 1, variations: %w[x value2], trackEvents: true, debugEventsUntilDate: 1000 } } let(:test_data) { - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.use_preconfigured_flag(flag1) td.use_preconfigured_flag(flag2) td @@ -153,7 +153,7 @@ module LaunchDarkly it "returns flags state" do - with_client(test_config(data_source: test_data)) do |client| + with_client(test_config(data_system_config: test_data_system(test_data))) do |client| state = client.all_flags_state({ key: 'userkey', kind: 'user' }) expect(state.valid?).to be true @@ -182,7 +182,7 @@ module LaunchDarkly end it "correctly includes only top level prerequisite keys" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.use_preconfigured_flag({ key: "top-level-has-prereqs-1", on: true, @@ -207,7 +207,7 @@ module LaunchDarkly td.use_preconfigured_flag({ key: "prereq2", on: true, version: 200, fallthrough: { variation: 0 }, variations: [ 'value' ] }) td.use_preconfigured_flag({ key: "prereq3", on: true, version: 200, fallthrough: { variation: 0 }, variations: [ 'value' ] }) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| state = client.all_flags_state({ key: 'userkey', kind: 'user' }) expect(state.valid?).to be true @@ -239,13 +239,13 @@ module LaunchDarkly end it "can be filtered for only client-side flags" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.use_preconfigured_flag({ key: "server-side-1", offVariation: 0, variations: [ 'a' ], clientSide: false }) td.use_preconfigured_flag({ key: "server-side-2", offVariation: 0, variations: [ 'b' ], clientSide: false }) td.use_preconfigured_flag({ key: "client-side-1", offVariation: 0, variations: [ 'value1' ], clientSide: true }) td.use_preconfigured_flag({ key: "client-side-2", offVariation: 0, variations: [ 'value2' ], clientSide: true }) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| state = client.all_flags_state({ key: 'userkey', kind: 'user' }, client_side_only: true) expect(state.valid?).to be true @@ -256,12 +256,12 @@ module LaunchDarkly it "can omit details for untracked flags" do future_time = (Time.now.to_f * 1000).to_i + 100000 - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.use_preconfigured_flag({ key: "key1", version: 100, offVariation: 0, variations: [ 'value1' ], trackEvents: false }) td.use_preconfigured_flag({ key: "key2", version: 200, offVariation: 1, variations: %w[x value2], trackEvents: true }) td.use_preconfigured_flag({ key: "key3", version: 300, offVariation: 1, variations: %w[x value3], debugEventsUntilDate: future_time }) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| state = client.all_flags_state({ key: 'userkey', kind: 'user' }, { details_only_for_tracked_flags: true }) expect(state.valid?).to be true @@ -294,7 +294,7 @@ module LaunchDarkly end it "returns empty state for nil context" do - with_client(test_config(data_source: test_data)) do |client| + with_client(test_config(data_system_config: test_data_system(test_data))) do |client| state = client.all_flags_state(nil) expect(state.valid?).to be false expect(state.values_map).to eq({}) @@ -302,7 +302,7 @@ module LaunchDarkly end it "returns empty state for nil context key" do - with_client(test_config(data_source: test_data)) do |client| + with_client(test_config(data_system_config: test_data_system(test_data))) do |client| state = client.all_flags_state({}) expect(state.valid?).to be false expect(state.values_map).to eq({}) @@ -310,7 +310,7 @@ module LaunchDarkly end it "returns empty state if offline" do - with_client(test_config(data_source: test_data, offline: true)) do |offline_client| + with_client(test_config(data_system_config: test_data_system(test_data), offline: true)) do |offline_client| state = offline_client.all_flags_state({ key: 'userkey' }) expect(state.valid?).to be false expect(state.values_map).to eq({}) @@ -318,15 +318,7 @@ module LaunchDarkly end it "returns empty state if store is not initialize" do - wait = double - expect(wait).to receive(:wait).at_least(:once) - - source = double - expect(source).to receive(:start).at_least(:once).and_return(wait) - expect(source).to receive(:stop).at_least(:once).and_return(wait) - expect(source).to receive(:initialized?).at_least(:once).and_return(false) - store = LaunchDarkly::InMemoryFeatureStore.new - with_client(test_config(store: store, data_source: source)) do |offline_client| + with_client(test_config(data_system_config: silent_data_system_builder.build), 0) do |offline_client| state = offline_client.all_flags_state({ key: 'userkey' }) expect(state.valid?).to be false expect(state.values_map).to eq({}) diff --git a/spec/ldclient_events_spec.rb b/spec/ldclient_events_spec.rb index 9c1d7f07..f12b134c 100644 --- a/spec/ldclient_events_spec.rb +++ b/spec/ldclient_events_spec.rb @@ -29,11 +29,11 @@ def event_processor(client) end it "known flag" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").variation_for_all(0)) context = basic_context - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| expect(event_processor(client)).to receive(:record_eval_event).with( context, 'flagkey', 1, 0, 'value', nil, 'default', false, nil, nil, nil, false ) @@ -42,12 +42,12 @@ def event_processor(client) end it "does not send event, and logs error, if context is nil" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").variation_for_all(0)) logger = double.as_null_object - with_client(test_config(data_source: td, logger: logger)) do |client| + with_client(test_config(data_system_config: test_data_system(td), logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_eval_event) expect(logger).to receive(:error) client.variation("flagkey", nil, "default") @@ -55,13 +55,13 @@ def event_processor(client) end it "does not send event, and logs error, if context key is nil" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").variation_for_all(0)) logger = double.as_null_object keyless_user = { key: nil } - with_client(test_config(data_source: td, logger: logger)) do |client| + with_client(test_config(data_system_config: test_data_system(td), logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_eval_event) expect(logger).to receive(:error) client.variation("flagkey", keyless_user, "default") @@ -69,7 +69,7 @@ def event_processor(client) end it "sets trackEvents and reason if trackEvents is set for matched rule" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.use_preconfigured_flag( FlagBuilder.new("flagkey").version(100).on(true).variations("value") .rule(RuleBuilder.new.variation(0).id("id").track_events(true) @@ -78,7 +78,7 @@ def event_processor(client) ) context = basic_context - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| expect(event_processor(client)).to receive(:record_eval_event).with( context, 'flagkey', 100, 0, 'value', LaunchDarkly::EvaluationReason::rule_match(0, 'id'), 'default', true, nil, nil, nil, false @@ -88,14 +88,14 @@ def event_processor(client) end it "sets trackEvents and reason if trackEventsFallthrough is set and we fell through" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.use_preconfigured_flag( FlagBuilder.new("flagkey").version(100).on(true).variations("value").fallthrough_variation(0) .track_events_fallthrough(true).build ) context = basic_context - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| expect(event_processor(client)).to receive(:record_eval_event).with( context, 'flagkey', 100, 0, 'value', LaunchDarkly::EvaluationReason::fallthrough, 'default', true, nil, nil, nil, false @@ -119,11 +119,11 @@ def event_processor(client) end it "known flag" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").on(false).off_variation(0)) context = basic_context - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| expect(event_processor(client)).to receive(:record_eval_event).with( context, 'flagkey', 1, 0, 'value', LaunchDarkly::EvaluationReason::off, 'default', false, nil, nil, nil, false @@ -133,12 +133,12 @@ def event_processor(client) end it "does not send event, and logs error, if context is nil" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").on(false).off_variation(0)) logger = double.as_null_object - with_client(test_config(data_source: td, logger: logger)) do |client| + with_client(test_config(data_system_config: test_data_system(td), logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_eval_event) expect(logger).to receive(:error) client.variation_detail("flagkey", nil, "default") @@ -146,12 +146,12 @@ def event_processor(client) end it "does not send event, and logs warning, if context key is nil" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").on(false).off_variation(0)) logger = double.as_null_object - with_client(test_config(data_source: td, logger: logger)) do |client| + with_client(test_config(data_system_config: test_data_system(td), logger: logger)) do |client| expect(event_processor(client)).not_to receive(:record_eval_event) expect(logger).to receive(:error) client.variation_detail("flagkey", { key: nil }, "default") diff --git a/spec/ldclient_hooks_spec.rb b/spec/ldclient_hooks_spec.rb index 16feb0bd..72691f6e 100644 --- a/spec/ldclient_hooks_spec.rb +++ b/spec/ldclient_hooks_spec.rb @@ -102,12 +102,12 @@ module LaunchDarkly context "passing data" do it "hook receives EvaluationDetail" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").variation_for_all(0)) detail = nil config_hook = MockHook.new(->(_, _) { }, ->(_, _, d) { detail = d }) - with_client(test_config(data_source: td, hooks: [config_hook])) do |client| + with_client(test_config(data_system_config: test_data_system(td), hooks: [config_hook])) do |client| client.variation("flagkey", basic_context, "default") expect(detail.value).to eq "value" @@ -131,12 +131,10 @@ module LaunchDarkly config_hook = MockHook.new(->(_, _) { }, ->(series_context, _, _) { environment_id = series_context.environment_id }) with_server do |poll_server| - poll_server.setup_ok_response("/sdk/latest-all", { flags: {}, segments: {} }.to_json, "application/json", { "X-LD-EnvID" => "env-abc" }) + poll_server.setup_ok_response("/sdk/poll", fdv2_poll_payload, "application/json", { "X-LD-EnvID" => "env-abc" }) config = test_config( - stream: false, - data_source: nil, - base_uri: poll_server.base_uri.to_s, + data_system_config: polling_data_system_builder(poll_server.base_uri.to_s).build, hooks: [config_hook] ) with_client(config) do |client| @@ -183,12 +181,12 @@ module LaunchDarkly context "migration variation" do it "EvaluationDetail contains stage value" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("off").variation_for_all(0)) detail = nil config_hook = MockHook.new(->(_, _) { }, ->(_, _, d) { detail = d }) - with_client(test_config(data_source: td, hooks: [config_hook])) do |client| + with_client(test_config(data_system_config: test_data_system(td), hooks: [config_hook])) do |client| client.migration_variation("flagkey", basic_context, LaunchDarkly::Migrations::STAGE_LIVE) expect(detail.value).to eq LaunchDarkly::Migrations::STAGE_OFF.to_s @@ -198,12 +196,12 @@ module LaunchDarkly end it "EvaluationDetail gets default if flag doesn't evaluate to stage" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("nonstage").variation_for_all(0)) detail = nil config_hook = MockHook.new(->(_, _) { }, ->(_, _, d) { detail = d }) - with_client(test_config(data_source: td, hooks: [config_hook])) do |client| + with_client(test_config(data_system_config: test_data_system(td), hooks: [config_hook])) do |client| client.migration_variation("flagkey", basic_context, LaunchDarkly::Migrations::STAGE_LIVE) expect(detail.value).to eq LaunchDarkly::Migrations::STAGE_LIVE.to_s @@ -213,12 +211,12 @@ module LaunchDarkly end it "EvaluationDetail default gets converted to off if invalid" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("nonstage").variation_for_all(0)) detail = nil config_hook = MockHook.new(->(_, _) { }, ->(_, _, d) { detail = d }) - with_client(test_config(data_source: td, hooks: [config_hook])) do |client| + with_client(test_config(data_system_config: test_data_system(td), hooks: [config_hook])) do |client| client.migration_variation("flagkey", basic_context, :invalid) expect(detail.value).to eq LaunchDarkly::Migrations::STAGE_OFF.to_s diff --git a/spec/ldclient_migration_variation_spec.rb b/spec/ldclient_migration_variation_spec.rb index b1dc2c6d..a0ca73d4 100644 --- a/spec/ldclient_migration_variation_spec.rb +++ b/spec/ldclient_migration_variation_spec.rb @@ -5,9 +5,9 @@ module LaunchDarkly describe "LDClient migration variation tests" do it "returns off if default stage is invalid" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| result, tracker = client.migration_variation("flagkey", basic_context, "invalid stage should default to off") expect(result).to eq(LaunchDarkly::Migrations::STAGE_OFF) @@ -16,9 +16,9 @@ module LaunchDarkly end it "returns error if flag isn't found" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| result, tracker = client.migration_variation("flagkey", basic_context, LaunchDarkly::Migrations::STAGE_LIVE) expect(result).to eq(LaunchDarkly::Migrations::STAGE_LIVE) @@ -27,10 +27,10 @@ module LaunchDarkly end it "flag doesn't return a valid stage" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").variation_for_all(0)) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| result, tracker = client.migration_variation("flagkey", basic_context, LaunchDarkly::Migrations::STAGE_LIVE) tracker.operation(LaunchDarkly::Migrations::OP_READ) tracker.invoked(LaunchDarkly::Migrations::ORIGIN_OLD) @@ -46,10 +46,10 @@ module LaunchDarkly end it "flag doesn't return a valid stage and default is invalid" do - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations("value").variation_for_all(0)) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| result, tracker = client.migration_variation("flagkey", basic_context, "invalid stage") tracker.operation(LaunchDarkly::Migrations::OP_READ) tracker.invoked(LaunchDarkly::Migrations::ORIGIN_OLD) @@ -66,10 +66,10 @@ module LaunchDarkly it "can determine correct stage from flag" do LaunchDarkly::Migrations::VALID_STAGES.each do |stage| - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source td.update(td.flag("flagkey").variations(stage).variation_for_all(0)) - with_client(test_config(data_source: td)) do |client| + with_client(test_config(data_system_config: test_data_system(td))) do |client| result, tracker = client.migration_variation("flagkey", basic_context, LaunchDarkly::Migrations::STAGE_LIVE) expect(result).to eq(stage) diff --git a/spec/ldclient_spec.rb b/spec/ldclient_spec.rb index dc4906cb..0a1a3a3b 100644 --- a/spec/ldclient_spec.rb +++ b/spec/ldclient_spec.rb @@ -90,11 +90,14 @@ module LaunchDarkly it "passes data set to feature store in correct order on init" do store = CapturingFeatureStore.new - td = Integrations::TestData.data_source + td = Integrations::TestDataV2.data_source dependency_ordering_test_data[LaunchDarkly::Interfaces::DataStore::FEATURES].each { |_, flag| td.use_preconfigured_flag(flag) } dependency_ordering_test_data[LaunchDarkly::Interfaces::DataStore::SEGMENTS].each { |_, segment| td.use_preconfigured_segment(segment) } - with_client(test_config(feature_store: store, data_source: td)) do |_| + data_system = test_data_system_builder(td) + .data_store(store, LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_WRITE) + .build + with_client(test_config(data_system_config: data_system)) do |_| data = store.received_data expect(data).not_to be_nil expect(data.count).to eq(2) diff --git a/spec/mock_components.rb b/spec/mock_components.rb index fc11d010..53acfd4e 100644 --- a/spec/mock_components.rb +++ b/spec/mock_components.rb @@ -2,15 +2,47 @@ require "ldclient-rb/impl/big_segments" require "ldclient-rb/impl/evaluator" -require "ldclient-rb/impl/data_source/null_processor" +require "ldclient-rb/data_system" +require "ldclient-rb/integrations/test_data_v2" require "ldclient-rb/interfaces" def sdk_key "sdk-key" end -def null_data - LaunchDarkly::Impl::DataSource::NullUpdateProcessor.new +def null_data_system + LaunchDarkly::DataSystem.custom.build +end + +def test_data_system(test_data) + test_data_system_builder(test_data).build +end + +def test_data_system_builder(test_data) + LaunchDarkly::DataSystem.custom.synchronizers([test_data.test_data_ds_builder]) +end + +# A data system that polls the FDv2 endpoint of a test server. +def polling_data_system_builder(base_uri) + LaunchDarkly::DataSystem.custom.synchronizers([LaunchDarkly::DataSystem.polling_ds_builder.base_uri(base_uri)]) +end + +# An FDv2 polling response body that transfers the given flags and segments in full. +def fdv2_poll_payload(flags: {}, segments: {}) + objects = flags.values.map { |f| { kind: "flag", item: f } } + segments.values.map { |s| { kind: "segment", item: s } } + events = [ + { event: "server-intent", data: { payloads: [{ id: "test-payload", target: 1, intentCode: "xfer-full", reason: "payload-missing" }] } }, + ] + objects.each do |o| + events << { event: "put-object", data: { kind: o[:kind], key: o[:item][:key], version: o[:item][:version], object: o[:item] } } + end + events << { event: "payload-transferred", data: { state: "(p:test-payload:1)", version: 1 } } + { events: events }.to_json +end + +# A data system that never receives data. The client stays uninitialized. +def silent_data_system_builder + LaunchDarkly::DataSystem.custom.synchronizers([LaunchDarkly::SilentSynchronizerBuilder.new]) end def null_logger @@ -19,7 +51,7 @@ def null_logger def base_config { - data_source: null_data, + data_system_config: null_data_system, send_events: false, logger: null_logger, } @@ -29,8 +61,8 @@ def test_config(add_props = {}) LaunchDarkly::Config.new(base_config.merge(add_props)) end -def with_client(config) - ensure_close(LaunchDarkly::LDClient.new(sdk_key, config)) do |client| +def with_client(config, wait_for_sec = 5) + ensure_close(LaunchDarkly::LDClient.new(sdk_key, config, wait_for_sec)) do |client| yield client end end @@ -71,11 +103,72 @@ def stopped? end end - class CapturingFeatureStore - attr_reader :received_data + # A synchronizer that sends no data. Its sync method returns when the data system stops it. + class SilentSynchronizer + def initialize + @stopped = Concurrent::Event.new + end + + def name + "silent" + end + + def sync(_selector_store) + @stopped.wait + end + + def stop + @stopped.set + end + end + + class SilentSynchronizerBuilder + def build(_sdk_key, _config) + SilentSynchronizer.new + end + end + + # A persistent store that keeps its data in a hash. + class MockFeatureStore + include Interfaces::FeatureStore + + def initialize + @data = {} + @initialized = false + end def init(all_data) - @received_data = all_data + @data = all_data.transform_values(&:dup) + @initialized = true + end + + def get(kind, key) + item = (@data[kind] || {})[key.to_sym] + item.nil? || item[:deleted] ? nil : item + end + + def all(kind) + (@data[kind] || {}).reject { |_, item| item[:deleted] } + end + + def upsert(kind, item) + items = (@data[kind] ||= {}) + old = items[item[:key].to_sym] + return false if !old.nil? && old[:version] >= item[:version] + + items[item[:key].to_sym] = item + true + end + + def delete(kind, key, version) + upsert(kind, { key: key, version: version, deleted: true }) + end + + def initialized? + @initialized + end + + def stop end def monitoring_enabled? @@ -85,8 +178,14 @@ def monitoring_enabled? def available? true end + end - def stop + class CapturingFeatureStore < MockFeatureStore + attr_reader :received_data + + def init(all_data) + @received_data = all_data + super end end From f4887c4980cd24638f9343c4abac0e76486dc890 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 5 Nov 2026 13:59:52 -0500 Subject: [PATCH 4/7] fix: Validate preconfigured TestDataV2 items and deliver changes in order use_preconfigured_flag and use_preconfigured_segment now convert the input to a new hash with symbol keys at every level, check it, and raise ArgumentError for an input that is not a valid flag or segment: nil or another type, a missing or empty key, a version that is not an integer, malformed JSON, or a data model error. Before, a hash parsed from JSON kept string keys below the top level and evaluated wrongly, and a malformed item was accepted and later made the whole initial data set fail to load while the data source reported VALID. Both methods also accept a JSON string, as v1 TestData did, and store a copy, so later changes to the caller's object have no effect. A preconfigured flag now keeps a provided version that is greater than the version of the flag it replaces, and otherwise gets the next version. This matches Go's TestDataV2 and the test data source spec, which requires the increment only when the provided version is not greater. Version 0 on a new flag becomes 1. update, use_preconfigured_flag and use_preconfigured_segment now hold a mutex across the change and its delivery to the data source instances. Before, two concurrent changes could reach an instance in the opposite order to the one in which they were made. --- lib/ldclient-rb/integrations/test_data_v2.rb | 162 ++++++++++++------- spec/integrations/test_data_v2_spec.rb | 123 ++++++++++++++ 2 files changed, 230 insertions(+), 55 deletions(-) diff --git a/lib/ldclient-rb/integrations/test_data_v2.rb b/lib/ldclient-rb/integrations/test_data_v2.rb index c519d40e..f248058b 100644 --- a/lib/ldclient-rb/integrations/test_data_v2.rb +++ b/lib/ldclient-rb/integrations/test_data_v2.rb @@ -1,7 +1,9 @@ require 'ldclient-rb/impl/integrations/test_data/test_data_source_v2' require 'ldclient-rb/impl/model/feature_flag' +require 'ldclient-rb/impl/model/serialization' require 'ldclient-rb/integrations/test_data_v2/flag_builder_v2' require 'concurrent/atomics' +require 'json' module LaunchDarkly module Integrations @@ -56,6 +58,9 @@ def initialize @current_flags = Hash.new @current_segments = Hash.new @lock = Concurrent::ReadWriteLock.new + # Serializes each change with its delivery, so that every instance receives changes in the order + # that they are made. + @update_lock = Mutex.new @instances = Array.new @version = 0 end @@ -108,24 +113,26 @@ def flag(key) # @return [TestDataV2] the TestDataV2 instance # def update(flag_builder) - instances_copy = [] - new_flag = nil - @lock.with_write_lock do - flag_key = flag_builder._key.to_sym - old_flag = @current_flags[flag_key] - old_version = old_flag ? old_flag[:version] : 0 + @update_lock.synchronize do + instances_copy = [] + new_flag = nil + @lock.with_write_lock do + flag_key = flag_builder._key.to_sym + old_flag = @current_flags[flag_key] + old_version = old_flag ? old_flag[:version] : 0 - new_flag = flag_builder.build(old_version + 1) + new_flag = flag_builder.build(old_version + 1) - @current_flags[flag_key] = new_flag - @flag_builders[flag_builder._key] = flag_builder.clone + @current_flags[flag_key] = new_flag + @flag_builders[flag_builder._key] = flag_builder.clone - # Create a copy of instances while holding the lock to avoid race conditions - instances_copy = @instances.dup - end + # 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) + instances_copy.each do |instance| + instance.upsert_flag(new_flag) + end end self @@ -181,33 +188,37 @@ def add_instance(instance) # You cannot make incremental changes with {#flag}/{#update} to a flag that you add in this way. # You can only replace it with an entirely new flag configuration. # - # A new flag keeps its own version, or gets version 1 if it has none. A flag that replaces an existing - # flag gets a version one more than the version of the existing flag. + # The flag keeps its own version if that version is greater than the version of any flag that it + # replaces. Otherwise, it gets a version one more than the version of the flag that it replaces, or + # version 1 if it is a new flag. # - # @param flag [Hash, LaunchDarkly::Impl::Model::FeatureFlag] the flag configuration as a hash or - # a FeatureFlag model object. + # The method stores a copy of the flag, so later changes to the object that you pass in have no effect. + # + # @param flag [Hash, String, LaunchDarkly::Impl::Model::FeatureFlag] the flag configuration as a hash + # with symbol or string keys, a JSON string, or a FeatureFlag model object. # @return [TestDataV2] the TestDataV2 instance + # @raise [ArgumentError] if the flag is not a valid flag configuration # def use_preconfigured_flag(flag) - instances_copy = [] - updated_flag = nil - - @lock.with_write_lock do - flag_hash = (flag.is_a?(Hash) ? flag : flag.as_json).transform_keys(&:to_sym) - flag_key = flag_hash[:key].to_sym + flag_data = normalize_preconfigured_item(LaunchDarkly::Interfaces::DataStore::FEATURES, flag, "flag") - updated_flag = flag_hash.dup - old_flag = @current_flags[flag_key] - updated_flag[:version] = old_flag.nil? ? (updated_flag[:version] || 1) : old_flag[:version] + 1 + @update_lock.synchronize do + instances_copy = [] + @lock.with_write_lock do + flag_key = flag_data[:key].to_sym + old_flag = @current_flags[flag_key] + next_version = (old_flag.nil? ? 0 : old_flag[:version]) + 1 + flag_data[:version] = [flag_data[:version] || 0, next_version].max - @current_flags[flag_key] = updated_flag - @flag_builders.delete(flag_hash[:key]) + @current_flags[flag_key] = flag_data + @flag_builders.delete(flag_data[:key]) - instances_copy = @instances.dup - end + instances_copy = @instances.dup + end - instances_copy.each do |instance| - instance.upsert_flag(updated_flag) + instances_copy.each do |instance| + instance.upsert_flag(flag_data) + end end self @@ -226,41 +237,82 @@ def use_preconfigured_flag(flag) # since application tests that need to produce a desired evaluation state could do so more easily # by just setting flag values. # - # @param segment [Hash, LaunchDarkly::Impl::Model::Segment] the segment configuration as a hash or - # a Segment model object. + # The segment gets a version one more than the version of the segment that it replaces, or version 1 + # if it is a new segment. The method stores a copy of the segment, so later changes to the object that + # you pass in have no effect. + # + # @param segment [Hash, String, LaunchDarkly::Impl::Model::Segment] the segment configuration as a hash + # with symbol or string keys, a JSON string, or a Segment model object. # @return [TestDataV2] the TestDataV2 instance + # @raise [ArgumentError] if the segment is not a valid segment configuration # def use_preconfigured_segment(segment) - instances_copy = [] - segment_key = nil - updated_segment = nil + segment_data = normalize_preconfigured_item(LaunchDarkly::Interfaces::DataStore::SEGMENTS, segment, "segment") - @lock.with_write_lock do - # Convert to hash and normalize keys to symbols - segment_hash = if segment.is_a?(Hash) - segment.transform_keys(&:to_sym) - else - segment.as_json + @update_lock.synchronize do + instances_copy = [] + @lock.with_write_lock do + segment_key = segment_data[:key].to_sym + old_segment = @current_segments[segment_key] + segment_data[:version] = (old_segment ? old_segment[:version] : 0) + 1 + + @current_segments[segment_key] = segment_data + + # 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(segment_data) end - segment_key = segment_hash[:key].to_sym + end + + self + end - old_segment = @current_segments[segment_key] - old_version = old_segment ? old_segment[:version] : 0 + # + # Converts a preconfigured flag or segment to a new hash with symbol keys at every level, and checks + # that it is a valid data model object. + # + # @param kind [LaunchDarkly::Interfaces::DataStore::DataKind] the kind of the item + # @param item [Hash, String, Object] the item as a hash, a JSON string, or a model object + # @param description [String] the name of the kind, for error messages + # @return [Hash] the normalized item + # @raise [ArgumentError] if the item is not valid + # + private def normalize_preconfigured_item(kind, item, description) + data = + if item.is_a?(String) + JSON.parse(item, symbolize_names: true) + elsif item.is_a?(Hash) + JSON.parse(JSON.generate(item), symbolize_names: true) + elsif item.respond_to?(:as_json) + JSON.parse(JSON.generate(item.as_json), symbolize_names: true) + else + raise ArgumentError, "a preconfigured #{description} must be a Hash, a JSON string, or a model object" + end - updated_segment = segment_hash.dup - updated_segment[:version] = old_version + 1 + raise ArgumentError, "a preconfigured #{description} must be a JSON object" unless data.is_a?(Hash) - @current_segments[segment_key] = updated_segment + key = data[:key] + unless key.is_a?(String) && !key.empty? + raise ArgumentError, "a preconfigured #{description} must have a non-empty string key" + end - # Create a copy of instances while holding the lock to avoid race conditions - instances_copy = @instances.dup + version = data[:version] + unless version.nil? || version.is_a?(Integer) + raise ArgumentError, "the version of preconfigured #{description} '#{key}' must be an integer" end - instances_copy.each do |instance| - instance.upsert_segment(updated_segment) + begin + LaunchDarkly::Impl::Model.deserialize(kind, JSON.parse(JSON.generate(data), symbolize_names: true)) + rescue => e + raise ArgumentError, "preconfigured #{description} '#{key}' is not valid: #{e.message}" end - self + data + rescue JSON::ParserError => e + raise ArgumentError, "a preconfigured #{description} is not valid JSON: #{e.message}" end # diff --git a/spec/integrations/test_data_v2_spec.rb b/spec/integrations/test_data_v2_spec.rb index 41657eb0..e27704b3 100644 --- a/spec/integrations/test_data_v2_spec.rb +++ b/spec/integrations/test_data_v2_spec.rb @@ -1,6 +1,7 @@ require "ldclient-rb/integrations/test_data_v2" require "ldclient-rb/impl/integrations/test_data/test_data_source_v2" require "spec_helper" +require "json" module LaunchDarkly module Integrations @@ -96,6 +97,128 @@ module Integrations td.use_preconfigured_flag({ key: 'myflag', version: 7, variations: ['a'] }) end + it 'keeps a provided version that is greater than the version of the flag it replaces' do + td = TestDataV2.data_source + td.use_preconfigured_flag({ key: 'myflag', version: 5, variations: ['a'] }) + td.use_preconfigured_flag({ key: 'myflag', version: 100, variations: ['b'] }) + expect(td.make_init_data[:flags][:myflag][:version]).to eq(100) + end + + it 'gives a new preconfigured flag with version 0 version 1' do + td = TestDataV2.data_source + td.use_preconfigured_flag({ key: 'myflag', version: 0, variations: ['a'] }) + expect(td.make_init_data[:flags][:myflag][:version]).to eq(1) + end + + it 'converts string keys at every level of a preconfigured flag to symbols' do + td = TestDataV2.data_source + flag_json = '{"key":"myflag","version":3,"on":true,"fallthrough":{"variation":0},"variations":["a"],' \ + '"rules":[{"id":"r","variation":0,"clauses":[{"attribute":"key","op":"in","values":["u"]}]}]}' + td.use_preconfigured_flag(JSON.parse(flag_json)) + flag = td.make_init_data[:flags][:myflag] + expect(flag[:fallthrough]).to eq({ variation: 0 }) + expect(flag[:rules][0][:clauses][0][:attribute]).to eq('key') + end + + it 'accepts a preconfigured flag as a JSON string' do + td = TestDataV2.data_source + td.use_preconfigured_flag('{"key":"myflag","version":3,"variations":["a"]}') + expect(td.make_init_data[:flags][:myflag][:version]).to eq(3) + end + + it 'stores a copy of a preconfigured flag' do + td = TestDataV2.data_source + flag = { key: 'myflag', version: 1, fallthrough: { variation: 0 }, variations: ['a'] } + td.use_preconfigured_flag(flag) + flag[:fallthrough][:variation] = 1 + flag[:variations] << 'b' + stored = td.make_init_data[:flags][:myflag] + expect(stored[:fallthrough]).to eq({ variation: 0 }) + expect(stored[:variations]).to eq(['a']) + end + + describe 'rejects an invalid preconfigured flag' do + { + 'nil' => nil, + 'a value that is not a hash' => 42, + 'a hash without a key' => { version: 1 }, + 'an empty key' => { key: '' }, + 'a version that is not an integer' => { key: 'myflag', version: '100' }, + 'malformed JSON' => '{not json', + 'a JSON value that is not an object' => '[1]', + 'a malformed prerequisite' => { key: 'myflag', version: 1, prerequisites: ['oops'] }, + }.each do |name, flag| + it "with #{name}" do + td = TestDataV2.data_source + td.use_preconfigured_flag({ key: 'other', version: 1, variations: ['a'] }) + expect { td.use_preconfigured_flag(flag) }.to raise_error(ArgumentError) + expect(td.make_init_data[:flags].keys).to eq([:other]) + end + end + end + + describe 'rejects an invalid preconfigured segment' do + { + 'nil' => nil, + 'a hash without a key' => { version: 1 }, + 'malformed JSON' => '{not json', + 'a malformed rule' => { key: 'myseg', rules: ['oops'] }, + }.each do |name, segment| + it "with #{name}" do + td = TestDataV2.data_source + expect { td.use_preconfigured_segment(segment) }.to raise_error(ArgumentError) + expect(td.make_init_data[:segments]).to eq({}) + end + end + end + + it 'converts string keys at every level of a preconfigured segment to symbols' do + td = TestDataV2.data_source + td.use_preconfigured_segment(JSON.parse('{"key":"myseg","rules":[{"id":"r","clauses":[{"attribute":"key","op":"in","values":["u"]}]}]}')) + expect(td.make_init_data[:segments][:myseg][:rules][0][:clauses][0][:op]).to eq('in') + end + + describe 'change delivery order' do + # A data source instance that blocks inside the first delivery until the spec releases it. + let(:delivered) { Queue.new } + let(:release) { Queue.new } + let(:instance) do + delivered_versions = delivered + release_first = release + calls = 0 + inst = Object.new + inst.define_singleton_method(:upsert_flag) do |flag| + calls += 1 + release_first.pop if calls == 1 + delivered_versions << flag[:version] + end + inst + end + + def expect_delivery_in_order + td = TestDataV2.data_source + td.add_instance(instance) + first = Thread.new { yield td, 1 } + sleep 0.05 until first.status == 'sleep' + second = Thread.new { yield td, 2 } + + # The second change waits for the first change to finish its delivery. + expect(second.join(0.2)).to be_nil + release << true + [first, second].each { |t| t.join(5) } + + expect([delivered.pop(timeout: 1), delivered.pop(timeout: 1)]).to eq([1, 2]) + end + + it 'delivers flag updates in the order that they are made' do + expect_delivery_in_order { |td, _| td.update(td.flag('myflag').variation_for_all(true)) } + end + + it 'delivers preconfigured flags in the order that they are made' do + expect_delivery_in_order { |td, n| td.use_preconfigured_flag({ key: 'myflag', version: n, variations: ['a'] }) } + end + end + describe 'FlagBuilderV2 event and migration settings' do it 'omits the settings by default' do flag = TestDataV2.data_source.flag('myflag').build(1) From aadae60618cec415acde0c489a16deb3ae99c0ea Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 5 Nov 2026 14:24:27 -0500 Subject: [PATCH 5/7] fix: Reject preconfigured TestDataV2 values that are not JSON values The previous commit normalized a preconfigured flag or segment with a JSON round trip. JSON.generate calls to_s on a value it cannot encode, so a Set, a Time or another object became a string with no error. A segment whose included keys were a Set then failed to match. NaN, Infinity, and a hash with the same key as a string and a symbol raised JSON::GeneratorError in place of ArgumentError. The item is now copied value by value. A Set becomes an array and a symbol value becomes a string, as in JSON. Any other value that JSON cannot hold, a value that is not finite, a hash key that is not a string or symbol, a repeated key, and nesting deeper than 100 levels raise ArgumentError. The delivery order spec now has a deadline for the first thread and always releases it, so a failure cannot hang the suite. --- lib/ldclient-rb/integrations/test_data_v2.rb | 69 +++++++++++++++++--- spec/integrations/test_data_v2_spec.rb | 54 +++++++++++++-- 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/lib/ldclient-rb/integrations/test_data_v2.rb b/lib/ldclient-rb/integrations/test_data_v2.rb index f248058b..eabd4778 100644 --- a/lib/ldclient-rb/integrations/test_data_v2.rb +++ b/lib/ldclient-rb/integrations/test_data_v2.rb @@ -4,6 +4,7 @@ require 'ldclient-rb/integrations/test_data_v2/flag_builder_v2' require 'concurrent/atomics' require 'json' +require 'set' module LaunchDarkly module Integrations @@ -270,6 +271,10 @@ def use_preconfigured_segment(segment) self end + # The maximum depth of nested hashes and arrays in a preconfigured item. + MAX_PRECONFIGURED_DEPTH = 100 + private_constant :MAX_PRECONFIGURED_DEPTH + # # Converts a preconfigured flag or segment to a new hash with symbol keys at every level, and checks # that it is a valid data model object. @@ -281,18 +286,24 @@ def use_preconfigured_segment(segment) # @raise [ArgumentError] if the item is not valid # private def normalize_preconfigured_item(kind, item, description) - data = + raw = if item.is_a?(String) - JSON.parse(item, symbolize_names: true) + begin + JSON.parse(item) + rescue JSON::ParserError => e + raise ArgumentError, "a preconfigured #{description} is not valid JSON: #{e.message}" + end elsif item.is_a?(Hash) - JSON.parse(JSON.generate(item), symbolize_names: true) + item elsif item.respond_to?(:as_json) - JSON.parse(JSON.generate(item.as_json), symbolize_names: true) + item.as_json else raise ArgumentError, "a preconfigured #{description} must be a Hash, a JSON string, or a model object" end - raise ArgumentError, "a preconfigured #{description} must be a JSON object" unless data.is_a?(Hash) + raise ArgumentError, "a preconfigured #{description} must be a JSON object" unless raw.is_a?(Hash) + + data = normalize_json_value(raw, description, "", 0) key = data[:key] unless key.is_a?(String) && !key.empty? @@ -305,14 +316,56 @@ def use_preconfigured_segment(segment) end begin - LaunchDarkly::Impl::Model.deserialize(kind, JSON.parse(JSON.generate(data), symbolize_names: true)) + LaunchDarkly::Impl::Model.deserialize(kind, data) rescue => e raise ArgumentError, "preconfigured #{description} '#{key}' is not valid: #{e.message}" end data - rescue JSON::ParserError => e - raise ArgumentError, "a preconfigured #{description} is not valid JSON: #{e.message}" + end + + # + # Returns a copy of a JSON-compatible value with symbol hash keys at every level. A Set becomes an + # array and a symbol value becomes a string, as they would in JSON. + # + # @param value [Object] the value to convert + # @param description [String] the name of the item kind, for error messages + # @param path [String] the location of the value in the item, for error messages + # @param depth [Integer] the nesting depth of the value + # @return [Object] the converted value + # @raise [ArgumentError] if the value is not a JSON-compatible value + # + private def normalize_json_value(value, description, path, depth) + if depth > MAX_PRECONFIGURED_DEPTH + raise ArgumentError, "a preconfigured #{description} is nested more than #{MAX_PRECONFIGURED_DEPTH} levels deep" + end + + case value + when Hash + value.each_with_object({}) do |(k, v), out| + unless k.is_a?(String) || k.is_a?(Symbol) + raise ArgumentError, "a preconfigured #{description} has the key #{k.inspect} at '#{path}', which is not a string or symbol" + end + sym = k.to_sym + if out.key?(sym) + raise ArgumentError, "a preconfigured #{description} has the key '#{k}' more than once at '#{path}'" + end + out[sym] = normalize_json_value(v, description, "#{path}/#{k}", depth + 1) + end + when Array, Set + value.each_with_index.map { |v, i| normalize_json_value(v, description, "#{path}/#{i}", depth + 1) } + when String + value.dup + when Symbol + value.to_s + when Integer, true, false, nil + value + when Float + raise ArgumentError, "a preconfigured #{description} has a value at '#{path}' that is not a finite number" unless value.finite? + value + else + raise ArgumentError, "a preconfigured #{description} has a #{value.class} at '#{path}', which is not a JSON value" + end end # diff --git a/spec/integrations/test_data_v2_spec.rb b/spec/integrations/test_data_v2_spec.rb index e27704b3..42070cff 100644 --- a/spec/integrations/test_data_v2_spec.rb +++ b/spec/integrations/test_data_v2_spec.rb @@ -2,6 +2,7 @@ require "ldclient-rb/impl/integrations/test_data/test_data_source_v2" require "spec_helper" require "json" +require "set" module LaunchDarkly module Integrations @@ -147,6 +148,11 @@ module Integrations 'malformed JSON' => '{not json', 'a JSON value that is not an object' => '[1]', 'a malformed prerequisite' => { key: 'myflag', version: 1, prerequisites: ['oops'] }, + 'a value that is not a JSON value' => { key: 'myflag', variations: [Time.at(0)] }, + 'a NaN value' => { key: 'myflag', variations: [Float::NAN] }, + 'an infinite value' => { key: 'myflag', variations: [Float::INFINITY] }, + 'the same key as a string and a symbol' => { key: 'myflag', 'key' => 'other' }, + 'a hash key that is not a string or symbol' => { key: 'myflag', variations: [{ 1 => 'a' }] }, }.each do |name, flag| it "with #{name}" do td = TestDataV2.data_source @@ -172,6 +178,34 @@ module Integrations end end + it 'rejects a preconfigured flag that is nested too deeply' do + deep = 'a' + 150.times { deep = [deep] } + td = TestDataV2.data_source + expect { td.use_preconfigured_flag({ key: 'myflag', variations: [deep] }) }.to raise_error(ArgumentError) + end + + it 'rejects a preconfigured flag that contains itself' do + flag = { key: 'myflag', variations: [] } + flag[:variations] << flag + td = TestDataV2.data_source + expect { td.use_preconfigured_flag(flag) }.to raise_error(ArgumentError) + end + + it 'stores Set values of a preconfigured segment as arrays' do + td = TestDataV2.data_source + td.use_preconfigured_segment({ key: 'myseg', included: Set['u1', 'u2'] }) + expect(td.make_init_data[:segments][:myseg][:included]).to eq(%w[u1 u2]) + end + + it 'stores symbol values of a preconfigured flag as strings' do + td = TestDataV2.data_source + td.use_preconfigured_flag({ key: 'myflag', variations: [:a], rules: [{ id: 'r', variation: 0, clauses: [{ attribute: 'key', op: :in, values: ['u'] }] }] }) + flag = td.make_init_data[:flags][:myflag] + expect(flag[:variations]).to eq(['a']) + expect(flag[:rules][0][:clauses][0][:op]).to eq('in') + end + it 'converts string keys at every level of a preconfigured segment to symbols' do td = TestDataV2.data_source td.use_preconfigured_segment(JSON.parse('{"key":"myseg","rules":[{"id":"r","clauses":[{"attribute":"key","op":"in","values":["u"]}]}]}')) @@ -199,13 +233,19 @@ def expect_delivery_in_order td = TestDataV2.data_source td.add_instance(instance) first = Thread.new { yield td, 1 } - sleep 0.05 until first.status == 'sleep' - second = Thread.new { yield td, 2 } - - # The second change waits for the first change to finish its delivery. - expect(second.join(0.2)).to be_nil - release << true - [first, second].each { |t| t.join(5) } + second = nil + begin + deadline = Time.now + 5 + sleep 0.01 until first.status == 'sleep' || !first.alive? || Time.now > deadline + expect(first.status).to eq('sleep') + second = Thread.new { yield td, 2 } + + # The second change waits for the first change to finish its delivery. + expect(second.join(0.2)).to be_nil + ensure + release << true + [first, second].compact.each { |t| t.join(5) } + end expect([delivered.pop(timeout: 1), delivered.pop(timeout: 1)]).to eq([1, 2]) end From c0f0460fd2f323a44122b6e7b9bcd59a4eb9c0b3 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 5 Nov 2026 15:42:23 -0500 Subject: [PATCH 6/7] fix: Keep TestDataV2 flags unchanged when a rule builder changes after update FlagRuleBuilderV2#build put the builder's own clause array into the built flag. A rule builder that the test kept after update could then add clauses to the stored flag. This changed what later clients received without a version change or a delivered update. The build now copies each clause and its values. --- .../integrations/test_data_v2/flag_builder_v2.rb | 2 +- spec/integrations/test_data_v2_spec.rb | 11 +++++++++++ 2 files changed, 12 insertions(+), 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 c11f8782..f0bfeeff 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 @@ -650,7 +650,7 @@ def build(id) { id: 'rule' + id, variation: @_variation, - clauses: @_clauses, + clauses: @_clauses.map { |clause| clause.merge(values: clause[:values].dup) }, } end end diff --git a/spec/integrations/test_data_v2_spec.rb b/spec/integrations/test_data_v2_spec.rb index 42070cff..fdfaf0f2 100644 --- a/spec/integrations/test_data_v2_spec.rb +++ b/spec/integrations/test_data_v2_spec.rb @@ -138,6 +138,17 @@ module Integrations expect(stored[:variations]).to eq(['a']) end + it 'does not change a stored flag when its rule builder changes later' do + td = TestDataV2.data_source + builder = td.flag('myflag') + rule = builder.if_match('name', 'alice') + rule.then_return(true) + td.update(builder) + rule.and_match('country', 'us') + clauses = td.make_init_data[:flags][:myflag][:rules][0][:clauses] + expect(clauses.map { |clause| clause[:attribute] }).to eq(['name']) + end + describe 'rejects an invalid preconfigured flag' do { 'nil' => nil, From c638040abffe6ea985683f4ead669d8b9131f862 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Fri, 6 Nov 2026 12:05:48 -0500 Subject: [PATCH 7/7] fix: Prevent TestDataV2 from briefly restoring an older flag when a client starts (#491) --- .../test_data/test_data_source_v2.rb | 116 +++++++++--------- lib/ldclient-rb/integrations/test_data_v2.rb | 10 +- spec/integrations/test_data_v2_spec.rb | 33 ++++- 3 files changed, 102 insertions(+), 57 deletions(-) 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 b0b91071..990ef572 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 @@ -28,9 +28,6 @@ def initialize(test_data) @closed = false @update_queue = Queue.new @lock = Mutex.new - - # Always register for change notifications - @test_data.add_instance(self) end # @@ -53,68 +50,25 @@ def name # @return [LaunchDarkly::Interfaces::DataSystem::FetchResult] # def fetch(selector_store) - result = - begin - @lock.synchronize do - if @closed - next LaunchDarkly::Result.fail('TestDataV2 source has been closed') - end - - # Get all current flags and segments from test data - init_data = @test_data.make_init_data - version = @test_data.get_version - - # Build a full transfer changeset - builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new - builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) - - # Add all flags to the changeset - init_data[:flags].each do |key, flag_data| - builder.add_put( - LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, - key, - flag_data[:version] || 1, - flag_data - ) - end - - # Add all segments to the changeset - init_data[:segments].each do |key, segment_data| - builder.add_put( - LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT, - key, - segment_data[:version] || 1, - segment_data - ) - end - - # Create selector for this version - selector = LaunchDarkly::Interfaces::DataSystem::Selector.new_selector(version.to_s, version) - change_set = builder.finish(selector) - - basis = LaunchDarkly::Interfaces::DataSystem::Basis.new(change_set: change_set, persist: false, environment_id: nil) - - LaunchDarkly::Result.success(basis) - end - rescue => e - LaunchDarkly::Result.fail("Error fetching test data: #{e.message}", e) - end - - LaunchDarkly::Interfaces::DataSystem::FetchResult.new(result: result, fallback_to_fdv1: false) + fetch_result do + init_data = @test_data.make_init_data + [init_data, @test_data.get_version] + end end # # Implementation of the Synchronizer.sync method. # - # Yields updates as test data changes occur. + # Yields the current test data, and then an update for each later change. # # @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_fetch = fetch(selector_store) + # Subscribe and take the initial data in one step. Then the queue holds only the changes + # that the initial data does not hold. + initial_fetch = fetch_result { @test_data.subscribe(self) } unless initial_fetch.success? yield LaunchDarkly::Interfaces::DataSystem::Update.new( state: LaunchDarkly::Interfaces::DataSource::Status::OFF, @@ -287,9 +241,61 @@ def upsert_segment(segment_data) end end end + + # + # Builds a full transfer result from the data and the version that the block returns. + # The block runs while this instance holds its lock. + # + private def fetch_result + result = + begin + @lock.synchronize do + if @closed + next LaunchDarkly::Result.fail('TestDataV2 source has been closed') + end + + init_data, version = yield + + # Build a full transfer changeset + builder = LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.new + builder.start(LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_FULL) + + # Add all flags to the changeset + init_data[:flags].each do |key, flag_data| + builder.add_put( + LaunchDarkly::Interfaces::DataSystem::ObjectKind::FLAG, + key, + flag_data[:version] || 1, + flag_data + ) + end + + # Add all segments to the changeset + init_data[:segments].each do |key, segment_data| + builder.add_put( + LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT, + key, + segment_data[:version] || 1, + segment_data + ) + end + + # Create selector for this version + selector = LaunchDarkly::Interfaces::DataSystem::Selector.new_selector(version.to_s, version) + change_set = builder.finish(selector) + + basis = LaunchDarkly::Interfaces::DataSystem::Basis.new(change_set: change_set, persist: false, environment_id: nil) + + LaunchDarkly::Result.success(basis) + end + rescue => e + LaunchDarkly::Result.fail("Error fetching test data: #{e.message}", e) + end + + LaunchDarkly::Interfaces::DataSystem::FetchResult.new(result: result, fallback_to_fdv1: false) + 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 eabd4778..e742d3d5 100644 --- a/lib/ldclient-rb/integrations/test_data_v2.rb +++ b/lib/ldclient-rb/integrations/test_data_v2.rb @@ -167,10 +167,18 @@ def closed_instance(instance) end # @api private + # + # Adds an instance and takes the current data in one step. The instance then receives each + # change that the returned data does not hold, and no change that it holds. + # # @param instance [LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2] the TestDataSourceV2 instance to add - def add_instance(instance) + # @return [Array(Hash, Integer)] the data, in the form of {#make_init_data}, and the next version + def subscribe(instance) @lock.with_write_lock do @instances.push(instance) + version = @version + @version += 1 + [{ flags: @current_flags.dup, segments: @current_segments.dup }, version] end end diff --git a/spec/integrations/test_data_v2_spec.rb b/spec/integrations/test_data_v2_spec.rb index fdfaf0f2..cb76d424 100644 --- a/spec/integrations/test_data_v2_spec.rb +++ b/spec/integrations/test_data_v2_spec.rb @@ -94,6 +94,7 @@ module Integrations it 'sends a preconfigured flag to a running data source' do td = TestDataV2.data_source source = LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2.new(td) + td.subscribe(source) expect(source).to receive(:upsert_flag).with(hash_including(key: 'myflag', version: 7)) td.use_preconfigured_flag({ key: 'myflag', version: 7, variations: ['a'] }) end @@ -242,7 +243,7 @@ module Integrations def expect_delivery_in_order td = TestDataV2.data_source - td.add_instance(instance) + td.subscribe(instance) first = Thread.new { yield td, 1 } second = nil begin @@ -308,6 +309,36 @@ def expect_delivery_in_order end describe 'TestDataSourceV2' do + it 'does not deliver a change from before sync starts after the initial data' do + td = TestDataV2.data_source + source = LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2.new(td) + td.update(td.flag('myflag').variation_for_all(true)) + td.update(td.flag('myflag').variation_for_all(false)) + + updates = Queue.new + sync_thread = Thread.new { source.sync(nil) { |update| updates << update } } + begin + initial = updates.pop(timeout: 1) + td.update(td.flag('myflag').variation_for_all(true)) + following = updates.pop(timeout: 1) + + expect(initial.change_set.changes.map(&:version)).to eq([2]) + expect(following.change_set.changes.map(&:version)).to eq([3]) + ensure + source.stop + sync_thread.join(1) + end + end + + it 'does not receive changes after it is used as an initializer' do + td = TestDataV2.data_source + source = LaunchDarkly::Impl::Integrations::TestData::TestDataSourceV2.new(td) + source.fetch(nil) + + expect(source).not_to receive(:upsert_flag) + td.update(td.flag('myflag').variation_for_all(true)) + end + it 'includes both flags and segments in fetch' do td = TestDataV2.data_source td.update(td.flag('myflag').variation_for_all(true))