From 0627ceb34ecf26f945c10cd519b86ca4c709c30f Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 5 Nov 2026 09:32:04 -0500 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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)) From 32c491d90e95705b44dc03eb01dfbd2926bac3a6 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 10 Nov 2026 11:24:28 -0500 Subject: [PATCH 08/11] feat!: Remove the FDv1 data system (#487) fix: Enforce the 30 second minimum poll interval in the polling builders --- .github/actions/check/action.yml | 9 - Makefile | 4 +- contract-tests/client_entity.rb | 20 +- contract-tests/service.rb | 4 - lib/ldclient-rb/config.rb | 211 +----- .../polling_data_source_builder.rb | 26 +- lib/ldclient-rb/impl/cache_store.rb | 44 -- lib/ldclient-rb/impl/data_source.rb | 228 ------ .../impl/data_source/null_processor.rb | 52 -- lib/ldclient-rb/impl/data_source/polling.rb | 122 ---- lib/ldclient-rb/impl/data_source/requestor.rb | 121 ---- .../impl/data_source/status_provider.rb | 7 +- lib/ldclient-rb/impl/data_source/stream.rb | 239 ------- .../data_store/in_memory_feature_store.rb | 1 - lib/ldclient-rb/impl/data_store/status.rb | 59 -- .../impl/data_store/status_provider.rb | 6 +- lib/ldclient-rb/impl/data_store/store.rb | 1 - lib/ldclient-rb/impl/data_system/fdv1.rb | 183 ----- lib/ldclient-rb/impl/data_system/fdv2.rb | 4 - lib/ldclient-rb/impl/data_system/polling.rb | 21 +- lib/ldclient-rb/impl/diagnostic_events.rb | 7 +- .../impl/integrations/file_data_source.rb | 230 ------ .../test_data/test_data_source.rb | 39 -- lib/ldclient-rb/impl/retry_state.rb | 240 ------- lib/ldclient-rb/impl/store_client_wrapper.rb | 129 ---- lib/ldclient-rb/impl/util.rb | 36 - lib/ldclient-rb/in_memory_store.rb | 87 --- lib/ldclient-rb/integrations.rb | 2 +- lib/ldclient-rb/integrations/consul.rb | 4 +- lib/ldclient-rb/integrations/dynamodb.rb | 6 +- lib/ldclient-rb/integrations/file_data.rb | 35 +- lib/ldclient-rb/integrations/redis.rb | 6 +- lib/ldclient-rb/integrations/test_data.rb | 213 ------ .../integrations/test_data/flag_builder.rb | 663 ------------------ lib/ldclient-rb/interfaces/data_source.rb | 109 +-- lib/ldclient-rb/interfaces/data_store.rb | 15 - lib/ldclient-rb/interfaces/feature_store.rb | 3 +- lib/ldclient-rb/ldclient.rb | 60 +- spec/config_spec.rb | 34 - .../polling_data_source_builder_spec.rb | 41 ++ spec/diagnostic_events_spec.rb | 10 - spec/impl/cache_store_spec.rb | 14 - spec/impl/data_source/null_processor_spec.rb | 57 -- spec/impl/data_source/polling_spec.rb | 361 ---------- spec/impl/data_source/requestor_spec.rb | 258 ------- spec/impl/data_source/status_provider_spec.rb | 43 ++ spec/impl/data_source/stream_spec.rb | 592 ---------------- spec/impl/data_source_spec.rb | 388 ---------- .../feature_store_client_wrapper_spec.rb | 86 +++ .../in_memory_feature_store_v2_spec.rb | 1 - spec/impl/data_store/store_spec.rb | 1 - spec/impl/data_system/fdv1_spec.rb | 306 -------- spec/impl/data_system/fdv2_datasystem_spec.rb | 94 ++- .../impl/data_system/fdv2_persistence_spec.rb | 1 - .../polling_http_requester_spec.rb | 289 ++++++++ .../data_system/polling_initializer_spec.rb | 10 +- .../data_system/polling_synchronizer_spec.rb | 57 +- .../data_system/streaming_headers_spec.rb | 109 +-- .../streaming_synchronizer_spec.rb | 135 ++++ spec/impl/retry_state_spec.rb | 224 ------ spec/impl/store_client_wrapper_spec.rb | 138 ---- spec/impl/util_spec.rb | 47 -- spec/in_memory_feature_store_spec.rb | 26 - spec/integrations/file_data_source_spec.rb | 352 ---------- spec/integrations/file_data_source_v2_spec.rb | 207 ++++++ spec/integrations/test_data_spec.rb | 243 ------- .../test_data_v2/flag_builder_v2_spec.rb | 373 ++++++++++ spec/ldclient_fork_spec.rb | 47 +- spec/ldclient_spec.rb | 35 +- 69 files changed, 1525 insertions(+), 6300 deletions(-) delete mode 100644 lib/ldclient-rb/impl/cache_store.rb delete mode 100644 lib/ldclient-rb/impl/data_source.rb delete mode 100644 lib/ldclient-rb/impl/data_source/null_processor.rb delete mode 100644 lib/ldclient-rb/impl/data_source/polling.rb delete mode 100644 lib/ldclient-rb/impl/data_source/requestor.rb delete mode 100644 lib/ldclient-rb/impl/data_source/stream.rb delete mode 100644 lib/ldclient-rb/impl/data_store/status.rb delete mode 100644 lib/ldclient-rb/impl/data_system/fdv1.rb delete mode 100644 lib/ldclient-rb/impl/integrations/file_data_source.rb delete mode 100644 lib/ldclient-rb/impl/integrations/test_data/test_data_source.rb delete mode 100644 lib/ldclient-rb/impl/retry_state.rb delete mode 100644 lib/ldclient-rb/impl/store_client_wrapper.rb delete mode 100644 lib/ldclient-rb/in_memory_store.rb delete mode 100644 lib/ldclient-rb/integrations/test_data.rb delete mode 100644 lib/ldclient-rb/integrations/test_data/flag_builder.rb create mode 100644 spec/data_system/polling_data_source_builder_spec.rb delete mode 100644 spec/impl/cache_store_spec.rb delete mode 100644 spec/impl/data_source/null_processor_spec.rb delete mode 100644 spec/impl/data_source/polling_spec.rb delete mode 100644 spec/impl/data_source/requestor_spec.rb delete mode 100644 spec/impl/data_source/stream_spec.rb delete mode 100644 spec/impl/data_source_spec.rb delete mode 100644 spec/impl/data_system/fdv1_spec.rb create mode 100644 spec/impl/data_system/polling_http_requester_spec.rb delete mode 100644 spec/impl/retry_state_spec.rb delete mode 100644 spec/impl/store_client_wrapper_spec.rb delete mode 100644 spec/in_memory_feature_store_spec.rb delete mode 100644 spec/integrations/file_data_source_spec.rb delete mode 100644 spec/integrations/test_data_spec.rb create mode 100644 spec/integrations/test_data_v2/flag_builder_v2_spec.rb diff --git a/.github/actions/check/action.yml b/.github/actions/check/action.yml index 643d9816..01a4ed1d 100644 --- a/.github/actions/check/action.yml +++ b/.github/actions/check/action.yml @@ -38,15 +38,6 @@ runs: shell: bash run: make start-contract-test-service-bg - - name: Run contract tests v2 - if: ${{ inputs.flaky != 'true' }} - uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1 - with: - test_service_port: 9000 - enable_persistence_tests: true - token: ${{ inputs.token }} - stop_service: 'false' - - name: Run contract tests v3 if: ${{ inputs.flaky != 'true' }} uses: launchdarkly/gh-actions/actions/contract-tests@contract-tests-v1 diff --git a/Makefile b/Makefile index 2dd2279e..e5723136 100644 --- a/Makefile +++ b/Makefile @@ -15,8 +15,8 @@ start-contract-test-service-bg: @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & run-contract-tests: - @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v2/downloader/run.sh \ - | VERSION=v2 PARAMS="-url http://localhost:9000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh + @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/v3/downloader/run.sh \ + | VERSION=v3 PARAMS="-url http://localhost:9000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb index 8d061005..a5768ef0 100644 --- a/contract-tests/client_entity.rb +++ b/contract-tests/client_entity.rb @@ -53,25 +53,9 @@ def initialize(log, config) end opts[:data_system_config] = data_system.build - elsif config[:streaming] - streaming = config[:streaming] - opts[:stream_uri] = streaming[:baseUri] unless streaming[:baseUri].nil? - opts[:payload_filter_key] = streaming[:filter] unless streaming[:filter].nil? - set_optional_time_prop(streaming, :initialRetryDelayMs, opts, :initial_reconnect_delay) - elsif config[:polling] - polling = config[:polling] - opts[:stream] = false - opts[:base_uri] = polling[:baseUri] unless polling[:baseUri].nil? - opts[:payload_filter_key] = polling[:filter] unless polling[:filter].nil? - set_optional_time_prop(polling, :pollIntervalMs, opts, :poll_interval) else - opts[:use_ldd] = true - end - - # Configure persistent data store for legacy (non-dataSystem) configurations - if !data_system_config && config[:persistentDataStore] - store, _store_mode = build_persistent_store(config[:persistentDataStore]) - opts[:feature_store] = store + # Without a data system configuration, the client does not connect to LaunchDarkly for data. + opts[:data_system_config] = LaunchDarkly::DataSystem.custom.build end if config[:events] diff --git a/contract-tests/service.rb b/contract-tests/service.rb index 1f9d45e0..1ff98c79 100644 --- a/contract-tests/service.rb +++ b/contract-tests/service.rb @@ -32,8 +32,6 @@ 'all-flags-with-reasons', 'all-flags-client-side-only', 'all-flags-details-only-for-tracked-flags', - 'filtering', - 'filtering-strict', 'secure-mode-hash', 'tags', 'migrations', @@ -55,8 +53,6 @@ 'flag-change-listeners', 'flag-value-change-listeners', 'fdv1-fallback', - 'retry-conformance-fdv1-streaming', - 'retry-conformance-fdv1-polling', ], }.to_json end diff --git a/lib/ldclient-rb/config.rb b/lib/ldclient-rb/config.rb index a3cd0331..ce0950ed 100644 --- a/lib/ldclient-rb/config.rb +++ b/lib/ldclient-rb/config.rb @@ -1,5 +1,4 @@ require "logger" -require "ldclient-rb/impl/cache_store" require "ldclient-rb/impl/data_system/http_config_options" require "ldclient-rb/impl/data_system/polling" require "ldclient-rb/impl/data_system/streaming" @@ -19,26 +18,17 @@ class Config # # @param opts [Hash] the configuration options # @option opts [Logger] :logger See {#logger}. - # @option opts [String] :base_uri ("https://sdk.launchdarkly.com") See {#base_uri}. - # @option opts [String] :stream_uri ("https://stream.launchdarkly.com") See {#stream_uri}. # @option opts [String] :events_uri ("https://events.launchdarkly.com") See {#events_uri}. # @option opts [Integer] :capacity (10000) See {#capacity}. # @option opts [Float] :flush_interval (30) See {#flush_interval}. # @option opts [Float] :read_timeout (10) See {#read_timeout}. - # @option opts [Float] :initial_reconnect_delay (1) See {#initial_reconnect_delay}. # @option opts [Float] :connect_timeout (2) See {#connect_timeout}. - # @option opts [Object] :cache_store See {#cache_store}. - # @option opts [Object] :feature_store See {#feature_store}. - # @option opts [Boolean] :use_ldd (false) See {#use_ldd?}. # @option opts [Boolean] :offline (false) See {#offline?}. - # @option opts [Float] :poll_interval (30) See {#poll_interval}. - # @option opts [Boolean] :stream (true) See {#stream?}. # @option opts [Boolean] all_attributes_private (false) See {#all_attributes_private}. # @option opts [Enumerable] :private_attributes See {#private_attributes}. # @option opts [Boolean] :send_events (true) See {#send_events}. # @option opts [Integer] :context_keys_capacity (1000) See {#context_keys_capacity}. # @option opts [Float] :context_keys_flush_interval (300) See {#context_keys_flush_interval}. - # @option opts [Object] :data_source See {#data_source}. # @option opts [Boolean] :diagnostic_opt_out (false) See {#diagnostic_opt_out?}. # @option opts [Float] :diagnostic_recording_interval (900) See {#diagnostic_recording_interval}. # @option opts [String] :wrapper_name See {#wrapper_name}. @@ -46,35 +36,25 @@ class Config # @option opts [#open] :socket_factory See {#socket_factory}. # @option opts [BigSegmentsConfig] :big_segments See {#big_segments}. # @option opts [Hash] :application See {#application} - # @option opts [String] :payload_filter_key See {#payload_filter_key} # @option opts [Boolean] :omit_anonymous_contexts See {#omit_anonymous_contexts} # @option opts [DataSystemConfig] :data_system_config See {#data_system_config} # @option hooks [Array Config.default_poll_interval ? opts[:poll_interval] : Config.default_poll_interval @all_attributes_private = opts[:all_attributes_private] || false @private_attributes = Set.new(opts[:private_attributes] || []).freeze @send_events = opts.has_key?(:send_events) ? opts[:send_events] : Config.default_send_events @compress_events = opts.has_key?(:compress_events) ? opts[:compress_events] : Config.default_compress_events @context_keys_capacity = opts[:context_keys_capacity] || Config.default_context_keys_capacity @context_keys_flush_interval = opts[:context_keys_flush_interval] || Config.default_context_keys_flush_interval - @data_source = opts[:data_source] @diagnostic_opt_out = opts.has_key?(:diagnostic_opt_out) && opts[:diagnostic_opt_out] @diagnostic_recording_interval = opts.has_key?(:diagnostic_recording_interval) && opts[:diagnostic_recording_interval] > Config.minimum_diagnostic_recording_interval ? opts[:diagnostic_recording_interval] : Config.default_diagnostic_recording_interval @@ -83,29 +63,13 @@ def initialize(opts = {}) @socket_factory = opts[:socket_factory] @big_segments = opts[:big_segments] || BigSegmentsConfig.new(store: nil) @application = LaunchDarkly::Impl::Util.validate_application_info(opts[:application] || {}, @logger) - @payload_filter_key = LaunchDarkly::Impl::Util.validate_payload_filter_key(opts[:payload_filter_key] , @logger) @hooks = (opts[:hooks] || []).keep_if { |hook| hook.is_a? Interfaces::Hooks::Hook } @plugins = (opts[:plugins] || []).keep_if { |plugin| plugin.is_a? Interfaces::Plugins::Plugin } @omit_anonymous_contexts = opts.has_key?(:omit_anonymous_contexts) && opts[:omit_anonymous_contexts] @data_system_config = opts[:data_system_config] - @data_source_update_sink = nil @instance_id = nil end - # - # Returns the component that allows a data source to push data into the SDK. - # - # This property should only be set by the SDK. Long term access of this - # property is not supported; it is temporarily being exposed to maintain - # backwards compatibility while the SDK structure is updated. - # - # Custom data source implementations should integrate with this sink if - # they want to provide support for data source status listeners. - # - # @api private - # - attr_accessor :data_source_update_sink - # # Returns the unique identifier for this instance of the SDK. @@ -118,20 +82,6 @@ def initialize(opts = {}) # attr_accessor :instance_id - # - # The base URL for the LaunchDarkly server. This is configurable mainly for testing - # purposes; most users should use the default value. - # @return [String] - # - attr_reader :base_uri - - # - # The base URL for the LaunchDarkly streaming server. This is configurable mainly for testing - # purposes; most users should use the default value. - # @return [String] - # - attr_reader :stream_uri - # # The base URL for the LaunchDarkly events server. This is configurable mainly for testing # purposes; most users should use the default value. @@ -139,31 +89,6 @@ def initialize(opts = {}) # attr_reader :events_uri - # - # Whether streaming mode should be enabled. Streaming mode asynchronously updates - # feature flags in real-time using server-sent events. Streaming is enabled by default, and - # should only be disabled on the advice of LaunchDarkly support. - # @return [Boolean] - # - def stream? - @stream - end - - # - # Whether to use the LaunchDarkly relay proxy in daemon mode. In this mode, the client does not - # use polling or streaming to get feature flag updates from the server, but instead reads them - # from the {#feature_store feature store}, which is assumed to be a database that is populated by - # a LaunchDarkly relay proxy. For more information, see ["The relay proxy"](https://docs.launchdarkly.com/home/relay-proxy) - # and ["Using a persistent data stores"](https://docs.launchdarkly.com/sdk/concepts/data-stores). - # - # All other properties related to streaming or polling are ignored if this option is set to true. - # - # @return [Boolean] - # - def use_ldd? - @use_ldd - end - # # Whether the client should be initialized in offline mode. In offline mode, default values are # returned for all flags and no remote network requests are made. @@ -180,13 +105,6 @@ def offline? # attr_reader :flush_interval - # - # The number of seconds to wait before polling for feature flag updates. This option has no - # effect unless streaming is disabled. - # @return [Float] - # - attr_reader :poll_interval - # # The configured logger for the LaunchDarkly client. The client library uses the log to # print warning and error messages. If not specified, this defaults to the Rails logger @@ -205,15 +123,6 @@ def offline? # attr_reader :capacity - # - # A store for HTTP caching (used only in polling mode). This must support the semantics used by - # the [`faraday-http-cache`](https://github.com/plataformatec/faraday-http-cache) gem, although - # the SDK no longer uses Faraday. Defaults to the Rails cache in a Rails environment, or a - # thread-safe in-memory store otherwise. - # @return [Object] - # - attr_reader :cache_store - # # The read timeout for network connections in seconds. This does not apply to the streaming # connection, which uses a longer timeout since the server does not send data constantly. @@ -221,30 +130,12 @@ def offline? # attr_reader :read_timeout - # - # The initial delay before reconnecting after an error in the SSE client. - # This only applies to the streaming connection. - # @return [Float] - # - attr_reader :initial_reconnect_delay - # # The connect timeout for network connections in seconds. # @return [Float] # attr_reader :connect_timeout - # - # A store for feature flags and related data. The client uses it to store all data received - # from LaunchDarkly, and uses the last stored data when evaluating flags. Defaults to - # {InMemoryFeatureStore}; for other implementations, see {LaunchDarkly::Integrations}. - # - # For more information, see ["Persistent data stores"](https://docs.launchdarkly.com/sdk/concepts/data-stores). - # - # @return [LaunchDarkly::Interfaces::FeatureStore] - # - attr_reader :feature_store - # # True if all context attributes (other than the key) should be considered private. This means # that the attribute values will not be sent to LaunchDarkly in analytics events and will not @@ -306,21 +197,6 @@ def offline? # attr_reader :context_keys_flush_interval - # - # An object that is responsible for receiving feature flag data from LaunchDarkly. By default, - # the client uses its standard polling or streaming implementation; this is customizable for - # testing purposes. - # - # This may be set to either an object that conforms to {LaunchDarkly::Interfaces::DataSource}, - # or a lambda (or Proc) that takes two parameters-- SDK key and {Config}-- and returns such an - # object. - # - # @return [LaunchDarkly::Interfaces::DataSource|lambda] - # @see LaunchDarkly::Integrations::FileData - # @see LaunchDarkly::Integrations::TestData - # - attr_reader :data_source - # # Configuration options related to Big Segments. # @@ -349,24 +225,6 @@ def offline? # attr_reader :application - # - # LaunchDarkly Server SDKs historically downloaded all flag configuration and segments for a particular environment - # during initialization. - # - # For some customers, this is an unacceptably large amount of data, and has contributed to performance issues within - # their products. - # - # Filtered environments aim to solve this problem. By allowing customers to specify subsets of an environment's - # flags using a filter key, SDKs will initialize faster and use less memory. - # - # This payload filter key only applies to the default streaming and polling data sources. It will not affect TestData or FileData - # data sources, nor will it be applied to any data source provided through the {#data_source} config property. - # - # Payload filtering is not supported with the FDv2 data system, so this key has no effect on requests made by FDv2 - # data sources. - # - attr_reader :payload_filter_key - # # Set to true to opt out of sending diagnostics data. # @@ -469,14 +327,13 @@ def with_wrapper_information(wrapper_name, wrapper_version = nil) # # Configuration for the data system, which controls how the SDK gets - # flag and segment data. When set, it overrides {#feature_store}, - # {#data_source}, {#stream?}, and {#use_ldd?}. + # flag and segment data. If this is nil, the client uses + # {LaunchDarkly::DataSystem.default}. # # @return [DataSystemConfig, nil] # attr_reader :data_system_config - # # The default LaunchDarkly client configuration. This configuration sets # reasonable defaults for most users. @@ -494,22 +351,6 @@ def self.default_capacity 10000 end - # - # The default value for {#base_uri}. - # @return [String] "https://sdk.launchdarkly.com" - # - def self.default_base_uri - DataSystem::PollingDataSourceBuilder::DEFAULT_BASE_URI - end - - # - # The default value for {#stream_uri}. - # @return [String] "https://stream.launchdarkly.com" - # - def self.default_stream_uri - DataSystem::StreamingDataSourceBuilder::DEFAULT_BASE_URI - end - # # The default value for {#events_uri}. # @return [String] "https://events.launchdarkly.com" @@ -518,14 +359,6 @@ def self.default_events_uri "https://events.launchdarkly.com" end - # - # The default value for {#cache_store}. - # @return [Object] the Rails cache if in Rails, or a simple in-memory implementation otherwise - # - def self.default_cache_store - defined?(Rails) && Rails.respond_to?(:cache) ? Rails.cache : Impl::ThreadSafeMemoryStore.new - end - # # The default value for {#flush_interval}. # @return [Float] 10 @@ -542,14 +375,6 @@ def self.default_read_timeout Impl::DataSystem::HttpConfigOptions::DEFAULT_READ_TIMEOUT end - # - # The default value for {#initial_reconnect_delay}. - # @return [Float] 1 - # - def self.default_initial_reconnect_delay - DataSystem::StreamingDataSourceBuilder::DEFAULT_INITIAL_RECONNECT_DELAY - end - # # The default value for {#connect_timeout}. # @return [Float] 2 @@ -572,30 +397,6 @@ def self.default_logger end end - # - # The default value for {#stream?}. - # @return [Boolean] true - # - def self.default_stream - true - end - - # - # The default value for {#use_ldd?}. - # @return [Boolean] false - # - def self.default_use_ldd - false - end - - # - # The default value for {#feature_store}. - # @return [LaunchDarkly::Interfaces::FeatureStore] an {InMemoryFeatureStore} - # - def self.default_feature_store - InMemoryFeatureStore.new - end - # # The default value for {#offline?}. # @return [Boolean] false @@ -604,14 +405,6 @@ def self.default_offline false end - # - # The default value for {#poll_interval}. - # @return [Float] 30 - # - def self.default_poll_interval - DataSystem::PollingDataSourceBuilder::DEFAULT_POLL_INTERVAL - end - # # The default value for {#send_events}. # @return [Boolean] true diff --git a/lib/ldclient-rb/data_system/polling_data_source_builder.rb b/lib/ldclient-rb/data_system/polling_data_source_builder.rb index e8bf4815..d2ea1d95 100644 --- a/lib/ldclient-rb/data_system/polling_data_source_builder.rb +++ b/lib/ldclient-rb/data_system/polling_data_source_builder.rb @@ -104,7 +104,8 @@ def initialize # # This controls how frequently the SDK polls LaunchDarkly for updates. # Lower values mean more frequent updates but higher network traffic. - # The default is {DEFAULT_POLL_INTERVAL} seconds. + # The default and minimum is {DEFAULT_POLL_INTERVAL} seconds. A smaller value is replaced + # with the minimum. # # @param secs [Float] Polling interval in seconds # @return [PollingDataSourceBuilder] self for chaining @@ -114,6 +115,14 @@ def poll_interval(secs) self end + private def effective_poll_interval(logger) + return DEFAULT_POLL_INTERVAL if @poll_interval.nil? + return @poll_interval if @poll_interval >= DEFAULT_POLL_INTERVAL + + logger.warn { "[LDClient] Poll interval #{@poll_interval} is less than the minimum; using #{DEFAULT_POLL_INTERVAL} seconds" } + DEFAULT_POLL_INTERVAL + end + # # Sets a custom {Requester} for this polling data source. # @@ -146,7 +155,7 @@ def requester(requester) def build(sdk_key, config) http_opts = build_http_config requester = @requester || LaunchDarkly::Impl::DataSystem::HTTPPollingRequester.new(sdk_key, http_opts, config) - LaunchDarkly::Impl::DataSystem::PollingDataSource.new(@poll_interval || DEFAULT_POLL_INTERVAL, requester, config.logger) + LaunchDarkly::Impl::DataSystem::PollingDataSource.new(effective_poll_interval(config.logger), requester, config.logger) end end @@ -181,7 +190,8 @@ def initialize # Sets the polling interval in seconds. # # This controls how frequently the SDK polls LaunchDarkly for updates. - # The default is {DEFAULT_POLL_INTERVAL} seconds. + # The default and minimum is {DEFAULT_POLL_INTERVAL} seconds. A smaller value is replaced + # with the minimum. # # @param secs [Float] Polling interval in seconds # @return [FDv1PollingDataSourceBuilder] self for chaining @@ -191,6 +201,14 @@ def poll_interval(secs) self end + private def effective_poll_interval(logger) + return DEFAULT_POLL_INTERVAL if @poll_interval.nil? + return @poll_interval if @poll_interval >= DEFAULT_POLL_INTERVAL + + logger.warn { "[LDClient] Poll interval #{@poll_interval} is less than the minimum; using #{DEFAULT_POLL_INTERVAL} seconds" } + DEFAULT_POLL_INTERVAL + end + # # Sets a custom {Requester} for this polling data source. # @@ -222,7 +240,7 @@ def requester(requester) def build(sdk_key, config) http_opts = build_http_config requester = @requester || LaunchDarkly::Impl::DataSystem::HTTPFDv1PollingRequester.new(sdk_key, http_opts, config) - LaunchDarkly::Impl::DataSystem::PollingDataSource.new(@poll_interval || DEFAULT_POLL_INTERVAL, requester, config.logger) + LaunchDarkly::Impl::DataSystem::PollingDataSource.new(effective_poll_interval(config.logger), requester, config.logger) end end end diff --git a/lib/ldclient-rb/impl/cache_store.rb b/lib/ldclient-rb/impl/cache_store.rb deleted file mode 100644 index e32566c0..00000000 --- a/lib/ldclient-rb/impl/cache_store.rb +++ /dev/null @@ -1,44 +0,0 @@ -require "concurrent/map" - -module LaunchDarkly - module Impl - # A thread-safe in-memory store that uses the same semantics that Faraday would expect, although we - # no longer use Faraday. This is used by Requestor, when we are not in a Rails environment. - class ThreadSafeMemoryStore - # - # Default constructor - # - # @return [ThreadSafeMemoryStore] a new store - def initialize - @cache = Concurrent::Map.new - end - - # - # Read a value from the cache - # @param key [Object] the cache key - # - # @return [Object] the cache value - def read(key) - @cache[key] - end - - # - # Store a value in the cache - # @param key [Object] the cache key - # @param value [Object] the value to associate with the key - # - # @return [Object] the value - def write(key, value) - @cache[key] = value - end - - # - # Delete a value in the cache - # @param key [Object] the cache key - def delete(key) - @cache.delete(key) - end - end - end -end - diff --git a/lib/ldclient-rb/impl/data_source.rb b/lib/ldclient-rb/impl/data_source.rb deleted file mode 100644 index 24015c5a..00000000 --- a/lib/ldclient-rb/impl/data_source.rb +++ /dev/null @@ -1,228 +0,0 @@ -require "concurrent" -require "forwardable" -require "ldclient-rb/impl/dependency_tracker" -require "ldclient-rb/interfaces" -require "set" - -module LaunchDarkly - module Impl - module DataSource - LD_ENVID_HEADER = "X-LD-EnvID" - - # - # Records the environment ID reported by LaunchDarkly, when the response headers provide one and the sink is - # able to hold it. Externally implemented sinks are not required to support this. - # - # @param sink [LaunchDarkly::Interfaces::DataSource::UpdateSink, nil] - # @param headers [#[], nil] - # @return [void] - # - def self.record_environment_id(sink, headers) - return if headers.nil? || !sink.respond_to?(:set_environment_id) - - environment_id = headers[LD_ENVID_HEADER] - # The http gem returns arrays for repeated headers; normalize to a string. - environment_id = environment_id.first if environment_id.is_a?(Array) - return unless environment_id.is_a?(String) && !environment_id.empty? - - sink.set_environment_id(environment_id) - end - - class StatusProvider - include LaunchDarkly::Interfaces::DataSource::StatusProvider - - extend Forwardable - def_delegators :@status_broadcaster, :add_listener, :remove_listener - - def initialize(status_broadcaster, update_sink) - # @type [Broadcaster] - @status_broadcaster = status_broadcaster - # @type [UpdateSink] - @data_source_update_sink = update_sink - end - - def status - @data_source_update_sink.current_status - end - end - - class UpdateSink - include LaunchDarkly::Interfaces::DataSource::UpdateSink - - # @return [LaunchDarkly::Interfaces::DataSource::Status] - attr_reader :current_status - - def initialize(data_store, status_broadcaster, flag_change_broadcaster) - # @type [LaunchDarkly::Interfaces::FeatureStore] - @data_store = data_store - # @type [Broadcaster] - @status_broadcaster = status_broadcaster - # @type [Broadcaster] - @flag_change_broadcaster = flag_change_broadcaster - @dependency_tracker = LaunchDarkly::Impl::DependencyTracker.new - - @mutex = Mutex.new - @environment_id = nil - @current_status = LaunchDarkly::Interfaces::DataSource::Status.new( - LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING, - Time.now, - nil) - end - - # - # @return [String, nil] The environment ID reported by LaunchDarkly, if known - # - def environment_id - @mutex.synchronize { @environment_id } - end - - # - # @param environment_id [String] - # @return [void] - # - def set_environment_id(environment_id) - @mutex.synchronize { @environment_id = environment_id } - end - - def init(all_data) - old_data = nil - monitor_store_update do - if @flag_change_broadcaster.has_listeners? - old_data = {} - LaunchDarkly::Interfaces::DataStore::ALL_KINDS.each do |kind| - old_data[kind] = @data_store.all(kind) - end - end - - @data_store.init(all_data) - end - - update_full_dependency_tracker(all_data) - - return if old_data.nil? - - send_change_events( - compute_changed_items_for_full_data_set(old_data, all_data) - ) - end - - def upsert(kind, item) - updated = monitor_store_update { @data_store.upsert(kind, item) } - return unless updated - - @dependency_tracker.update_dependencies_from(kind, item[:key], item) - if @flag_change_broadcaster.has_listeners? - affected_items = Set.new - @dependency_tracker.add_affected_items(affected_items, {kind: kind, key: item[:key]}) - send_change_events(affected_items) - end - end - - def delete(kind, key, version) - deleted = monitor_store_update { @data_store.delete(kind, key, version) } - return unless deleted - - @dependency_tracker.update_dependencies_from(kind, key, nil) - if @flag_change_broadcaster.has_listeners? - affected_items = Set.new - @dependency_tracker.add_affected_items(affected_items, {kind: kind, key: key}) - send_change_events(affected_items) - end - end - - def update_status(new_state, new_error) - return if new_state.nil? - - status_to_broadcast = nil - - @mutex.synchronize do - old_status = @current_status - - # OFF is final: no status is reported after it. - return if old_status.state == LaunchDarkly::Interfaces::DataSource::Status::OFF - - if new_state == LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED && old_status.state == LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING - # See {LaunchDarkly::Interfaces::DataSource::UpdateSink#update_status} for more information - new_state = LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING - end - - unless new_state == old_status.state && new_error.nil? - @current_status = LaunchDarkly::Interfaces::DataSource::Status.new( - new_state, - new_state == current_status.state ? current_status.state_since : Time.now, - new_error.nil? ? current_status.last_error : new_error - ) - status_to_broadcast = current_status - end - end - - @status_broadcaster.broadcast(status_to_broadcast) unless status_to_broadcast.nil? - end - - private def update_full_dependency_tracker(all_data) - @dependency_tracker.reset - all_data.each do |kind, items| - items.each do |key, item| - @dependency_tracker.update_dependencies_from(kind, item.key, item) - end - end - end - - - # - # Method to monitor updates to the store. You provide a block to update - # the store. This mthod wraps that block, catching and re-raising all - # errors, and notifying all status listeners of the error. - # - private def monitor_store_update - begin - yield - rescue => e - error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(LaunchDarkly::Interfaces::DataSource::ErrorInfo::STORE_ERROR, 0, e.to_s, Time.now) - update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error_info) - raise - end - end - - - # - # @param [Hash] old_data - # @param [Hash] new_data - # @return [Set] - # - private def compute_changed_items_for_full_data_set(old_data, new_data) - affected_items = Set.new - - LaunchDarkly::Interfaces::DataStore::ALL_KINDS.each do |kind| - old_items = old_data[kind] || {} - new_items = new_data[kind] || {} - - old_items.keys.concat(new_items.keys).each do |key| - old_item = old_items[key] - new_item = new_items[key] - - next if old_item.nil? && new_item.nil? - - if old_item.nil? || new_item.nil? || old_item[:version] < new_item[:version] - @dependency_tracker.add_affected_items(affected_items, {kind: kind, key: key.to_s}) - end - end - end - - affected_items - end - - # - # @param affected_items [Set] - # - private def send_change_events(affected_items) - affected_items.each do |item| - if item[:kind] == LaunchDarkly::Interfaces::DataStore::FEATURES - @flag_change_broadcaster.broadcast(LaunchDarkly::Interfaces::FlagChange.new(item[:key].to_s)) - end - end - end - end - end - end -end diff --git a/lib/ldclient-rb/impl/data_source/null_processor.rb b/lib/ldclient-rb/impl/data_source/null_processor.rb deleted file mode 100644 index 0929e328..00000000 --- a/lib/ldclient-rb/impl/data_source/null_processor.rb +++ /dev/null @@ -1,52 +0,0 @@ -require 'concurrent' -require 'ldclient-rb/interfaces' - -module LaunchDarkly - module Impl - module DataSource - # - # A minimal UpdateProcessor implementation used when the SDK is in offline mode - # or daemon (LDD) mode. It does nothing except mark itself as initialized. - # - class NullUpdateProcessor - include LaunchDarkly::Interfaces::DataSource - - # - # Creates a new NullUpdateProcessor. - # - def initialize - @ready = Concurrent::Event.new - end - - # - # Starts the data source. Since this is a null implementation, it immediately - # sets the ready event to indicate initialization is complete. - # - # @return [Concurrent::Event] The ready event - # - def start - @ready.set - @ready - end - - # - # Stops the data source. This is a no-op for the null implementation. - # - # @return [void] - # - def stop - # Nothing to do - end - - # - # Checks if the data source has been initialized. - # - # @return [Boolean] Always returns true since this is a null implementation - # - def initialized? - true - end - end - end - end -end diff --git a/lib/ldclient-rb/impl/data_source/polling.rb b/lib/ldclient-rb/impl/data_source/polling.rb deleted file mode 100644 index 22f68d11..00000000 --- a/lib/ldclient-rb/impl/data_source/polling.rb +++ /dev/null @@ -1,122 +0,0 @@ -require "ldclient-rb/impl/data_source" -require "ldclient-rb/impl/repeating_task" -require "ldclient-rb/impl/retry_state" -require "ldclient-rb/impl/util" - -require "concurrent/atomics" -require "json" -require "thread" - -module LaunchDarkly - module Impl - module DataSource - class PollingProcessor - def initialize(config, requestor) - @config = config - @requestor = requestor - @initialized = Concurrent::AtomicBoolean.new(false) - @started = Concurrent::AtomicBoolean.new(false) - @ready = Concurrent::Event.new - @retry_state = Impl::RetryState.for_polling(@config.poll_interval, @config.logger) - @task = Impl::RepeatingTask.new(-> { @retry_state.next_delay }, 0, -> { self.poll }, @config.logger, 'LD/PollingDataSource') - end - - def initialized? - @initialized.value - end - - def start - return @ready unless @started.make_true - @config.logger.info { "[LDClient] Initializing polling connection" } - @task.start - @ready - end - - def stop - stop_with_error_info - end - - def poll - begin - all_data, headers = request_all_data - DataSource.record_environment_id(@config.data_source_update_sink, headers) - newly_initialized = false - if all_data - update_sink_or_data_store.init(all_data) - newly_initialized = @initialized.make_true - end - @retry_state.record_success - @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - - if newly_initialized - @config.logger.info { "[LDClient] Polling connection initialized" } - @ready.set - end - rescue JSON::ParserError => e - @retry_state.record_failure(:normal) - @config.logger.error { "[LDClient] JSON parsing failed for polling response - #{Util.retry_message(@retry_state.next_delay)}" } - error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( - LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA, - 0, - e.to_s, - Time.now - ) - @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error_info) - rescue Impl::DataSource::UnexpectedResponseError => e - kind = Util.classify_http_status(e.status) - @retry_state.record_failure(kind) - error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( - LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE, e.status, nil, Time.now) - message = Util.http_error_retry_message(e.status, "polling request", @retry_state.next_delay) - @config.logger.public_send(Util.failure_log_level(kind)) { "[LDClient] #{message}" } - @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error_info) - rescue StandardError => e - @retry_state.record_failure(:normal) - Impl::Util.log_exception(@config.logger, "Exception while polling - #{Util.retry_message(@retry_state.next_delay)}", e) - @config.data_source_update_sink&.update_status( - LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, - LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN, 0, e.to_s, Time.now) - ) - end - end - - # - # The original implementation of this class relied on the feature store - # directly, which we are trying to move away from. Customers who might have - # instantiated this directly for some reason wouldn't know they have to set - # the config's sink manually, so we have to fall back to the store if the - # sink isn't present. - # - # The next major release should be able to simplify this structure and - # remove the need for fall back to the data store because the update sink - # should always be present. - # - private def update_sink_or_data_store - @config.data_source_update_sink || @config.feature_store - end - - # - # Requestors provided by application code may not be able to report the response headers. - # - # @return [Array(Hash, HTTP::Headers, nil)] - # - private def request_all_data - return @requestor.request_all_data_with_headers if @requestor.respond_to?(:request_all_data_with_headers) - - [@requestor.request_all_data, nil] - end - - # - # @param [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] error_info - # - private def stop_with_error_info(error_info = nil) - @task.stop - @requestor.stop if @requestor.respond_to?(:stop) - @config.logger.info { "[LDClient] Polling connection stopped" } - @config.data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error_info) - end - end - end - end -end - diff --git a/lib/ldclient-rb/impl/data_source/requestor.rb b/lib/ldclient-rb/impl/data_source/requestor.rb deleted file mode 100644 index d1110582..00000000 --- a/lib/ldclient-rb/impl/data_source/requestor.rb +++ /dev/null @@ -1,121 +0,0 @@ -require "ldclient-rb/impl/model/serialization" -require "ldclient-rb/impl/util" -require "ldclient-rb/impl/data_system/http_config_options" - -require "concurrent/atomics" -require "json" -require "uri" -require "http" - -module LaunchDarkly - module Impl - module DataSource - class UnexpectedResponseError < StandardError - def initialize(status) - @status = status - super("HTTP error #{status}") - end - - def status - @status - end - end - - class Requestor - CacheEntry = Struct.new(:etag, :body) - - def initialize(sdk_key, config) - @sdk_key = sdk_key - @config = config - @http_config = DataSystem::HttpConfigOptions.new( - base_uri: config.base_uri, - socket_factory: config.socket_factory, - read_timeout: config.read_timeout, - connect_timeout: config.connect_timeout - ) - @http_client = Impl::Util.new_http_client(@http_config) - .use(:auto_inflate) - .headers("Accept-Encoding" => "gzip") - @cache = @config.cache_store - end - - def request_all_data() - request_all_data_with_headers.first - end - - # - # Requests the full data set, also returning the headers of the response it was retrieved from. - # - # @return [Array(Hash, HTTP::Headers)] - # - def request_all_data_with_headers - body, headers = make_request("/sdk/latest-all") - all_data = JSON.parse(body, symbolize_names: true) - [Impl::Model.make_all_store_data(all_data, @config.logger), headers] - end - - def stop - begin - @http_client.close - rescue - end - end - - private - - def make_request(path) - uri = URI( - Util.add_payload_filter_key(@http_config.base_uri + path, @config) - ) - headers = {} - Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| headers[k] = v } - headers["Connection"] = "keep-alive" - cached = @cache.read(uri) - unless cached.nil? - headers["If-None-Match"] = cached.etag - end - response = @http_client.request("GET", uri, headers: headers) - status = response.status.code - # must fully read body for persistent connections - body = response.to_s - @config.logger.debug { "[LDClient] Got response from uri: #{uri}\n\tstatus code: #{status}\n\theaders: #{response.headers.to_h}\n\tbody: #{body}" } - if status == 304 && !cached.nil? - body = cached.body - else - @cache.delete(uri) - if status < 200 || status >= 300 - raise UnexpectedResponseError.new(status) - end - body = fix_encoding(body, response.headers["content-type"]) - etag = response.headers["etag"] - @cache.write(uri, CacheEntry.new(etag, body)) unless etag.nil? - end - [body, response.headers] - end - - def fix_encoding(body, content_type) - return body if content_type.nil? - media_type, charset = parse_content_type(content_type) - return body if charset.nil? - body.force_encoding(Encoding::find(charset)).encode(Encoding::UTF_8) - end - - def parse_content_type(value) - return [nil, nil] if value.nil? || value == '' - parts = value.split(/; */) - return [value, nil] if parts.count < 2 - charset = nil - parts.each do |part| - fields = part.split('=') - if fields.count >= 2 && fields[0] == 'charset' - charset = fields[1] - break - end - end - [parts[0], charset] - end - end - end - end -end - diff --git a/lib/ldclient-rb/impl/data_source/status_provider.rb b/lib/ldclient-rb/impl/data_source/status_provider.rb index 9e6bdd26..bd84dc09 100644 --- a/lib/ldclient-rb/impl/data_source/status_provider.rb +++ b/lib/ldclient-rb/impl/data_source/status_provider.rb @@ -67,7 +67,12 @@ def status_and_seconds_in_state end end - # (see LaunchDarkly::Interfaces::DataSource::UpdateSink#update_status) + # + # Records a new data source state and error, and notifies the listeners if the status changes. + # + # @param new_state [Symbol] the new state, one of the {LaunchDarkly::Interfaces::DataSource::Status} values + # @param new_error [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] the most recent error, if any + # def update_status(new_state, new_error) return if new_state.nil? diff --git a/lib/ldclient-rb/impl/data_source/stream.rb b/lib/ldclient-rb/impl/data_source/stream.rb deleted file mode 100644 index d386f96b..00000000 --- a/lib/ldclient-rb/impl/data_source/stream.rb +++ /dev/null @@ -1,239 +0,0 @@ -require "ldclient-rb/impl/data_source" -require "ldclient-rb/impl/model/serialization" -require "ldclient-rb/impl/retry_state" -require "ldclient-rb/impl/util" -require "ldclient-rb/in_memory_store" - -require "concurrent/atomics" -require "json" -require "ld-eventsource" - -module LaunchDarkly - module Impl - module DataSource - PUT = :put - PATCH = :patch - DELETE = :delete - READ_TIMEOUT_SECONDS = 300 # 5 minutes; the stream should send a ping every 3 minutes - - KEY_PATHS = { - LaunchDarkly::Interfaces::DataStore::FEATURES => "/flags/", - LaunchDarkly::Interfaces::DataStore::SEGMENTS => "/segments/", - } - - class StreamProcessor - def initialize(sdk_key, config, diagnostic_accumulator = nil) - @sdk_key = sdk_key - @config = config - @diagnostic_accumulator = diagnostic_accumulator - @data_source_update_sink = config.data_source_update_sink - @feature_store = config.feature_store - @initialized = Concurrent::AtomicBoolean.new(false) - @started = Concurrent::AtomicBoolean.new(false) - @stopped = Concurrent::AtomicBoolean.new(false) - @ready = Concurrent::Event.new - @stop_event = Concurrent::Event.new - @retry_state = Impl::RetryState.for_streaming(@config.initial_reconnect_delay, @config.logger) - @connection_attempt_start_time = 0 - @connection_attempt_started_monotonic = nil - end - - def initialized? - @initialized.value - end - - def start - return @ready unless @started.make_true - - @config.logger.info { "[LDClient] Initializing stream connection" } - - headers = Impl::Util.default_http_headers(@sdk_key, @config) - opts = { - headers: headers, - read_timeout: READ_TIMEOUT_SECONDS, - connect_timeout: @config.connect_timeout, - logger: @config.logger, - socket_factory: @config.socket_factory, - # The SDK waits in its error handler before each reconnect, so the SSE client adds no delay. - reconnect_time: 0, - } - log_connection_started - - uri = Impl::Util.add_payload_filter_key(@config.stream_uri + "/all", @config) - @es = SSE::Client.new(uri, **opts) do |conn| - conn.on_connect { |response_headers| DataSource.record_environment_id(@data_source_update_sink, response_headers) } - conn.on_event { |event| process_message(event) } - conn.on_error { |err| handle_error(err) } - end - # stop can run before @es is set, and then it has no client to close. - @es.close if @stopped.value - - @ready - end - - def stop - stop_with_error_info - end - - private - - # - # @param [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] error_info - # - def stop_with_error_info(error_info = nil) - return unless @stopped.make_true - - @stop_event.set - begin - @es&.close - rescue StandardError => e - Impl::Util.log_exception(@config.logger, "Error while closing stream connection", e) - end - @data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error_info) - @config.logger.info { "[LDClient] Stream connection stopped" } - end - - def handle_error(err) - status = 0 - message = err.to_s - kind = :normal - level = :warn - - case err - when SSE::Errors::HTTPStatusError - status = err.status - message = nil - kind = Util.classify_http_status(status) - level = Util.failure_log_level(kind) - error_kind = LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE - description = Util.http_error_description(status, "streaming connection") - when JSON::ParserError - level = :error - error_kind = LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA - description = "Invalid data on streaming connection: #{err}" - when SSE::Errors::StreamClosedByServerError - error_kind = LaunchDarkly::Interfaces::DataSource::ErrorInfo::NETWORK_ERROR - description = "The server closed the streaming connection" - when SSE::Errors::HTTPContentTypeError, SSE::Errors::HTTPProxyError, SSE::Errors::ReadTimeoutError - error_kind = LaunchDarkly::Interfaces::DataSource::ErrorInfo::NETWORK_ERROR - description = "Error on streaming connection: #{err}" - else - error_kind = LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN - description = "Error on streaming connection: #{err}" - end - - error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(error_kind, status, message, Time.now) - handle_failure(kind, error_info, level, description) - end - - # - # Records a failure, logs and reports it, and waits before the next attempt. The SSE client calls this on - # its worker thread before it reconnects, so the wait is the reconnect delay, and {#stop} ends it at once. - # - # @param kind [Symbol] `:normal` or `:unexpected` - # @param error_info [LaunchDarkly::Interfaces::DataSource::ErrorInfo] - # @param level [Symbol] `:error` or `:warn` - # @param description [String] what failed, for the log message - # - def handle_failure(kind, error_info, level, description) - return if @stopped.value - - @retry_state.record_failure(kind) - delay = @retry_state.next_delay - begin - log_connection_result(false) - @config.logger.public_send(level) { "[LDClient] #{description} - #{Util.retry_message(delay)}" } - @data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error_info) - ensure - # The SSE client reconnects as soon as this returns, also when it returns by raising. - @stop_event.wait([delay, Util::TIMEOUT_MAX].min) - log_connection_started - end - end - - # - # The original implementation of this class relied on the feature store - # directly, which we are trying to move away from. Customers who might have - # instantiated this directly for some reason wouldn't know they have to set - # the config's sink manually, so we have to fall back to the store if the - # sink isn't present. - # - # The next major release should be able to simplify this structure and - # remove the need for fall back to the data store because the update sink - # should always be present. - # - def update_sink_or_data_store - @data_source_update_sink || @feature_store - end - - def process_message(message) - log_connection_result(true) - method = message.type - known_type = [PUT, PATCH, DELETE].include?(method) - @config.logger.debug { "[LDClient] Stream received #{method} message: #{message.data}" } - - begin - if method == PUT - message = JSON.parse(message.data, symbolize_names: true) - all_data = Impl::Model.make_all_store_data(message[:data], @config.logger) - update_sink_or_data_store.init(all_data) - @initialized.make_true - @config.logger.info { "[LDClient] Stream initialized" } - @ready.set - elsif method == PATCH - data = JSON.parse(message.data, symbolize_names: true) - for kind in [LaunchDarkly::Interfaces::DataStore::FEATURES, LaunchDarkly::Interfaces::DataStore::SEGMENTS] - key = key_for_path(kind, data[:path]) - if key - item = Impl::Model.deserialize(kind, data[:data], @config.logger) - update_sink_or_data_store.upsert(kind, item) - break - end - end - elsif method == DELETE - data = JSON.parse(message.data, symbolize_names: true) - for kind in [LaunchDarkly::Interfaces::DataStore::FEATURES, LaunchDarkly::Interfaces::DataStore::SEGMENTS] - key = key_for_path(kind, data[:path]) - if key - update_sink_or_data_store.delete(kind, key, data[:version]) - break - end - end - else - @config.logger.warn { "[LDClient] Unknown message received: #{method}" } - end - - # Only a data payload shows that the stream is healthy. - @retry_state.record_success if known_type - @data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - rescue JSON::ParserError - @config.logger.error { "[LDClient] JSON parsing failed for method #{method}. Ignoring event." } - - # Re-raise the exception so the SSE implementation can catch it and restart the stream. - raise - end - end - - def key_for_path(kind, path) - path.start_with?(KEY_PATHS[kind]) ? path[KEY_PATHS[kind].length..-1] : nil - end - - def log_connection_started - @connection_attempt_start_time = Impl::Util::current_time_millis - @connection_attempt_started_monotonic = Impl::Util.monotonic_seconds - end - - def log_connection_result(is_success) - if !@diagnostic_accumulator.nil? && !@connection_attempt_started_monotonic.nil? - # The monotonic stamp is both the "attempt in flight" sentinel and the - # duration base; the wall-clock value is only the reported timestamp. - duration_millis = ((Impl::Util.monotonic_seconds - @connection_attempt_started_monotonic) * 1_000).to_i - @diagnostic_accumulator.record_stream_init(@connection_attempt_start_time, !is_success, duration_millis) - @connection_attempt_started_monotonic = nil - end - end - end - end - end -end - diff --git a/lib/ldclient-rb/impl/data_store/in_memory_feature_store.rb b/lib/ldclient-rb/impl/data_store/in_memory_feature_store.rb index b393b4d5..3af18e42 100644 --- a/lib/ldclient-rb/impl/data_store/in_memory_feature_store.rb +++ b/lib/ldclient-rb/impl/data_store/in_memory_feature_store.rb @@ -2,7 +2,6 @@ require "concurrent" require "concurrent/atomics" -require "ldclient-rb/impl/data_store/status" require "ldclient-rb/interfaces/data_system" module LaunchDarkly diff --git a/lib/ldclient-rb/impl/data_store/status.rb b/lib/ldclient-rb/impl/data_store/status.rb deleted file mode 100644 index e834e522..00000000 --- a/lib/ldclient-rb/impl/data_store/status.rb +++ /dev/null @@ -1,59 +0,0 @@ -require 'concurrent' -require "ldclient-rb/interfaces" - -module LaunchDarkly - module Impl - module DataStore - class StatusProvider - include LaunchDarkly::Interfaces::DataStore::StatusProvider - - def initialize(store, update_sink) - # @type [LaunchDarkly::Impl::FeatureStoreClientWrapper] - @store = store - # @type [UpdateSink] - @update_sink = update_sink - end - - def status - @update_sink.last_status.get - end - - def monitoring_enabled? - @store.monitoring_enabled? - end - - def add_listener(listener) - @update_sink.broadcaster.add_listener(listener) - end - - def remove_listener(listener) - @update_sink.broadcaster.remove_listener(listener) - end - end - - class UpdateSink - include LaunchDarkly::Interfaces::DataStore::UpdateSink - - # @return [LaunchDarkly::Impl::Broadcaster] - attr_reader :broadcaster - - # @return [Concurrent::AtomicReference] - attr_reader :last_status - - def initialize(broadcaster) - @broadcaster = broadcaster - @last_status = Concurrent::AtomicReference.new( - LaunchDarkly::Interfaces::DataStore::Status.new(true, false) - ) - end - - def update_status(status) - return if status.nil? - - old_status = @last_status.get_and_set(status) - @broadcaster.broadcast(status) unless old_status == status - end - end - end - end -end diff --git a/lib/ldclient-rb/impl/data_store/status_provider.rb b/lib/ldclient-rb/impl/data_store/status_provider.rb index cdb73969..0726b2d4 100644 --- a/lib/ldclient-rb/impl/data_store/status_provider.rb +++ b/lib/ldclient-rb/impl/data_store/status_provider.rb @@ -30,7 +30,11 @@ def initialize(store, status_broadcaster) @monitoring_enabled = store_supports_monitoring? end - # (see LaunchDarkly::Interfaces::DataStore::UpdateSink#update_status) + # + # Records a new data store status, and notifies the listeners if it changes. + # + # @param status [LaunchDarkly::Interfaces::DataStore::Status] the new status + # def update_status(status) modified = false diff --git a/lib/ldclient-rb/impl/data_store/store.rb b/lib/ldclient-rb/impl/data_store/store.rb index dc04bd35..77e63ffe 100644 --- a/lib/ldclient-rb/impl/data_store/store.rb +++ b/lib/ldclient-rb/impl/data_store/store.rb @@ -2,7 +2,6 @@ require "concurrent" require "set" -require "ldclient-rb/impl/data_store/status" require "ldclient-rb/impl/data_store/in_memory_feature_store" require "ldclient-rb/impl/dependency_tracker" require "ldclient-rb/interfaces/data_system" diff --git a/lib/ldclient-rb/impl/data_system/fdv1.rb b/lib/ldclient-rb/impl/data_system/fdv1.rb deleted file mode 100644 index 010f91d0..00000000 --- a/lib/ldclient-rb/impl/data_system/fdv1.rb +++ /dev/null @@ -1,183 +0,0 @@ -require 'concurrent' -require 'ldclient-rb/impl/broadcaster' -require 'ldclient-rb/impl/data_source' -require 'ldclient-rb/impl/data_source/null_processor' -require 'ldclient-rb/impl/data_source/polling' -require 'ldclient-rb/impl/data_source/requestor' -require 'ldclient-rb/impl/data_source/stream' -require 'ldclient-rb/impl/data_store/status' -require 'ldclient-rb/impl/data_system' -require 'ldclient-rb/impl/store_client_wrapper' - -module LaunchDarkly - module Impl - module DataSystem - # - # FDv1 wires the existing v1 data source and store behavior behind the - # generic DataSystem surface. - # - # @see DataSystem - # - class FDv1 - include LaunchDarkly::Impl::DataSystem - - # - # Creates a new FDv1 data system. - # - # @param sdk_key [String] The SDK key - # @param config [LaunchDarkly::Config] The SDK configuration - # - def initialize(sdk_key, config) - @sdk_key = sdk_key - @config = config - @shared_executor = Concurrent::SingleThreadExecutor.new - - # Set up data store plumbing - @data_store_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@shared_executor, @config.logger) - @data_store_update_sink = LaunchDarkly::Impl::DataStore::UpdateSink.new( - @data_store_broadcaster - ) - - # Preserve the original unwrapped store to avoid nested wrappers on postfork - original_store = @config.feature_store - if original_store.is_a?(LaunchDarkly::Impl::FeatureStoreClientWrapper) - original_store = original_store.instance_variable_get(:@store) - end - - # Wrap the original data store with client wrapper (must be created before status provider) - @store_wrapper = LaunchDarkly::Impl::FeatureStoreClientWrapper.new( - original_store, - @data_store_update_sink, - @config.logger - ) - - # Update config to use wrapped store so data sources can access it - @config.instance_variable_set(:@feature_store, @store_wrapper) - - # Create status provider with store wrapper - @data_store_status_provider = LaunchDarkly::Impl::DataStore::StatusProvider.new( - @store_wrapper, - @data_store_update_sink - ) - - # Set up data source plumbing - @data_source_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@shared_executor, @config.logger) - @flag_change_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@shared_executor, @config.logger) - @data_source_update_sink = LaunchDarkly::Impl::DataSource::UpdateSink.new( - @store_wrapper, - @data_source_broadcaster, - @flag_change_broadcaster - ) - @data_source_status_provider = LaunchDarkly::Impl::DataSource::StatusProvider.new( - @data_source_broadcaster, - @data_source_update_sink - ) - - # Ensure v1 processors can find the sink via config for status updates - @config.data_source_update_sink = @data_source_update_sink - - # Update processor created in start() - @update_processor = nil - - # Diagnostic accumulator provided by client for streaming metrics - @diagnostic_accumulator = nil - end - - # (see DataSystem#start) - def start - @update_processor ||= make_update_processor - @update_processor.start - end - - # (see DataSystem#stop) - def stop - @update_processor&.stop - @store_wrapper.stop - @shared_executor.shutdown - end - - # (see DataSystem#store) - def store - @store_wrapper - end - - # (see DataSystem#set_diagnostic_accumulator) - def set_diagnostic_accumulator(diagnostic_accumulator) - @diagnostic_accumulator = diagnostic_accumulator - end - - # (see DataSystem#data_source_status_provider) - def data_source_status_provider - @data_source_status_provider - end - - # (see DataSystem#data_store_status_provider) - def data_store_status_provider - @data_store_status_provider - end - - # (see DataSystem#flag_change_broadcaster) - def flag_change_broadcaster - @flag_change_broadcaster - end - - # (see DataSystem#environment_id) - def environment_id - @data_source_update_sink.environment_id - end - - # - # (see DataSystem#data_availability) - # - # In LDD mode, always returns CACHED for backwards compatibility, - # even if the store is empty. - # - def data_availability - return DataAvailability::DEFAULTS if @config.offline? - return DataAvailability::REFRESHED if @update_processor && @update_processor.initialized? - return DataAvailability::CACHED if @store_wrapper.initialized? - - DataAvailability::DEFAULTS - end - - # (see DataSystem#target_availability) - def target_availability - return DataAvailability::DEFAULTS if @config.offline? - - DataAvailability::REFRESHED - end - - # - # Creates the appropriate update processor based on the configuration. - # - # @return [Object] The update processor - # - private def make_update_processor - # Handle custom data source (factory or instance) - if @config.data_source - return @config.data_source unless @config.data_source.respond_to?(:call) - - # Factory - call with appropriate arity - return @config.data_source.arity == 3 ? - @config.data_source.call(@sdk_key, @config, @diagnostic_accumulator) : - @config.data_source.call(@sdk_key, @config) - end - - # Create default data source based on config - return LaunchDarkly::Impl::DataSource::NullUpdateProcessor.new if @config.offline? || @config.use_ldd? - - if @config.stream? - return LaunchDarkly::Impl::DataSource::StreamProcessor.new(@sdk_key, @config, @diagnostic_accumulator) - end - - # Polling processor - @config.logger.info { "Disabling streaming API" } - @config.logger.warn { "You should only disable the streaming API if instructed to do so by LaunchDarkly support" } - requestor = LaunchDarkly::Impl::DataSource::Requestor.new(@sdk_key, @config) - LaunchDarkly::Impl::DataSource::PollingProcessor.new(@config, requestor) - end - end - end - end -end - diff --git a/lib/ldclient-rb/impl/data_system/fdv2.rb b/lib/ldclient-rb/impl/data_system/fdv2.rb index 63ec1a90..27f0a367 100644 --- a/lib/ldclient-rb/impl/data_system/fdv2.rb +++ b/lib/ldclient-rb/impl/data_system/fdv2.rb @@ -55,10 +55,6 @@ def initialize(sdk_key, config, data_system_config) @data_system_config = data_system_config @logger = config.logger - unless config.payload_filter_key.nil? - @logger.warn { "[LDClient] Payload filtering is not supported with the FDv2 data system; the configured payload filter has no effect on FDv2 requests" } - end - @synchronizer_builders = data_system_config.synchronizers || [] @fdv1_fallback_synchronizer_builder = data_system_config.fdv1_fallback_synchronizer @disabled = @config.offline? diff --git a/lib/ldclient-rb/impl/data_system/polling.rb b/lib/ldclient-rb/impl/data_system/polling.rb index 9613a752..3bd919df 100644 --- a/lib/ldclient-rb/impl/data_system/polling.rb +++ b/lib/ldclient-rb/impl/data_system/polling.rb @@ -5,7 +5,6 @@ require "ldclient-rb/impl/data_system" require "ldclient-rb/impl/data_system/protocolv2" require "ldclient-rb/data_system/polling_data_source_builder" -require "ldclient-rb/impl/data_source/requestor" require "ldclient-rb/impl/util" require "concurrent" require "json" @@ -15,6 +14,18 @@ module LaunchDarkly module Impl module DataSystem + # + # A polling request received an HTTP status that is not a success. + # + class UnexpectedResponseError < StandardError + attr_reader :status + + def initialize(status) + @status = status + super("HTTP error #{status}") + end + end + FDV2_POLLING_ENDPOINT = "/sdk/poll" FDV1_POLLING_ENDPOINT = "/sdk/latest-all" @@ -131,7 +142,7 @@ def sync(ss) envid = LaunchDarkly::Impl::DataSystem.lookup_header(result.headers, LD_ENVID_HEADER) if !result.success? - if result.exception.is_a?(LaunchDarkly::Impl::DataSource::UnexpectedResponseError) + if result.exception.is_a?(UnexpectedResponseError) error_info = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE, result.exception.status, @@ -235,7 +246,7 @@ def stop fallback = LaunchDarkly::Impl::DataSystem.fdv1_fallback_requested?(result.headers) unless result.success? - if result.exception.is_a?(LaunchDarkly::Impl::DataSource::UnexpectedResponseError) + if result.exception.is_a?(UnexpectedResponseError) status_code = result.exception.status http_error_message_result = Impl::Util.http_error_message( status_code, "polling request", "will retry" @@ -326,7 +337,7 @@ def fetch(selector) if status >= 400 return LaunchDarkly::Result.fail( "HTTP error #{status}", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(status), + UnexpectedResponseError.new(status), response_headers ) end @@ -407,7 +418,7 @@ def fetch(selector) if status >= 400 return LaunchDarkly::Result.fail( "HTTP error #{status}", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(status), + UnexpectedResponseError.new(status), response_headers ) end diff --git a/lib/ldclient-rb/impl/diagnostic_events.rb b/lib/ldclient-rb/impl/diagnostic_events.rb index 7bc26047..a00aad0c 100644 --- a/lib/ldclient-rb/impl/diagnostic_events.rb +++ b/lib/ldclient-rb/impl/diagnostic_events.rb @@ -63,23 +63,20 @@ def create_periodic_event_and_reset(dropped_events, deduplicated_users, events_i event end + # TODO(SDK-3430): Decide the FDv2 data system fields before the 9.0 release. The removed FDv1 fields + # were customBaseURI, customStreamURI, pollingIntervalMillis, streamingDisabled and usingRelayDaemon. def self.make_config_data(config) ret = { allAttributesPrivate: config.all_attributes_private, connectTimeoutMillis: self.seconds_to_millis(config.connect_timeout), - customBaseURI: config.base_uri != Config.default_base_uri, customEventsURI: config.events_uri != Config.default_events_uri, - customStreamURI: config.stream_uri != Config.default_stream_uri, diagnosticRecordingIntervalMillis: self.seconds_to_millis(config.diagnostic_recording_interval), eventsCapacity: config.capacity, eventsFlushIntervalMillis: self.seconds_to_millis(config.flush_interval), - pollingIntervalMillis: self.seconds_to_millis(config.poll_interval), socketTimeoutMillis: self.seconds_to_millis(config.read_timeout), - streamingDisabled: !config.stream?, userKeysCapacity: config.context_keys_capacity, userKeysFlushIntervalMillis: self.seconds_to_millis(config.context_keys_flush_interval), usingProxy: ENV.has_key?('http_proxy') || ENV.has_key?('https_proxy') || ENV.has_key?('HTTP_PROXY') || ENV.has_key?('HTTPS_PROXY'), - usingRelayDaemon: config.use_ldd?, } ret end diff --git a/lib/ldclient-rb/impl/integrations/file_data_source.rb b/lib/ldclient-rb/impl/integrations/file_data_source.rb deleted file mode 100644 index d5a984ca..00000000 --- a/lib/ldclient-rb/impl/integrations/file_data_source.rb +++ /dev/null @@ -1,230 +0,0 @@ -require 'ldclient-rb/in_memory_store' -require 'ldclient-rb/impl/util' - -require 'concurrent/atomics' -require 'json' -require 'yaml' -require 'pathname' - -module LaunchDarkly - module Impl - module Integrations - class FileDataSourceImpl - # To avoid pulling in 'listen' and its transitive dependencies for people who aren't using the - # file data source or who don't need auto-updating, we only enable auto-update if the 'listen' - # gem has been provided by the host app. - @@have_listen = false - begin - require 'listen' - @@have_listen = true - rescue LoadError - # Ignored - end - - # - # @param data_store [LaunchDarkly::Interfaces::FeatureStore] - # @param data_source_update_sink [LaunchDarkly::Interfaces::DataSource::UpdateSink, nil] Might be nil for backwards compatibility reasons. - # @param logger [Logger] - # @param options [Hash] - # - def initialize(data_store, data_source_update_sink, logger, options={}) - @data_store = data_source_update_sink || data_store - @data_source_update_sink = data_source_update_sink - @logger = logger - @paths = options[:paths] || [] - if @paths.is_a? String - @paths = [ @paths ] - end - @auto_update = options[:auto_update] - @use_listen = @auto_update && @@have_listen && !options[:force_polling] - @poll_interval = options[:poll_interval] || 1 - @initialized = Concurrent::AtomicBoolean.new(false) - @ready = Concurrent::Event.new - - @version_lock = Mutex.new - @last_version = 1 - end - - def initialized? - @initialized.value - end - - def start - ready = Concurrent::Event.new - - # We will return immediately regardless of whether the file load succeeded or failed - - # the difference can be detected by checking "initialized?" - ready.set - - load_all - - if @auto_update - # If we're going to watch files, then the start event will be set the first time we get - # a successful load. - @listener = start_listener - end - - ready - end - - def stop - @listener.stop unless @listener.nil? - end - - private - - def load_all - all_data = { - LaunchDarkly::Interfaces::DataStore::FEATURES => {}, - LaunchDarkly::Interfaces::DataStore::SEGMENTS => {}, - } - @paths.each do |path| - begin - load_file(path, all_data) - rescue => exn - Impl::Util.log_exception(@logger, "Unable to load flag data from \"#{path}\"", exn) - @data_source_update_sink&.update_status( - LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, - LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA, 0, exn.to_s, Time.now) - ) - return - end - end - @data_store.init(all_data) - @data_source_update_sink&.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - @initialized.make_true - end - - def load_file(path, all_data) - version = 1 - @version_lock.synchronize { - version = @last_version - @last_version += 1 - } - - parsed = parse_content(File.read(path)) - (parsed[:flags] || {}).each do |key, flag| - flag[:version] = version - add_item(all_data, LaunchDarkly::Interfaces::DataStore::FEATURES, flag) - end - (parsed[:flagValues] || {}).each do |key, value| - add_item(all_data, LaunchDarkly::Interfaces::DataStore::FEATURES, make_flag_with_value(key.to_s, value, version)) - end - (parsed[:segments] || {}).each do |key, segment| - segment[:version] = version - add_item(all_data, LaunchDarkly::Interfaces::DataStore::SEGMENTS, segment) - end - end - - def parse_content(content) - # We can use the Ruby YAML parser for both YAML and JSON (JSON is a subset of YAML and while - # not all YAML parsers handle it correctly, we have verified that the Ruby one does, at least - # for all the samples of actual flag data that we've tested). - symbolize_all_keys(YAML.safe_load(content)) - end - - def symbolize_all_keys(value) - # This is necessary because YAML.load doesn't have an option for parsing keys as symbols, and - # the SDK expects all objects to be formatted that way. - if value.is_a?(Hash) - value.map{ |k, v| [k.to_sym, symbolize_all_keys(v)] }.to_h - elsif value.is_a?(Array) - value.map{ |v| symbolize_all_keys(v) } - else - value - end - end - - def add_item(all_data, kind, item) - items = all_data[kind] - raise ArgumentError, "Received unknown item kind #{kind[:namespace]} in add_data" if items.nil? # shouldn't be possible since we preinitialize the hash - key = item[:key].to_sym - unless items[key].nil? - raise ArgumentError, "#{kind[:namespace]} key \"#{item[:key]}\" was used more than once" - end - items[key] = Model.deserialize(kind, item) - end - - def make_flag_with_value(key, value, version) - { - key: key, - on: true, - version: version, - fallthrough: { variation: 0 }, - variations: [ value ], - } - end - - def start_listener - resolved_paths = @paths.map { |p| Pathname.new(File.absolute_path(p)).realpath.to_s } - if @use_listen - start_listener_with_listen_gem(resolved_paths) - else - FileDataSourcePoller.new(resolved_paths, @poll_interval, self.method(:load_all), @logger) - end - end - - def start_listener_with_listen_gem(resolved_paths) - path_set = resolved_paths.to_set - dir_paths = resolved_paths.map{ |p| File.dirname(p) }.uniq - opts = { latency: @poll_interval } - l = Listen.to(*dir_paths, opts) do |modified, added, removed| - paths = modified + added + removed - if paths.any? { |p| path_set.include?(p) } - load_all - end - end - l.start - l - end - - # - # Used internally by FileDataSource to track data file changes if the 'listen' gem is not available. - # - class FileDataSourcePoller - def initialize(resolved_paths, interval, reloader, logger) - @stopped = Concurrent::AtomicBoolean.new(false) - get_file_times = Proc.new do - ret = {} - resolved_paths.each do |path| - begin - ret[path] = File.mtime(path) - rescue Errno::ENOENT - ret[path] = nil - end - end - ret - end - last_times = get_file_times.call - @thread = Thread.new do - while true - sleep interval - break if @stopped.value - begin - new_times = get_file_times.call - changed = false - last_times.each do |path, old_time| - new_time = new_times[path] - if !new_time.nil? && new_time != old_time - changed = true - break - end - end - reloader.call if changed - rescue => exn - Impl::Util.log_exception(logger, "Unexpected exception in FileDataSourcePoller", exn) - end - end - end - @thread.name = "LD/FileDataSource" - end - - def stop - @stopped.make_true - @thread.run # wakes it up if it's sleeping - end - end - end - end - end -end diff --git a/lib/ldclient-rb/impl/integrations/test_data/test_data_source.rb b/lib/ldclient-rb/impl/integrations/test_data/test_data_source.rb deleted file mode 100644 index 4533e415..00000000 --- a/lib/ldclient-rb/impl/integrations/test_data/test_data_source.rb +++ /dev/null @@ -1,39 +0,0 @@ -require 'concurrent/atomics' -require 'ldclient-rb/interfaces' - -module LaunchDarkly - module Impl - module Integrations - module TestData - class TestDataSource - include LaunchDarkly::Interfaces::DataSource - - def initialize(feature_store, test_data) - @feature_store = feature_store - @test_data = test_data - end - - def initialized? - true - end - - def start - ready = Concurrent::Event.new - ready.set - init_data = @test_data.make_init_data - @feature_store.init(init_data) - ready - end - - def stop - @test_data.closed_instance(self) - end - - def upsert(kind, item) - @feature_store.upsert(kind, item) - end - end - end - end - end -end diff --git a/lib/ldclient-rb/impl/retry_state.rb b/lib/ldclient-rb/impl/retry_state.rb deleted file mode 100644 index 91849bc6..00000000 --- a/lib/ldclient-rb/impl/retry_state.rb +++ /dev/null @@ -1,240 +0,0 @@ -require "ldclient-rb/config" -require "ldclient-rb/impl/util" - -module LaunchDarkly - module Impl - # - # Computes how long to wait before a failed operation is tried again. - # - # Each failure is `:normal` or `:unexpected`. After a normal failure, the wait starts at the normal - # initial delay and doubles with each failure, up to the normal ceiling. An unexpected failure moves - # the state to the extended regime: the wait starts at the extended initial delay and doubles up to - # the extended ceiling. The extended bounds stay in place until the reset policy is satisfied, so a - # normal failure that follows cannot lower them. When the policy is satisfied, the state returns to - # the normal regime and the delay sequence starts over. - # - # A random jitter of up to half of each delay is subtracted, so that many callers do not all try - # again at the same moment. The wait never falls below the operating cadence. - # - # After each outcome, call {#record_failure} or {#record_success}, then read {#next_delay} for the - # wait before the next operation. An instance is not thread-safe. - # - # @private - # - class RetryState - # The longest normal delay for streaming, in seconds. - NORMAL_STREAMING_CEILING_DELAY = 30 - - # The delay bounds of the extended regime, in seconds. - EXTENDED_INITIAL_DELAY = 5 * 60 - EXTENDED_CEILING_DELAY = 60 * 60 - - # How long a stream must operate without a failure before its retry state resets, in seconds. - STREAMING_RESET_INTERVAL = 60 - - # How many polls in a row must succeed before polling's retry state resets. - POLLING_RESET_SUCCESSES = 2 - - MONOTONIC_CLOCK = -> { Impl::Util.monotonic_seconds } - private_constant :MONOTONIC_CLOCK - - # - # Builds the retry state for a streaming data source. - # - # A healthy stream never waits, so the operating cadence is zero. A delay that is not a - # positive, finite number is replaced by the default. A delay longer than a ceiling raises that - # ceiling. - # - # @param initial_reconnect_delay [Numeric] seconds - # @param logger [Logger] - # @param clock [#call] returns monotonic seconds - # @param random [#rand] returns a Float in [0, 1) - # @return [RetryState] - # - def self.for_streaming(initial_reconnect_delay, logger, clock: MONOTONIC_CLOCK, random: Random.new) - delay = usable_delay(initial_reconnect_delay, Config.default_initial_reconnect_delay, "initial_reconnect_delay", logger) - new( - normal_initial_delay: delay, - normal_ceiling_delay: NORMAL_STREAMING_CEILING_DELAY, - extended_initial_delay: [EXTENDED_INITIAL_DELAY, delay].max, - extended_ceiling_delay: EXTENDED_CEILING_DELAY, - reset_policy: AfterHealthyFor.new(STREAMING_RESET_INTERVAL, clock), - operating_cadence: 0, - random: random - ) - end - - # - # Builds the retry state for a polling data source. - # - # The poll interval is both the operating cadence and the normal ceiling, so a normal failure - # waits the interval. An interval that is not a positive, finite number is replaced by the - # default. No wait is shorter than the interval, so the interval wins over the extended ceiling. - # - # @param poll_interval [Numeric] seconds - # @param logger [Logger] - # @param random [#rand] returns a Float in [0, 1) - # @return [RetryState] - # - def self.for_polling(poll_interval, logger, random: Random.new) - interval = usable_delay(poll_interval, Config.default_poll_interval, "poll_interval", logger) - new( - normal_initial_delay: interval, - normal_ceiling_delay: interval, - extended_initial_delay: [EXTENDED_INITIAL_DELAY, interval].max, - extended_ceiling_delay: EXTENDED_CEILING_DELAY, - reset_policy: AfterConsecutiveSuccesses.new(POLLING_RESET_SUCCESSES), - operating_cadence: interval, - random: random - ) - end - - private_class_method def self.usable_delay(value, default, name, logger) - return value if value.is_a?(Numeric) && value.real? && value > 0 && value.finite? - - logger.warn { "[LDClient] #{name} must be a positive, finite number of seconds; using the default of #{default}s" } - default - end - - # - # @param normal_initial_delay [Numeric] the first normal delay, in seconds - # @param normal_ceiling_delay [Numeric] the longest normal delay, in seconds - # @param extended_initial_delay [Numeric] the first extended delay, in seconds - # @param extended_ceiling_delay [Numeric] the longest extended delay, in seconds - # @param reset_policy [AfterHealthyFor, AfterConsecutiveSuccesses] decides when the state resets - # @param operating_cadence [Numeric] the wait between healthy operations, in seconds; no wait is - # shorter than this - # @param random [#rand] returns a Float in [0, 1) - # - def initialize(normal_initial_delay:, normal_ceiling_delay:, extended_initial_delay:, extended_ceiling_delay:, - reset_policy:, operating_cadence: 0, random: Random.new) - @normal_initial_delay = normal_initial_delay - @normal_ceiling_delay = normal_ceiling_delay - @extended_initial_delay = extended_initial_delay - @extended_ceiling_delay = extended_ceiling_delay - @reset_policy = reset_policy - @operating_cadence = operating_cadence - @random = random - - @attempts = 0 - @extended = false - @min_delay = @normal_initial_delay - @max_delay = [@normal_ceiling_delay, @normal_initial_delay].max - @next_delay = @operating_cadence - end - - # - # @return [Numeric] the wait before the next operation, in seconds, as the last recorded - # outcome decided it - # - attr_reader :next_delay - - # - # Records a failed attempt and decides the wait before the next one. - # - # @param kind [Symbol] `:normal` or `:unexpected` - # @return [void] - # - def record_failure(kind) - # A caller can record nothing while it is healthy, so a time-based reset can only be noticed here. - reset_if_due - @reset_policy.note_failure - - if kind == :unexpected && !@extended - # Only the move to the extended regime starts the sequence over. A later unexpected failure - # keeps counting up. - @extended = true - @min_delay = @extended_initial_delay - @max_delay = [@extended_ceiling_delay, @min_delay].max - @attempts = 1 - else - @attempts += 1 - end - - delay = [@min_delay * (2**(@attempts - 1)), @max_delay].min - jitter = @random.rand * delay / 2 - @next_delay = [delay - jitter, @operating_cadence].max - end - - # - # Records a successful operation, and resets the retry state if the reset policy is satisfied. - # - # The next wait returns to the operating cadence even when the state has not reset, because a - # backoff delay applies to a retry and not to every operation. - # - # @return [void] - # - def record_success - @reset_policy.note_healthy - reset_if_due - @next_delay = @operating_cadence - end - - private def reset_if_due - return unless @reset_policy.satisfied? - - @attempts = 0 - @extended = false - @min_delay = @normal_initial_delay - @max_delay = [@normal_ceiling_delay, @normal_initial_delay].max - end - - # - # Resets once the component has operated without a failure for a number of seconds. - # - # @private - # - class AfterHealthyFor - # - # @param seconds [Numeric] - # @param clock [#call] returns monotonic seconds - # - def initialize(seconds, clock) - @healthy_seconds = seconds - @clock = clock - @healthy_since = nil - end - - # A later call during the same healthy stretch does not move its start. - def note_healthy - @healthy_since = @clock.call if @healthy_since.nil? - end - - def note_failure - @healthy_since = nil - end - - def satisfied? - !@healthy_since.nil? && @clock.call - @healthy_since >= @healthy_seconds - end - end - - # - # Resets once a number of operations in a row have succeeded. - # - # @private - # - class AfterConsecutiveSuccesses - # - # @param count [Integer] - # - def initialize(count) - @count = count - @successes = 0 - end - - def note_healthy - @successes += 1 - end - - def note_failure - @successes = 0 - end - - def satisfied? - @successes >= @count - end - end - end - end -end diff --git a/lib/ldclient-rb/impl/store_client_wrapper.rb b/lib/ldclient-rb/impl/store_client_wrapper.rb deleted file mode 100644 index 73fd836a..00000000 --- a/lib/ldclient-rb/impl/store_client_wrapper.rb +++ /dev/null @@ -1,129 +0,0 @@ -require "concurrent" -require "ldclient-rb/interfaces" -require "ldclient-rb/impl/store_data_set_sorter" - -module LaunchDarkly - module Impl - # - # Provides additional behavior that the client requires before or after feature store operations. - # This just means sorting the data set for init() and dealing with data store status listeners. - # - class FeatureStoreClientWrapper - include Interfaces::FeatureStore - - def initialize(store, store_update_sink, logger) - # @type [LaunchDarkly::Interfaces::FeatureStore] - @store = store - - @monitoring_enabled = @store.monitoring_enabled? - - # @type [LaunchDarkly::Impl::DataStore::UpdateSink] - @store_update_sink = store_update_sink - @logger = logger - - @mutex = Mutex.new # Covers the following variables - @last_available = true - @stopped = false - # @type [LaunchDarkly::Impl::RepeatingTask, nil] - @poller = nil - end - - def init(all_data) - wrapper { @store.init(FeatureStoreDataSetSorter.sort_all_collections(all_data)) } - end - - def get(kind, key) - wrapper { @store.get(kind, key) } - end - - def all(kind) - wrapper { @store.all(kind) } - end - - def upsert(kind, item) - wrapper { @store.upsert(kind, item) } - end - - def delete(kind, key, version) - wrapper { @store.delete(kind, key, version) } - end - - def initialized? - @store.initialized? - end - - def stop - @store.stop - - poller = @mutex.synchronize do - @stopped = true - task = @poller - @poller = nil - task - end - poller&.stop - end - - def monitoring_enabled? - @monitoring_enabled - end - - def available? - @store.available? - end - - private def wrapper() - begin - yield - rescue => e - update_availability(false) if @monitoring_enabled - raise - end - end - - private def update_availability(available) - @mutex.synchronize do - return if available == @last_available - @last_available = available - end - - status = LaunchDarkly::Interfaces::DataStore::Status.new(available, false) - - @logger.warn("Persistent store is available again") if available - - @store_update_sink.update_status(status) - - if available - poller = @mutex.synchronize do - task = @poller - @poller = nil - task - end - poller&.stop - - return - end - - @logger.warn("Detected persistent store unavailability; updates will be cached until it recovers.") - - task = Impl::RepeatingTask.new(0.5, 0, -> { self.check_availability }, @logger, 'LD/StoreWrapper#check_availability') - - @mutex.synchronize do - # A read can fail after stop, and a poller started then would never be stopped. - next if @stopped - - @poller = task - @poller.start - end - end - - private def check_availability - begin - update_availability(true) if @store.available? - rescue => e - @logger.error("Unexpected error from data store status function: #{e}") - end - end - end - end -end diff --git a/lib/ldclient-rb/impl/util.rb b/lib/ldclient-rb/impl/util.rb index 546ce806..2ac57f2c 100644 --- a/lib/ldclient-rb/impl/util.rb +++ b/lib/ldclient-rb/impl/util.rb @@ -97,42 +97,6 @@ def self.validate_application_info(app, logger) } end - # - # @param value [String, nil] - # @param logger [Logger] - # @return [String, nil] - # - def self.validate_payload_filter_key(value, logger) - return nil if value.nil? - return value if value.is_a?(String) && /^[a-zA-Z0-9][._\-a-zA-Z0-9]*$/.match?(value) - - logger.warn { - "Invalid payload filter configured, full environment will be fetched. Ensure the filter key is not empty and was copied correctly from LaunchDarkly settings." - } - nil - end - - # - # Append the payload filter key query parameter to the provided URI. - # - # @param uri [String] - # @param config [Config] - # @return [String] - # - def self.add_payload_filter_key(uri, config) - return uri if config.payload_filter_key.nil? - - begin - parsed = URI.parse(uri) - new_query_params = URI.decode_www_form(String(parsed.query)) << ["filter", config.payload_filter_key] - parsed.query = URI.encode_www_form(new_query_params) - parsed.to_s - rescue URI::InvalidURIError - config.logger.warn { "[LDClient] URI could not be parsed. No filtering will be applied." } - uri - end - end - # # Creates a new persistent HTTP client with the given configuration. # diff --git a/lib/ldclient-rb/in_memory_store.rb b/lib/ldclient-rb/in_memory_store.rb deleted file mode 100644 index d54e08ba..00000000 --- a/lib/ldclient-rb/in_memory_store.rb +++ /dev/null @@ -1,87 +0,0 @@ -require "concurrent/atomics" -require "ldclient-rb/impl/data_store/status" - -module LaunchDarkly - # - # Default implementation of the LaunchDarkly client's feature store, using an in-memory - # cache. This object holds feature flags and related data received from LaunchDarkly. - # Database-backed implementations are available in {LaunchDarkly::Integrations}. - # - class InMemoryFeatureStore - include LaunchDarkly::Interfaces::FeatureStore - - def initialize - @items = Hash.new - @lock = Concurrent::ReadWriteLock.new - @initialized = Concurrent::AtomicBoolean.new(false) - end - - def monitoring_enabled? - false - end - - def available? - true - end - - def get(kind, key) - @lock.with_read_lock do - coll = @items[kind] - f = coll.nil? ? nil : coll[key.to_sym] - (f.nil? || f[:deleted]) ? nil : f - end - end - - def all(kind) - @lock.with_read_lock do - coll = @items[kind] - (coll.nil? ? Hash.new : coll).select { |_k, f| not f[:deleted] } - end - end - - def delete(kind, key, version) - @lock.with_write_lock do - coll = @items[kind] - if coll.nil? - coll = Hash.new - @items[kind] = coll - end - old = coll[key.to_sym] - return false unless old.nil? || old[:version] < version - - coll[key.to_sym] = { deleted: true, version: version } - true - end - end - - def init(all_data) - @lock.with_write_lock do - @items.replace(all_data) - @initialized.make_true - end - end - - def upsert(kind, item) - @lock.with_write_lock do - coll = @items[kind] - if coll.nil? - coll = Hash.new - @items[kind] = coll - end - old = coll[item[:key].to_sym] - return false unless old.nil? || old[:version] < item[:version] - - coll[item[:key].to_sym] = item - true - end - end - - def initialized? - @initialized.value - end - - def stop - # nothing to do - end - end -end diff --git a/lib/ldclient-rb/integrations.rb b/lib/ldclient-rb/integrations.rb index aa5122ef..d8ff7488 100644 --- a/lib/ldclient-rb/integrations.rb +++ b/lib/ldclient-rb/integrations.rb @@ -2,5 +2,5 @@ require "ldclient-rb/integrations/dynamodb" require "ldclient-rb/integrations/file_data" require "ldclient-rb/integrations/redis" -require "ldclient-rb/integrations/test_data" +require "ldclient-rb/integrations/test_data_v2" require "ldclient-rb/integrations/util/persistent_data_store_wrapper" diff --git a/lib/ldclient-rb/integrations/consul.rb b/lib/ldclient-rb/integrations/consul.rb index c9e350b9..9d54192a 100644 --- a/lib/ldclient-rb/integrations/consul.rb +++ b/lib/ldclient-rb/integrations/consul.rb @@ -23,8 +23,8 @@ def self.default_prefix # # Creates a Consul-backed persistent feature store. # - # To use this method, you must first install the gem `diplomat`. Then, put the object returned by - # this method into the `feature_store` property of your client configuration ({LaunchDarkly::Config}). + # To use this method, you must first install the gem `diplomat`. Then, give the object returned by + # this method to {LaunchDarkly::DataSystem.persistent_store} or {LaunchDarkly::DataSystem.daemon}. # # @param opts [Hash] the configuration options # @option opts [Hash] :consul_config an instance of `Diplomat::Configuration` to replace the default diff --git a/lib/ldclient-rb/integrations/dynamodb.rb b/lib/ldclient-rb/integrations/dynamodb.rb index a1b73562..92416ab6 100644 --- a/lib/ldclient-rb/integrations/dynamodb.rb +++ b/lib/ldclient-rb/integrations/dynamodb.rb @@ -18,12 +18,12 @@ module DynamoDB # [SDK reference guide](https://docs.launchdarkly.com/sdk/features/storing-data#ruby). # # To use this method, you must first install one of the AWS SDK gems: either `aws-sdk-dynamodb`, or - # the full `aws-sdk`. Then, put the object returned by this method into the `feature_store` property - # of your client configuration ({LaunchDarkly::Config}). + # the full `aws-sdk`. Then, give the object returned by this method to + # {LaunchDarkly::DataSystem.persistent_store} or {LaunchDarkly::DataSystem.daemon}. # # @example Configuring the feature store # store = LaunchDarkly::Integrations::DynamoDB::new_feature_store("my-table-name") - # config = LaunchDarkly::Config.new(feature_store: store) + # config = LaunchDarkly::Config.new(data_system_config: LaunchDarkly::DataSystem.persistent_store(store).build) # client = LaunchDarkly::LDClient.new(my_sdk_key, config) # # Note that the specified table must already exist in DynamoDB. It must have a partition key called diff --git a/lib/ldclient-rb/integrations/file_data.rb b/lib/ldclient-rb/integrations/file_data.rb index 1f4662e1..7b48dc4e 100644 --- a/lib/ldclient-rb/integrations/file_data.rb +++ b/lib/ldclient-rb/integrations/file_data.rb @@ -1,4 +1,3 @@ -require 'ldclient-rb/impl/integrations/file_data_source' require 'ldclient-rb/impl/integrations/file_data_source_v2' module LaunchDarkly @@ -10,12 +9,13 @@ module Integrations # Reading flags from a file is only intended for pre-production environments. Production # environments should always be configured to receive flag updates from LaunchDarkly. # - # To use this component, call {FileData#data_source}, and store its return value in the - # {Config#data_source} property of your LaunchDarkly client configuration. In the options - # to `data_source`, set `paths` to the file path(s) of your data file(s): + # To use this component, call {FileData.data_source_v2}, and use its return value as an + # initializer or synchronizer in the data system configuration. In the options to + # `data_source_v2`, set `paths` to the file path(s) of your data file(s): # - # file_source = LaunchDarkly::Integrations::FileData.data_source(paths: [ myFilePath ]) - # config = LaunchDarkly::Config.new(data_source: file_source) + # file_source = LaunchDarkly::Integrations::FileData.data_source_v2(paths: [ myFilePath ]) + # data_system = LaunchDarkly::DataSystem.custom.synchronizers([file_source]).build + # config = LaunchDarkly::Config.new(data_system_config: data_system) # # This will cause the client not to connect to LaunchDarkly to get feature flags. The # client may still make network connections to send analytics events, unless you have disabled @@ -82,29 +82,6 @@ module Integrations # duplicate key-- it will not load flags from any of the files. # module FileData - # - # Returns a factory for the file data source component. - # - # @param options [Hash] the configuration options - # @option options [Array] :paths The paths of the source files for loading flag data. These - # may be absolute paths or relative to the current working directory. - # @option options [Boolean] :auto_update True if the data source should watch for changes to - # the source file(s) and reload flags whenever there is a change. Auto-updating will only - # work if all of the files you specified have valid directory paths at startup time. - # Note that the default implementation of this feature is based on polling the filesystem, - # which may not perform well. If you install the 'listen' gem (not included by default, to - # avoid adding unwanted dependencies to the SDK), its native file watching mechanism will be - # used instead. - # @option options [Float] :poll_interval The minimum interval, in seconds, between checks for - # file modifications - used only if auto_update is true, and if the native file-watching - # mechanism from 'listen' is not being used. The default value is 1 second. - # @return an object that can be stored in {Config#data_source} - # - def self.data_source(options={}) - lambda { |sdk_key, config| - Impl::Integrations::FileDataSourceImpl.new(config.feature_store, config.data_source_update_sink, config.logger, options) } - end - # # Returns a builder for the FDv2-compatible file data source. # diff --git a/lib/ldclient-rb/integrations/redis.rb b/lib/ldclient-rb/integrations/redis.rb index d08c15ae..ce272d55 100644 --- a/lib/ldclient-rb/integrations/redis.rb +++ b/lib/ldclient-rb/integrations/redis.rb @@ -37,12 +37,12 @@ def self.default_prefix # [SDK reference guide](https://docs.launchdarkly.com/sdk/features/storing-data#rubys). # # To use this method, you must first have the `redis` and `connection-pool` gems installed. Then, - # put the object returned by this method into the `feature_store` property of your - # client configuration. + # give the object returned by this method to {LaunchDarkly::DataSystem.persistent_store} or + # {LaunchDarkly::DataSystem.daemon}. # # @example Configuring the feature store # store = LaunchDarkly::Integrations::Redis::new_feature_store(redis_url: "redis://my-server") - # config = LaunchDarkly::Config.new(feature_store: store) + # config = LaunchDarkly::Config.new(data_system_config: LaunchDarkly::DataSystem.persistent_store(store).build) # client = LaunchDarkly::LDClient.new(my_sdk_key, config) # # @param opts [Hash] the configuration options diff --git a/lib/ldclient-rb/integrations/test_data.rb b/lib/ldclient-rb/integrations/test_data.rb deleted file mode 100644 index cbe3face..00000000 --- a/lib/ldclient-rb/integrations/test_data.rb +++ /dev/null @@ -1,213 +0,0 @@ -require 'ldclient-rb/impl/integrations/test_data/test_data_source' -require 'ldclient-rb/impl/model/feature_flag' -require 'ldclient-rb/impl/model/segment' -require 'ldclient-rb/integrations/test_data/flag_builder' - -require 'concurrent/atomics' - -module LaunchDarkly - module Integrations - # - # A mechanism for providing dynamically updatable feature flag state in a simplified form to an SDK - # client in test scenarios. - # - # Unlike {LaunchDarkly::Integrations::FileData}, this mechanism does not use any external resources. It - # provides only the data that the application has put into it using the {#update} method. - # - # @example - # td = LaunchDarkly::Integrations::TestData.data_source - # td.update(td.flag("flag-key-1").variation_for_all(true)) - # config = LaunchDarkly::Config.new(data_source: td) - # client = LaunchDarkly::LDClient.new('sdkKey', config) - # # flags can be updated at any time: - # td.update(td.flag("flag-key-2") - # .variation_for_key("user", some-user-key", true) - # .fallthrough_variation(false)) - # - # The above example uses a simple boolean flag, but more complex configurations are possible using - # the methods of the {FlagBuilder} that is returned by {#flag}. {FlagBuilder} - # supports many of the ways a flag can be configured on the LaunchDarkly dashboard, but does not - # currently support 1. rule operators other than "in" and "not in", or 2. percentage rollouts. - # - # If the same `TestData` instance is used to configure multiple `LDClient` instances, - # any changes made to the data will propagate to all of the `LDClient`s. - # - # @since 6.3.0 - # - class TestData - # Creates a new instance of the test data source. - # - # @return [TestData] a new configurable test data source - def self.data_source - self.new - end - - # @api private - def initialize - @flag_builders = Hash.new - @current_flags = Hash.new - @current_segments = Hash.new - @instances = Array.new - @instances_lock = Concurrent::ReadWriteLock.new - @lock = Concurrent::ReadWriteLock.new - end - - # - # Called internally by the SDK to determine what arguments to pass to call - # You do not need to call this method. - # - # @api private - def arity - 2 - end - - # - # Called internally by the SDK to associate this test data source with an {@code LDClient} instance. - # You do not need to call this method. - # - # @api private - def call(_, config) - impl = LaunchDarkly::Impl::Integrations::TestData::TestDataSource.new(config.feature_store, self) - @instances_lock.with_write_lock { @instances.push(impl) } - impl - end - - # - # Creates or copies a {FlagBuilder} for building a test flag configuration. - # - # If this flag key has already been defined in this `TestData` instance, then the builder - # starts with the same configuration that was last provided for this flag. - # - # Otherwise, it starts with a new default configuration in which the flag has `true` and - # `false` variations, is `true` for all contexts when targeting is turned on and - # `false` otherwise, and currently has targeting turned on. You can change any of those - # properties, and provide more complex behavior, using the {FlagBuilder} methods. - # - # Once you have set the desired configuration, pass the builder to {#update}. - # - # @param key [String] the flag key - # @return [FlagBuilder] a flag configuration builder - # - def flag(key) - existing_builder = @lock.with_read_lock { @flag_builders[key] } - if existing_builder.nil? - FlagBuilder.new(key).boolean_flag - else - existing_builder.clone - end - end - - # - # Updates the test data with the specified flag configuration. - # - # This has the same effect as if a flag were added or modified on the LaunchDarkly dashboard. - # It immediately propagates the flag change to any `LDClient` instance(s) that you have - # already configured to use this `TestData`. If no `LDClient` has been started yet, - # it simply adds this flag to the test data which will be provided to any `LDClient` that - # you subsequently configure. - # - # Any subsequent changes to this {FlagBuilder} instance do not affect the test data, - # unless you call {#update} again. - # - # @param flag_builder [FlagBuilder] a flag configuration builder - # @return [TestData] the TestData instance - # - def update(flag_builder) - new_flag = nil - @lock.with_write_lock do - @flag_builders[flag_builder.key] = flag_builder - version = 0 - flag_key = flag_builder.key.to_sym - if @current_flags[flag_key] - version = @current_flags[flag_key][:version] - end - new_flag = LaunchDarkly::Impl::Model.deserialize(LaunchDarkly::Interfaces::DataStore::FEATURES, flag_builder.build(version+1)) - @current_flags[flag_key] = new_flag - end - update_item(LaunchDarkly::Interfaces::DataStore::FEATURES, new_flag) - self - end - - # - # Copies a full feature flag data model object into the test data. - # - # It immediately propagates the flag change to any `LDClient` instance(s) that you have already - # configured to use this `TestData`. 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 {FlagBuilder} API. Otherwise it is recommended to 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 has been added in this way; - # you can only replace it with an entirely new flag configuration. - # - # @param flag [Hash] the flag configuration - # @return [TestData] the TestData instance - # - def use_preconfigured_flag(flag) - use_preconfigured_item(LaunchDarkly::Interfaces::DataStore::FEATURES, flag, @current_flags) - end - - # - # Copies a full segment data model object into the test data. - # - # It immediately propagates the change to any `LDClient` instance(s) that you have already - # configured to use this `TestData`. If no `LDClient` has been started yet, it simply adds - # this segment to the test data which will be provided to any LDClient that you subsequently - # configure. - # - # This method is currently the only way to inject segment data, since there is no builder - # API for segments. It is mainly intended for the SDK's own tests of segment functionality, - # since application tests that need to produce a desired evaluation state could do so more easily - # by just setting flag values. - # - # @param segment [Hash] the segment configuration - # @return [TestData] the TestData instance - # - def use_preconfigured_segment(segment) - use_preconfigured_item(LaunchDarkly::Interfaces::DataStore::SEGMENTS, segment, @current_segments) - end - - private def use_preconfigured_item(kind, item, current) - item = Impl::Model.deserialize(kind, item) - key = item.key.to_sym - @lock.with_write_lock do - old_item = current[key] - unless old_item.nil? - data = item.as_json - data[:version] = old_item.version + 1 - item = Impl::Model.deserialize(kind, data) - end - current[key] = item - end - update_item(kind, item) - self - end - - private def update_item(kind, item) - @instances_lock.with_read_lock do - @instances.each do | instance | - instance.upsert(kind, item) - end - end - end - - # @api private - def make_init_data - @lock.with_read_lock do - { - LaunchDarkly::Interfaces::DataStore::FEATURES => @current_flags.clone, - LaunchDarkly::Interfaces::DataStore::SEGMENTS => @current_segments.clone, - } - end - end - - # @api private - def closed_instance(instance) - @instances_lock.with_write_lock { @instances.delete(instance) } - end - end - end -end diff --git a/lib/ldclient-rb/integrations/test_data/flag_builder.rb b/lib/ldclient-rb/integrations/test_data/flag_builder.rb deleted file mode 100644 index f8666641..00000000 --- a/lib/ldclient-rb/integrations/test_data/flag_builder.rb +++ /dev/null @@ -1,663 +0,0 @@ -require 'ldclient-rb/util' - -module LaunchDarkly - module Integrations - class TestData - # - # A builder for feature flag configurations to be used with {TestData}. - # - # @see TestData#flag - # @see TestData#update - # - class FlagBuilder - attr_reader :key - - # @api private - def initialize(key) - @key = key - @on = true - @variations = [] - end - - # @api private - def initialize_copy(other) - super(other) - @variations = @variations.clone - @rules = @rules.nil? ? nil : deep_copy_array(@rules) - @targets = @targets.nil? ? nil : deep_copy_hash(@targets) - end - - # - # Sets targeting to be on or off for this flag. - # - # The effect of this depends on the rest of the flag configuration, just as it does on the - # real LaunchDarkly dashboard. In the default configuration that you get from calling - # {TestData#flag} with a new flag key, the flag will return `false` - # whenever targeting is off, and `true` when targeting is on. - # - # @param on [Boolean] true if targeting should be on - # @return [FlagBuilder] the builder - # - def on(on) - @on = on - self - end - - # - # Set the migration related settings for this feature flag. - # - # The settings hash should be built using the {FlagMigrationSettingsBuilder}. - # - # @param settings [Hash] - # @return [FlagBuilder] the builder - # - def migration_settings(settings) - @migration_settings = settings - self - end - - # - # Set the sampling ratio for this flag. This ratio is used to control the emission rate of feature, debug, and - # migration op events. - # - # General usage should not require interacting with this method. - # - # @param ratio [Integer] - # @return [FlagBuilder] - # - def sampling_ratio(ratio) - @sampling_ratio = ratio - self - end - - # - # Set the option to exclude this flag from summary events. This is used to control the size of the summary event - # in the event certain flag payloads are large. - # - # General usage should not require interacting with this method. - # - # @param exclude [Boolean] - # @return [FlagBuilder] - # - def exclude_from_summaries(exclude) - @exclude_from_summaries = exclude - self - end - - # - # Specifies the fallthrough variation. The fallthrough is the value - # that is returned if targeting is on and the context was not matched by a more specific - # target or rule. - # - # If the flag was previously configured with other variations and the variation specified is a boolean, - # this also changes it to a boolean flag. - # - # @param variation [Boolean, Integer] true or false or the desired fallthrough variation index: - # 0 for the first, 1 for the second, etc. - # @return [FlagBuilder] the builder - # - def fallthrough_variation(variation) - if LaunchDarkly::Impl::Util.bool? variation - boolean_flag.fallthrough_variation(variation_for_boolean(variation)) - else - @fallthrough_variation = variation - self - end - end - - # - # Specifies the off variation for a flag. This is the variation that is returned - # whenever targeting is off. - # - # If the flag was previously configured with other variations and the variation specified is a boolean, - # this also changes it to a boolean flag. - # - # @param variation [Boolean, Integer] true or false or the desired off variation index: - # 0 for the first, 1 for the second, etc. - # @return [FlagBuilder] the builder - # - def off_variation(variation) - if LaunchDarkly::Impl::Util.bool? variation - boolean_flag.off_variation(variation_for_boolean(variation)) - else - @off_variation = variation - self - end - end - - # - # Changes the allowable variation values for the flag. - # - # The value may be of any valid JSON type. For instance, a boolean flag - # normally has `true, false`; a string-valued flag might have - # `'red', 'green'`; etc. - # - # @example A single variation - # td.flag('new-flag') - # .variations(true) - # - # @example Multiple variations - # td.flag('new-flag') - # .variations('red', 'green', 'blue') - # - # @param variations [Array] the the desired variations - # @return [FlagBuilder] the builder - # - def variations(*variations) - @variations = variations - self - end - - # - # Sets the flag to always return the specified variation for all contexts. - # - # The variation is specified, Targeting is switched on, and any existing targets or rules are removed. - # The fallthrough variation is set to the specified value. The off variation is left unchanged. - # - # If the flag was previously configured with other variations and the variation specified is a boolean, - # this also changes it to a boolean flag. - # - # @param variation [Boolean, Integer] true or false or the desired variation index to return: - # 0 for the first, 1 for the second, etc. - # @return [FlagBuilder] the builder - # - def variation_for_all(variation) - if LaunchDarkly::Impl::Util.bool? variation - boolean_flag.variation_for_all(variation_for_boolean(variation)) - else - on(true).clear_rules.clear_targets.fallthrough_variation(variation) - end - end - - # - # Sets the flag to always return the specified variation value for all context. - # - # The value may be of any valid JSON type. This method changes the - # flag to have only a single variation, which is this value, and to return the same - # variation regardless of whether targeting is on or off. Any existing targets or rules - # are removed. - # - # @param value [Object] the desired value to be returned for all contexts - # @return [FlagBuilder] the builder - # - def value_for_all(value) - variations(value).variation_for_all(0) - end - - # - # Sets the flag to return the specified variation for a specific context key when targeting - # is on. - # - # This has no effect when targeting is turned off for the flag. - # - # If the flag was previously configured with other variations and the variation specified is a boolean, - # this also changes it to a boolean flag. - # - # @param context_kind [String] a context kind - # @param context_key [String] a context key - # @param variation [Boolean, Integer] true or false or the desired variation index to return: - # 0 for the first, 1 for the second, etc. - # @return [FlagBuilder] the builder - # - def variation_for_key(context_kind, context_key, variation) - if LaunchDarkly::Impl::Util.bool? variation - return boolean_flag.variation_for_key(context_kind, context_key, variation_for_boolean(variation)) - end - - if @targets.nil? - @targets = Hash.new - end - - targets = @targets[context_kind] || [] - @variations.count.times do | i | - if i == variation - if targets[i].nil? - targets[i] = [context_key] - else - targets[i].push(context_key) - end - elsif not targets[i].nil? - targets[i].delete(context_key) - end - end - - @targets[context_kind] = targets - - self - end - - # - # Sets the flag to return the specified variation for a specific user key when targeting - # is on. - # - # This is a shortcut for calling {variation_for_key} with - # `LaunchDarkly::LDContext::KIND_DEFAULT` as the context kind. - # - # This has no effect when targeting is turned off for the flag. - # - # If the flag was previously configured with other variations and the variation specified is a boolean, - # this also changes it to a boolean flag. - # - # @param user_key [String] a user key - # @param variation [Boolean, Integer] true or false or the desired variation index to return: - # 0 for the first, 1 for the second, etc. - # @return [FlagBuilder] the builder - # - def variation_for_user(user_key, variation) - variation_for_key(LaunchDarkly::LDContext::KIND_DEFAULT, user_key, variation) - end - - # - # Starts defining a flag rule, using the "is one of" operator. - # - # @example create a rule that returns `true` if the name is "Patsy" or "Edina" and the context kind is "user" - # testData.flag("flag") - # .if_match_context("user", :name, 'Patsy', 'Edina') - # .then_return(true); - # - # @param context_kind [String] a context kind - # @param attribute [Symbol] the context attribute to match against - # @param values [Array] values to compare to - # @return [FlagRuleBuilder] a flag rule builder - # - # @see FlagRuleBuilder#then_return - # @see FlagRuleBuilder#and_match - # @see FlagRuleBuilder#and_not_match - # - def if_match_context(context_kind, attribute, *values) - FlagRuleBuilder.new(self).and_match_context(context_kind, attribute, *values) - end - - # - # Starts defining a flag rule, using the "is one of" operator. - # - # This is a shortcut for calling {if_match_context} with - # `LaunchDarkly::LDContext::KIND_DEFAULT` as the context kind. - # - # @example create a rule that returns `true` if the name is "Patsy" or "Edina" - # testData.flag("flag") - # .if_match(:name, 'Patsy', 'Edina') - # .then_return(true); - # - # @param attribute [Symbol] the user attribute to match against - # @param values [Array] values to compare to - # @return [FlagRuleBuilder] a flag rule builder - # - # @see FlagRuleBuilder#then_return - # @see FlagRuleBuilder#and_match - # @see FlagRuleBuilder#and_not_match - # - def if_match(attribute, *values) - if_match_context(LaunchDarkly::LDContext::KIND_DEFAULT, attribute, *values) - end - - # - # Starts defining a flag rule, using the "is not one of" operator. - # - # @example create a rule that returns `true` if the name is neither "Saffron" nor "Bubble" - # testData.flag("flag") - # .if_not_match_context("user", :name, 'Saffron', 'Bubble') - # .then_return(true) - # - # @param context_kind [String] a context kind - # @param attribute [Symbol] the context attribute to match against - # @param values [Array] values to compare to - # @return [FlagRuleBuilder] a flag rule builder - # - # @see FlagRuleBuilder#then_return - # @see FlagRuleBuilder#and_match - # @see FlagRuleBuilder#and_not_match - # - def if_not_match_context(context_kind, attribute, *values) - FlagRuleBuilder.new(self).and_not_match_context(context_kind, attribute, *values) - end - - # - # Starts defining a flag rule, using the "is not one of" operator. - # - # This is a shortcut for calling {if_not_match_context} with - # `LaunchDarkly::LDContext::KIND_DEFAULT` as the context kind. - # - # @example create a rule that returns `true` if the name is neither "Saffron" nor "Bubble" - # testData.flag("flag") - # .if_not_match(:name, 'Saffron', 'Bubble') - # .then_return(true) - # - # @param attribute [Symbol] the user attribute to match against - # @param values [Array] values to compare to - # @return [FlagRuleBuilder] a flag rule builder - # - # @see FlagRuleBuilder#then_return - # @see FlagRuleBuilder#and_match - # @see FlagRuleBuilder#and_not_match - # - def if_not_match(attribute, *values) - if_not_match_context(LaunchDarkly::LDContext::KIND_DEFAULT, attribute, *values) - end - - # - # Removes any existing targets from the flag. - # This undoes the effect of methods like {#variation_for_key} - # - # @return [FlagBuilder] the same builder - # - def clear_targets - @targets = nil - self - end - - # - # Removes any existing rules from the flag. - # This undoes the effect of methods like {#if_match} - # - # @return [FlagBuilder] the same builder - # - def clear_rules - @rules = nil - self - end - - # @api private - def add_rule(rule) - if @rules.nil? - @rules = Array.new - end - @rules.push(rule) - self - end - - # - # A shortcut for setting the flag to use the standard boolean configuration. - # - # This is the default for all new flags created with {TestData#flag}. - # The flag will have two variations, `true` and `false` (in that order); - # it will return `false` whenever targeting is off, and `true` when targeting is on - # if no other settings specify otherwise. - # - # @return [FlagBuilder] the builder - # - def boolean_flag - if boolean_flag? - self - else - variations(true, false) - .fallthrough_variation(TRUE_VARIATION_INDEX) - .off_variation(FALSE_VARIATION_INDEX) - end - end - - # @api private - def build(version) - res = { key: @key, - version: version, - on: @on, - variations: @variations, - } - - unless @off_variation.nil? - res[:offVariation] = @off_variation - end - - unless @fallthrough_variation.nil? - res[:fallthrough] = { variation: @fallthrough_variation } - end - - unless @migration_settings.nil? - res[:migration] = @migration_settings - end - - unless @sampling_ratio.nil? || @sampling_ratio == 1 - res[:samplingRatio] = @sampling_ratio - end - - unless @exclude_from_summaries.nil? || !@exclude_from_summaries - res[:excludeFromSummaries] = @exclude_from_summaries - end - - unless @targets.nil? - targets = [] - context_targets = [] - - @targets.each do |kind, targets_for_kind| - targets_for_kind.each_with_index do |values, variation| - next if values.nil? - if kind == LaunchDarkly::LDContext::KIND_DEFAULT - targets << { variation: variation, values: values } - context_targets << { contextKind: LaunchDarkly::LDContext::KIND_DEFAULT, variation: variation, values: [] } - else - context_targets << { contextKind: kind, variation: variation, values: values } - end - end - end - - res[:targets] = targets - res[:contextTargets] = context_targets - end - - unless @rules.nil? - res[:rules] = @rules.each_with_index.map { | rule, i | rule.build(i) } - end - - res - end - - # - # A builder for feature flag migration settings to be used with {FlagBuilder}. - # - # In the LaunchDarkly model, a flag can be a standard feature flag, or it can be a migration-related flag, in - # which case it has migration-specified related settings. These settings control things like the rate at which - # reads are tested 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 - - def build - return nil if @check_ratio.nil? || @check_ratio == 1 - - { - "checkRatio": @check_ratio, - } - end - end - - # - # A builder for feature flag rules to be used with {FlagBuilder}. - # - # In the LaunchDarkly model, a flag can have any number of rules, and a rule can have any number of - # clauses. A clause is an individual test such as "name is 'X'". A rule matches a context if all of the - # rule's clauses match the context. - # - # To start defining a rule, use one of the flag builder's matching methods such as - # {FlagBuilder#if_match}. This defines the first clause for the rule. - # Optionally, you may add more clauses with the rule builder's methods such as - # {#and_match} or {#and_not_match}. - # Finally, call {#then_return} to finish defining the rule. - # - class FlagRuleBuilder - # @api private - FlagRuleClause = Struct.new(:contextKind, :attribute, :op, :values, :negate, keyword_init: true) # rubocop:disable Naming/MethodName - - # @api private - def initialize(flag_builder) - @flag_builder = flag_builder - @clauses = Array.new - end - - # @api private - def intialize_copy(other) - super(other) - @clauses = @clauses.clone - end - - # - # Adds another clause, using the "is one of" operator. - # - # @example create a rule that returns `true` if the name is "Patsy", the country is "gb", and the context kind is "user" - # testData.flag("flag") - # .if_match_context("user", :name, 'Patsy') - # .and_match_context("user", :country, 'gb') - # .then_return(true) - # - # @param context_kind [String] a context kind - # @param attribute [Symbol] the context attribute to match against - # @param values [Array] values to compare to - # @return [FlagRuleBuilder] the rule builder - # - def and_match_context(context_kind, attribute, *values) - @clauses.push(FlagRuleClause.new( - contextKind: context_kind, - attribute: attribute, - op: 'in', - values: values, - negate: false - )) - self - end - - # - # Adds another clause, using the "is one of" operator. - # - # This is a shortcut for calling {and_match_context} with - # `LaunchDarkly::LDContext::KIND_DEFAULT` as the context kind. - # - # @example create a rule that returns `true` if the name is "Patsy" and the country is "gb" - # testData.flag("flag") - # .if_match(:name, 'Patsy') - # .and_match(:country, 'gb') - # .then_return(true) - # - # @param attribute [Symbol] the user attribute to match against - # @param values [Array] values to compare to - # @return [FlagRuleBuilder] the rule builder - # - def and_match(attribute, *values) - and_match_context(LaunchDarkly::LDContext::KIND_DEFAULT, attribute, *values) - end - - # - # Adds another clause, using the "is not one of" operator. - # - # @example create a rule that returns `true` if the name is "Patsy" and the country is not "gb" - # testData.flag("flag") - # .if_match_context("user", :name, 'Patsy') - # .and_not_match_context("user", :country, 'gb') - # .then_return(true) - # - # @param context_kind [String] a context kind - # @param attribute [Symbol] the context attribute to match against - # @param values [Array] values to compare to - # @return [FlagRuleBuilder] the rule builder - # - def and_not_match_context(context_kind, attribute, *values) - @clauses.push(FlagRuleClause.new( - contextKind: context_kind, - attribute: attribute, - op: 'in', - values: values, - negate: true - )) - self - end - - # - # Adds another clause, using the "is not one of" operator. - # - # This is a shortcut for calling {and_not_match} with - # `LaunchDarkly::LDContext::KIND_DEFAULT` as the context kind. - # - # @example create a rule that returns `true` if the name is "Patsy" and the country is not "gb" - # testData.flag("flag") - # .if_match(:name, 'Patsy') - # .and_not_match(:country, 'gb') - # .then_return(true) - # - # @param attribute [Symbol] the user attribute to match against - # @param values [Array] values to compare to - # @return [FlagRuleBuilder] the rule builder - # - def and_not_match(attribute, *values) - and_not_match_context(LaunchDarkly::LDContext::KIND_DEFAULT, attribute, *values) - end - - # - # Finishes defining the rule, specifying the result as either a boolean - # or a variation index. - # - # If the flag was previously configured with other variations and the variation specified is a boolean, - # this also changes it to a boolean flag. - # - # @param variation [Boolean, Integer] true or false or the desired variation index: - # 0 for the first, 1 for the second, etc. - # @return [FlagBuilder] the flag builder with this rule added - # - def then_return(variation) - if LaunchDarkly::Impl::Util.bool? variation - @variation = @flag_builder.variation_for_boolean(variation) - @flag_builder.boolean_flag.add_rule(self) - else - @variation = variation - @flag_builder.add_rule(self) - end - end - - # @api private - def build(ri) - { - id: 'rule' + ri.to_s, - variation: @variation, - clauses: @clauses.map(&:to_h), - } - end - end - - # @api private - def variation_for_boolean(variation) - variation ? TRUE_VARIATION_INDEX : FALSE_VARIATION_INDEX - end - - private - - TRUE_VARIATION_INDEX = 0 - FALSE_VARIATION_INDEX = 1 - - def boolean_flag? - @variations.size == 2 && - @variations[TRUE_VARIATION_INDEX] == true && - @variations[FALSE_VARIATION_INDEX] == false - end - - def deep_copy_hash(from) - to = Hash.new - from.each do |k, v| - if v.is_a?(Hash) - to[k] = deep_copy_hash(v) - elsif v.is_a?(Array) - to[k] = deep_copy_array(v) - else - to[k] = v.clone - end - end - to - end - - def deep_copy_array(from) - to = Array.new - from.each { |v| to.push(v.clone) } - to - end - end - end - end -end diff --git a/lib/ldclient-rb/interfaces/data_source.rb b/lib/ldclient-rb/interfaces/data_source.rb index efb8985c..f3204c60 100644 --- a/lib/ldclient-rb/interfaces/data_source.rb +++ b/lib/ldclient-rb/interfaces/data_source.rb @@ -1,42 +1,9 @@ module LaunchDarkly module Interfaces # - # Mixin that defines the required methods of a data source implementation. This is the - # component that delivers feature flag data from LaunchDarkly to the LDClient by putting - # the data in the {FeatureStore}. It is expected to run concurrently on its own thread. - # - # The client has its own standard implementation, which uses either a streaming connection or - # polling depending on your configuration. Normally you will not need to use another one - # except for testing purposes. Two such test fixtures are {LaunchDarkly::Integrations::FileData} - # and {LaunchDarkly::Integrations::TestData}. + # Types that describe the status of the data source that the client uses. # module DataSource - # - # Checks whether the data source has finished initializing. Initialization is considered done - # once it has received one complete data set from LaunchDarkly. - # - # @return [Boolean] true if initialization is complete - # - def initialized? - end - - # - # Puts the data source into an active state. Normally this means it will make its first - # connection attempt to LaunchDarkly. If `start` has already been called, calling it again - # should simply return the same value as the first call. - # - # @return [Concurrent::Event] an Event which will be set once initialization is complete - # - def start - end - - # - # Puts the data source into an inactive state and releases all of its resources. - # This state should be considered permanent (`start` does not have to work after `stop`). - # - def stop - end - # # An interface for querying the status of the SDK's data source. The data # source is the component that receives updates to feature flag data; @@ -82,80 +49,6 @@ def add_listener(listener) end def remove_listener(listener) end end - # - # Interface that a data source implementation will use to push data into - # the SDK. - # - # The data source interacts with this object, rather than manipulating - # the data store directly, so that the SDK can perform any other - # necessary operations that must happen when data is updated. - # - module UpdateSink - # - # Initializes (or re-initializes) the store with the specified set of entities. Any - # existing entries will be removed. Implementations can assume that this data set is up to - # date-- there is no need to perform individual version comparisons between the existing - # objects and the supplied features. - # - # If possible, the store should update the entire data set atomically. If that is not possible, - # it should iterate through the outer hash and then the inner hash using the existing iteration - # order of those hashes (the SDK will ensure that the items were inserted into the hashes in - # the correct order), storing each item, and then delete any leftover items at the very end. - # - # @param all_data [Hash] a hash where each key is one of the data kind objects, and each - # value is in turn a hash of string keys to entities - # @return [void] - # - def init(all_data) end - - # - # Attempt to add an entity, or update an existing entity with the same key. An update - # should only succeed if the new item's `:version` is greater than the old one; - # otherwise, the method should do nothing. - # - # @param kind [Object] the kind of entity to add or update - # @param item [Hash] the entity to add or update - # @return [void] - # - def upsert(kind, item) end - - # - # Attempt to delete an entity if it exists. Deletion should only succeed if the - # `version` parameter is greater than the existing entity's `:version`; otherwise, the - # method should do nothing. - # - # @param kind [Object] the kind of entity to delete - # @param key [String] the unique key of the entity - # @param version [Integer] the entity must have a lower version than this to be deleted - # @return [void] - # - def delete(kind, key, version) end - - # - # Informs the SDK of a change in the data source's status. - # - # Data source implementations should use this method if they have any - # concept of being in a valid state, a temporarily disconnected state, - # or a permanently stopped state. - # - # If `new_state` is different from the previous state, and/or - # `new_error` is non-null, the SDK will start returning the new status - # (adding a timestamp for the change) from {StatusProvider#status}, and - # will trigger status change events to any registered listeners. - # - # A special case is that if {new_state} is {Status::INTERRUPTED}, but the - # previous state was {Status::INITIALIZING}, the state will remain at - # {Status::INITIALIZING} because {Status::INTERRUPTED} is only meaningful - # after a successful startup. - # - # After a {Status::OFF} status, the SDK ignores every later update. - # - # @param new_state [Symbol] - # @param new_error [ErrorInfo, nil] - # - def update_status(new_state, new_error) end - end - # # Information about the data source's status and about the last status change. # diff --git a/lib/ldclient-rb/interfaces/data_store.rb b/lib/ldclient-rb/interfaces/data_store.rb index f9d266dc..f3bc91b3 100644 --- a/lib/ldclient-rb/interfaces/data_store.rb +++ b/lib/ldclient-rb/interfaces/data_store.rb @@ -198,21 +198,6 @@ def remove_listener(listener) end end - # - # Interface that a data store implementation can use to report information back to the SDK. - # - module UpdateSink - # - # Reports a change in the data store's operational status. - # - # This is what makes the status monitoring mechanisms in {StatusProvider} work. - # - # @param status [Status] the updated status properties - # - def update_status(status) - end - end - class Status def initialize(available, stale) @available = available diff --git a/lib/ldclient-rb/interfaces/feature_store.rb b/lib/ldclient-rb/interfaces/feature_store.rb index 707af583..56b4a2e9 100644 --- a/lib/ldclient-rb/interfaces/feature_store.rb +++ b/lib/ldclient-rb/interfaces/feature_store.rb @@ -18,7 +18,8 @@ module Interfaces # `:namespace`, which is a short string unique to that kind. This string can be used as a # collection name or a key prefix. # - # The default implementation is {LaunchDarkly::InMemoryFeatureStore}. Several implementations + # A persistent store implements this interface. Give it to {LaunchDarkly::DataSystem.persistent_store} + # or {LaunchDarkly::DataSystem.daemon}. Several implementations # that use databases can be found in {LaunchDarkly::Integrations}. If you want to write a new # implementation, see {LaunchDarkly::Integrations::Util} for tools that can make this task # simpler. diff --git a/lib/ldclient-rb/ldclient.rb b/lib/ldclient-rb/ldclient.rb index c155d1a3..d19292b9 100644 --- a/lib/ldclient-rb/ldclient.rb +++ b/lib/ldclient-rb/ldclient.rb @@ -1,9 +1,7 @@ +require "ldclient-rb/data_system" require "ldclient-rb/impl/big_segments" require "ldclient-rb/impl/broadcaster" require "ldclient-rb/impl/context" -require "ldclient-rb/impl/data_source" -require "ldclient-rb/impl/data_store/status" -require "ldclient-rb/impl/data_system/fdv1" require "ldclient-rb/impl/data_system/fdv2" require "ldclient-rb/impl/diagnostic_events" require "ldclient-rb/impl/evaluation_with_hook_result" @@ -18,7 +16,6 @@ require "forwardable" require "logger" require "securerandom" -require "benchmark" require "json" require "openssl" @@ -58,15 +55,13 @@ class LDClient # @return [LDClient] The LaunchDarkly client instance # def initialize(sdk_key, config = Config.default, wait_for_sec = 5) - # Note that sdk_key is normally a required parameter, and a nil value would cause the SDK to - # fail in most configurations. However, there are some configurations where it would be OK to - # not provide a SDK key. - # * Offline mode - # * Using LDD mode with events disabled - # * Using a custom data source (like FileData) with events disabled + @data_system_config = config.data_system_config || DataSystem.default.build + + # The SDK key is required unless the client never sends it. That is the case in offline mode, and + # when events are disabled and no data source needs the SDK key (for example, daemon mode, or only + # FileData or TestDataV2 sources). if !config.offline? && sdk_key.nil? - # If the data source is nil we create a default data source which requires the SDK key. - if config.send_events || (!config.use_ldd? && config.data_source.nil?) + if config.send_events || LDClient.data_sources_need_sdk_key?(@data_system_config) raise ArgumentError, "sdk_key must not be nil" end end @@ -148,34 +143,47 @@ def postfork(wait_for_sec = 5) end # - # Return true if this configuration needs background threads. Offline mode has none. LDD mode with events - # disabled reads flags from the persistent store and sends nothing, so a fork does not break it. + # Return true if any data source in the data system configuration sends the SDK key. The polling, + # streaming and FDv1 fallback sources send it, whatever their base URI. + # + # @api private + # @param data_system_config [DataSystemConfig] + # @return [Boolean] + # + def self.data_sources_need_sdk_key?(data_system_config) + sources = (data_system_config.initializers || []) + (data_system_config.synchronizers || []) + + [data_system_config.fdv1_fallback_synchronizer].compact + sources.any? do |source| + source.is_a?(DataSystem::PollingDataSourceBuilder) || + source.is_a?(DataSystem::StreamingDataSourceBuilder) || + source.is_a?(DataSystem::FDv1PollingDataSourceBuilder) + end + end + + # + # Return true if this configuration needs background threads. Offline mode has none. A data system with + # no data sources only reads the data store, so with events disabled a fork does not break it. # # @return [Boolean] # private def fork_breaks_client? return false if @config.offline? - return false if @config.use_ldd? && !@config.send_events + return false if !data_sources_configured? && !@config.send_events true end + private def data_sources_configured? + !(@data_system_config.initializers || []).empty? || !(@data_system_config.synchronizers || []).empty? + end + private def start_up(wait_for_sec) environment_metadata = get_environment_metadata plugin_hooks = get_plugin_hooks(environment_metadata) @hooks = Concurrent::Array.new(@config.hooks + plugin_hooks) - # Initialize the data system - use FDv2 if configured, otherwise FDv1 - data_system_config = @config.data_system_config - if data_system_config.nil? - # Use FDv1 for backwards compatibility - # Note: FDv1 will update @config.feature_store to use its wrapped store - @data_system = Impl::DataSystem::FDv1.new(@sdk_key, @config) - else - # Use FDv2 with the provided configuration - @data_system = Impl::DataSystem::FDv2.new(@sdk_key, @config, data_system_config) - end + @data_system = Impl::DataSystem::FDv2.new(@sdk_key, @config, @data_system_config) # Components not managed by data system @big_segment_store_manager = Impl::BigSegmentStoreManager.new(@config.big_segments, @config.logger) @@ -317,7 +325,7 @@ def secure_mode_hash(context) # @return [Boolean] true if the client has been initialized # def initialized? - return true if @config.offline? || @config.use_ldd? + return true if @config.offline? Impl::DataSystem::DataAvailability.at_least?(@data_system.data_availability, Impl::DataSystem::DataAvailability::CACHED) end diff --git a/spec/config_spec.rb b/spec/config_spec.rb index a28cd334..950fc7af 100644 --- a/spec/config_spec.rb +++ b/spec/config_spec.rb @@ -12,37 +12,12 @@ module LaunchDarkly expect(subject).to_not receive(:default_capacity) expect(subject.new(capacity: 50).capacity).to eq 50 end - it "will chomp base_url and stream_uri" do - uri = "https://test.launchdarkly.com" - config = subject.new(base_uri: uri + "/") - expect(config.base_uri).to eq uri - end - end - describe "@base_uri" do - it "can be read" do - expect(subject.new.base_uri).to eq subject.default_base_uri - end end describe "@events_uri" do it "can be read" do expect(subject.new.events_uri).to eq subject.default_events_uri end end - describe "@stream_uri" do - it "can be read" do - expect(subject.new.stream_uri).to eq subject.default_stream_uri - end - end - describe ".default_cache_store" do - it "uses Rails cache if it is available" do - rails = instance_double("Rails", cache: :cache) - stub_const("Rails", rails) - expect(subject.default_cache_store).to eq :cache - end - it "uses memory store if Rails is not available" do - expect(subject.default_cache_store).to be_an_instance_of Impl::ThreadSafeMemoryStore - end - end describe ".default_logger" do it "uses Rails logger if it is available" do rails = instance_double("Rails", logger: :logger) @@ -58,15 +33,6 @@ module LaunchDarkly expect(subject.default_logger).to be_an_instance_of Logger end end - describe ".poll_interval" do - it "can be set to greater than the default" do - expect(subject.new(poll_interval: 31).poll_interval).to eq 31 - end - it "cannot be set to less than the default" do - expect(subject.new(poll_interval: 29).poll_interval).to eq 30 - end - end - describe ".application" do it "can be set and read" do app = { id: "my-id", version: "abcdef" } diff --git a/spec/data_system/polling_data_source_builder_spec.rb b/spec/data_system/polling_data_source_builder_spec.rb new file mode 100644 index 00000000..06c10a74 --- /dev/null +++ b/spec/data_system/polling_data_source_builder_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "spec_helper" +require "capturing_logger" +require "ldclient-rb/impl/data_system/polling" + +module LaunchDarkly + module DataSystem + [PollingDataSourceBuilder, FDv1PollingDataSourceBuilder].each do |builder_class| + RSpec.describe builder_class do + let(:logger) { CapturingLogger.new } + let(:config) { Config.new(logger: logger) } + + def poll_interval_of(builder) + data_source = builder.build("sdk-key", config) + data_source.instance_variable_get(:@poll_interval) + end + + it "defaults the poll interval to 30 seconds" do + expect(poll_interval_of(described_class.new)).to eq 30 + expect(logger.output).not_to include("Poll interval") + end + + it "keeps a poll interval larger than the minimum" do + expect(poll_interval_of(described_class.new.poll_interval(45))).to eq 45 + expect(logger.output).not_to include("Poll interval") + end + + it "keeps a poll interval equal to the minimum" do + expect(poll_interval_of(described_class.new.poll_interval(30))).to eq 30 + expect(logger.output).not_to include("Poll interval") + end + + it "replaces a poll interval below the minimum with 30 seconds and logs a warning" do + expect(poll_interval_of(described_class.new.poll_interval(5))).to eq 30 + expect(logger.output).to match(/WARN.*Poll interval 5 is less than the minimum; using 30 seconds/) + end + end + end + end +end diff --git a/spec/diagnostic_events_spec.rb b/spec/diagnostic_events_spec.rb index 09734570..9b6cd694 100644 --- a/spec/diagnostic_events_spec.rb +++ b/spec/diagnostic_events_spec.rb @@ -25,19 +25,14 @@ def expected_default_config { allAttributesPrivate: false, connectTimeoutMillis: Config.default_connect_timeout * 1000, - customBaseURI: false, customEventsURI: false, - customStreamURI: false, diagnosticRecordingIntervalMillis: Config.default_diagnostic_recording_interval * 1000, eventsCapacity: Config.default_capacity, eventsFlushIntervalMillis: Config.default_flush_interval * 1000, - pollingIntervalMillis: Config.default_poll_interval * 1000, socketTimeoutMillis: Config.default_read_timeout * 1000, - streamingDisabled: false, userKeysCapacity: Config.default_context_keys_capacity, userKeysFlushIntervalMillis: Config.default_context_keys_flush_interval * 1000, usingProxy: false, - usingRelayDaemon: false, } end @@ -57,18 +52,13 @@ def expected_default_config changes_and_expected = [ [ { all_attributes_private: true }, { allAttributesPrivate: true } ], [ { connect_timeout: 46 }, { connectTimeoutMillis: 46000 } ], - [ { base_uri: 'http://custom' }, { customBaseURI: true } ], [ { events_uri: 'http://custom' }, { customEventsURI: true } ], - [ { stream_uri: 'http://custom' }, { customStreamURI: true } ], [ { diagnostic_recording_interval: 9999 }, { diagnosticRecordingIntervalMillis: 9999000 } ], [ { capacity: 4000 }, { eventsCapacity: 4000 } ], [ { flush_interval: 46 }, { eventsFlushIntervalMillis: 46000 } ], - [ { poll_interval: 999 }, { pollingIntervalMillis: 999000 } ], [ { read_timeout: 46 }, { socketTimeoutMillis: 46000 } ], - [ { stream: false }, { streamingDisabled: true } ], [ { context_keys_capacity: 999 }, { userKeysCapacity: 999 } ], [ { context_keys_flush_interval: 999 }, { userKeysFlushIntervalMillis: 999000 } ], - [ { use_ldd: true }, { usingRelayDaemon: true } ], ] changes_and_expected.each do |config_values, expected_values| config = Config.new(config_values) diff --git a/spec/impl/cache_store_spec.rb b/spec/impl/cache_store_spec.rb deleted file mode 100644 index 7aeee20e..00000000 --- a/spec/impl/cache_store_spec.rb +++ /dev/null @@ -1,14 +0,0 @@ -require "ldclient-rb/impl/cache_store" -require "spec_helper" - -module LaunchDarkly - describe Impl::ThreadSafeMemoryStore do - subject { Impl::ThreadSafeMemoryStore } - let(:store) { subject.new } - it "can read and write" do - store.write("key", "value") - expect(store.read("key")).to eq "value" - end - end -end - diff --git a/spec/impl/data_source/null_processor_spec.rb b/spec/impl/data_source/null_processor_spec.rb deleted file mode 100644 index 3249c61f..00000000 --- a/spec/impl/data_source/null_processor_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -require "spec_helper" -require "ldclient-rb/impl/data_source/null_processor" - -module LaunchDarkly - module Impl - module DataSource - describe NullUpdateProcessor do - subject { NullUpdateProcessor.new } - - describe "#initialize" do - it "creates a ready event" do - expect(subject.instance_variable_get(:@ready)).to be_a(Concurrent::Event) - end - end - - describe "#start" do - it "returns a ready event that is already set" do - ready_event = subject.start - expect(ready_event).to be_a(Concurrent::Event) - expect(ready_event.set?).to be true - end - - it "returns the same event on multiple calls" do - first_event = subject.start - second_event = subject.start - - expect(second_event).to be(first_event) - end - end - - describe "#stop" do - it "does nothing and does not raise an error" do - expect { subject.stop }.not_to raise_error - end - end - - describe "#initialized?" do - it "always returns true" do - expect(subject.initialized?).to be true - end - - it "returns true even before start is called" do - processor = NullUpdateProcessor.new - expect(processor.initialized?).to be true - end - end - - describe "DataSource interface" do - it "includes the DataSource module" do - expect(subject.class.ancestors).to include(LaunchDarkly::Interfaces::DataSource) - end - end - end - end - end -end - diff --git a/spec/impl/data_source/polling_spec.rb b/spec/impl/data_source/polling_spec.rb deleted file mode 100644 index 5337e005..00000000 --- a/spec/impl/data_source/polling_spec.rb +++ /dev/null @@ -1,361 +0,0 @@ -require "ldclient-rb/impl/data_source/polling" -require "ldclient-rb/impl/model/feature_flag" -require "ldclient-rb/impl/model/segment" -require 'ostruct' -require "spec_helper" - -module LaunchDarkly - describe Impl::DataSource::PollingProcessor do - subject { Impl::DataSource::PollingProcessor } - let(:executor) { SynchronousExecutor.new } - let(:status_broadcaster) { Impl::Broadcaster.new(executor, $null_log) } - let(:flag_change_broadcaster) { Impl::Broadcaster.new(executor, $null_log) } - let(:requestor) { double } - - def with_processor(store, initialize_to_valid = false) - config = Config.new(feature_store: store, logger: $null_log) - config.data_source_update_sink = Impl::DataSource::UpdateSink.new(store, status_broadcaster, flag_change_broadcaster) - - if initialize_to_valid - # If the update sink receives an interrupted signal when the state is - # still initializing, it will continue staying in the initializing phase. - # Therefore, we set the state to valid before this test so we can - # determine if the interrupted signal is actually generated. - config.data_source_update_sink.update_status(Interfaces::DataSource::Status::VALID, nil) - end - - processor = subject.new(config, requestor) - begin - yield processor - ensure - processor.stop - end - end - - describe 'successful request' do - flag = Impl::Model::FeatureFlag.new({ key: 'flagkey', version: 1 }) - segment = Impl::Model::Segment.new({ key: 'segkey', version: 1 }) - all_data = { - LaunchDarkly::Interfaces::DataStore::FEATURES => { - flagkey: flag, - }, - LaunchDarkly::Interfaces::DataStore::SEGMENTS => { - segkey: segment, - }, - } - - it 'puts feature data in store' do - allow(requestor).to receive(:request_all_data).and_return(all_data) - store = InMemoryFeatureStore.new - with_processor(store) do |processor| - ready = processor.start - ready.wait - expect(store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, "flagkey")).to eq(flag) - expect(store.get(LaunchDarkly::Interfaces::DataStore::SEGMENTS, "segkey")).to eq(segment) - end - end - - it 'sets initialized to true' do - allow(requestor).to receive(:request_all_data).and_return(all_data) - store = InMemoryFeatureStore.new - with_processor(store) do |processor| - ready = processor.start - ready.wait - expect(processor.initialized?).to be true - expect(store.initialized?).to be true - end - end - - it 'status is set to valid when data is received' do - allow(requestor).to receive(:request_all_data).and_return(all_data) - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - - store = InMemoryFeatureStore.new - with_processor(store) do |processor| - ready = processor.start - ready.wait - expect(store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, "flagkey")).to eq(flag) - expect(store.get(LaunchDarkly::Interfaces::DataStore::SEGMENTS, "segkey")).to eq(segment) - - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].state).to eq(Interfaces::DataSource::Status::VALID) - end - end - - it 'publishes the valid status before releasing ready waiters' do - allow(requestor).to receive(:request_all_data).and_return(all_data) - store = InMemoryFeatureStore.new - with_processor(store) do |processor| - # The broadcaster notifies listeners inline on the poll thread, so a - # listener that sees the ready event already set proves the status was - # published too late. - ready = processor.instance_variable_get(:@ready) - ready_set_when_notified = nil - status_broadcaster.add_listener(CallbackListener.new(->(_status) { ready_set_when_notified = ready.set? })) - - config = processor.instance_variable_get(:@config) - processor.start.wait - - expect(ready_set_when_notified).to be false - expect(config.data_source_update_sink.current_status.state).to eq(Interfaces::DataSource::Status::VALID) - end - end - end - - describe 'environment ID' do - flag = Impl::Model::FeatureFlag.new({ key: 'flagkey', version: 1 }) - all_data = { - LaunchDarkly::Interfaces::DataStore::FEATURES => { flagkey: flag }, - LaunchDarkly::Interfaces::DataStore::SEGMENTS => {}, - } - - it 'is recorded from the response headers' do - allow(requestor).to receive(:request_all_data_with_headers).and_return([all_data, { "X-LD-EnvID" => "env-abc" }]) - store = InMemoryFeatureStore.new - with_processor(store) do |processor| - config = processor.instance_variable_get(:@config) - processor.start.wait - expect(config.data_source_update_sink.environment_id).to eq("env-abc") - end - end - - it 'is not recorded when the header is absent' do - allow(requestor).to receive(:request_all_data_with_headers).and_return([all_data, {}]) - store = InMemoryFeatureStore.new - with_processor(store) do |processor| - config = processor.instance_variable_get(:@config) - processor.start.wait - expect(config.data_source_update_sink.environment_id).to be_nil - end - end - - it 'is not recorded for an error response' do - allow(requestor).to receive(:request_all_data_with_headers).and_raise(Impl::DataSource::UnexpectedResponseError.new(503)) - with_processor(InMemoryFeatureStore.new, true) do |processor| - config = processor.instance_variable_get(:@config) - processor.start.wait(1) - expect(config.data_source_update_sink.environment_id).to be_nil - end - end - end - - describe 'connection error' do - it 'does not cause immediate failure, does not set initialized' do - allow(requestor).to receive(:request_all_data).and_raise(StandardError.new("test error")) - store = InMemoryFeatureStore.new - with_processor(store) do |processor| - ready = processor.start - finished = ready.wait(1) - expect(finished).to be false - expect(processor.initialized?).to be false - expect(store.initialized?).to be false - end - end - end - - describe 'HTTP errors' do - # Delays short enough that the task polls again at once. - let(:fast_retry_state) { - Impl::RetryState.new(normal_initial_delay: 0.001, normal_ceiling_delay: 0.001, extended_initial_delay: 0.002, - extended_ceiling_delay: 0.002, reset_policy: Impl::RetryState::AfterConsecutiveSuccesses.new(2), - operating_cadence: 0.001) - } - - def verify_unexpected_http_error_keeps_retrying(status) - allow(Impl::RetryState).to receive(:for_polling).and_return(fast_retry_state) - attempts = Concurrent::CountDownLatch.new(3) - allow(requestor).to receive(:request_all_data) do - attempts.count_down - raise Impl::DataSource::UnexpectedResponseError.new(status) - end - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - - with_processor(InMemoryFeatureStore.new, true) do |processor| - ready = processor.start - expect(attempts.wait(1)).to be true - expect(ready.set?).to be false - expect(processor.initialized?).to be false - - # The first status is the VALID that with_processor sets. - states = listener.statuses.map(&:state) - expect(states[1..2]).to eq([Interfaces::DataSource::Status::INTERRUPTED] * 2) - expect(states).not_to include(Interfaces::DataSource::Status::OFF) - expect(listener.statuses[1].last_error.status_code).to eq(status) - end - end - - def verify_recoverable_http_error(status) - allow(requestor).to receive(:request_all_data).and_raise(Impl::DataSource::UnexpectedResponseError.new(status)) - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - - with_processor(InMemoryFeatureStore.new, true) do |processor| - ready = processor.start - finished = ready.wait(1) - expect(finished).to be false - expect(processor.initialized?).to be false - - # The ready event is never set for a recoverable error, so the wait - # above only passes time. Wait for the poll thread to publish the - # INTERRUPTED status before asserting on it. - statuses = listener.wait_for_count(2) - expect(statuses.count).to eq(2) - - s = statuses[1] - expect(s.state).to eq(Interfaces::DataSource::Status::INTERRUPTED) - expect(s.last_error.status_code).to eq(status) - end - end - - it 'keeps retrying after error 401' do - verify_unexpected_http_error_keeps_retrying(401) - end - - it 'keeps retrying after error 403' do - verify_unexpected_http_error_keeps_retrying(403) - end - - it 'keeps retrying after error 404' do - verify_unexpected_http_error_keeps_retrying(404) - end - - it 'does not stop immediately for error 408' do - verify_recoverable_http_error(408) - end - - it 'does not stop immediately for error 429' do - verify_recoverable_http_error(429) - end - - it 'does not stop immediately for error 503' do - verify_recoverable_http_error(503) - end - end - - describe 'retry delay' do - let(:logger) { double("logger").as_null_object } - let(:all_data) { { LaunchDarkly::Interfaces::DataStore::FEATURES => {}, LaunchDarkly::Interfaces::DataStore::SEGMENTS => {} } } - - def make_processor - config = Config.new(feature_store: InMemoryFeatureStore.new, logger: logger) - config.data_source_update_sink = Impl::DataSource::UpdateSink.new(config.feature_store, status_broadcaster, flag_change_broadcaster) - subject.new(config, requestor) - end - - def next_delay(processor) - processor.instance_variable_get(:@retry_state).next_delay - end - - it 'waits in the extended regime after an unexpected error, then the poll interval after a success' do - responses = [:unauthorized, :ok] - allow(requestor).to receive(:request_all_data) do - raise Impl::DataSource::UnexpectedResponseError.new(401) if responses.shift == :unauthorized - all_data - end - processor = make_processor - - processor.poll - expect(next_delay(processor)).to be_between(150, 300) - processor.poll - expect(next_delay(processor)).to eq(Config.default_poll_interval) - end - - it 'waits the poll interval after a recoverable error' do - allow(requestor).to receive(:request_all_data).and_raise(Impl::DataSource::UnexpectedResponseError.new(503)) - processor = make_processor - - processor.poll - expect(next_delay(processor)).to eq(Config.default_poll_interval) - end - - it 'waits the poll interval after a network error' do - allow(requestor).to receive(:request_all_data).and_raise(StandardError.new("test error")) - processor = make_processor - - processor.poll - expect(next_delay(processor)).to eq(Config.default_poll_interval) - end - - it 'warns and uses the default interval for an infinite poll interval' do - allow(requestor).to receive(:request_all_data).and_return(all_data) - expect(logger).to receive(:warn) do |&block| - expect(block.call).to include("poll_interval must be a positive, finite number") - end - config = Config.new(poll_interval: Float::INFINITY, logger: logger) - expect(config.poll_interval).to eq(Float::INFINITY) - config.data_source_update_sink = Impl::DataSource::UpdateSink.new(config.feature_store, status_broadcaster, flag_change_broadcaster) - processor = subject.new(config, requestor) - - processor.poll - expect(next_delay(processor)).to eq(Config.default_poll_interval) - end - - [ - [401, :error, "[LDClient] HTTP error 401 (invalid SDK key) for polling request - will retry in 300.0s"], - [503, :warn, "[LDClient] HTTP error 503 for polling request - will retry in 30.0s"], - ].each do |status, level, message| - it "logs HTTP error #{status} once at #{level}, with the delay" do - allow(Impl::RetryState).to receive(:for_polling).and_return( - Impl::RetryState.for_polling(30, logger, random: double("random", rand: 0.0))) - allow(requestor).to receive(:request_all_data).and_raise(Impl::DataSource::UnexpectedResponseError.new(status)) - logs = [] - %i[error warn].each do |name| - allow(logger).to receive(name) { |&block| logs << [name, block.call] } - end - - make_processor.poll - expect(logs).to eq([[level, message]]) - end - end - end - - describe 'stop' do - it 'stops promptly during a backoff wait' do - polled = Concurrent::Event.new - allow(requestor).to receive(:request_all_data) do - polled.set - raise Impl::DataSource::UnexpectedResponseError.new(401) - end - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - - with_processor(InMemoryFeatureStore.new) do |processor| - processor.start - expect(polled.wait(1)).to be true - sleep(0.1) # let the worker start its wait of at least 150s - start_time = Time.now - processor.stop - expect(Time.now - start_time).to be < 1 - - expect(requestor).to have_received(:request_all_data).once - # INTERRUPTED stays INITIALIZING before the first success. - expect(listener.statuses.map(&:state)).to eq( - [Interfaces::DataSource::Status::INITIALIZING, Interfaces::DataSource::Status::OFF]) - end - end - - it 'closes requestor HTTP connections on stop' do - requestor_with_stop = double("RequestorWithStop") - allow(requestor_with_stop).to receive(:request_all_data).and_return({ - LaunchDarkly::Interfaces::DataStore::FEATURES => {}, - LaunchDarkly::Interfaces::DataStore::SEGMENTS => {}, - }) - allow(requestor_with_stop).to receive(:stop) - - config = Config.new(feature_store: InMemoryFeatureStore.new, logger: $null_log) - config.data_source_update_sink = Impl::DataSource::UpdateSink.new( - config.feature_store, status_broadcaster, flag_change_broadcaster - ) - - processor = subject.new(config, requestor_with_stop) - processor.start - sleep(0.1) # Give it time to start - - expect(requestor_with_stop).to receive(:stop) - processor.stop - end - end - end -end diff --git a/spec/impl/data_source/requestor_spec.rb b/spec/impl/data_source/requestor_spec.rb deleted file mode 100644 index 4068501a..00000000 --- a/spec/impl/data_source/requestor_spec.rb +++ /dev/null @@ -1,258 +0,0 @@ -require "http_util" -require "ldclient-rb/impl/data_source/requestor" -require "model_builders" -require "spec_helper" - -module LaunchDarkly - describe Impl::DataSource::Requestor do - let(:sdk_key) { "secret" } - - def with_requestor(base_uri, opts = {}) - r = Impl::DataSource::Requestor.new(sdk_key, Config.new({ base_uri: base_uri, application: {id: "id", version: "version"} }.merge(opts))) - begin - yield r - ensure - r.stop - end - end - - it "abandons a connect attempt after the connect timeout" do - socket_factory = HangingSocketFactory.new - requestor = Impl::DataSource::Requestor.new(sdk_key, Config.new( - base_uri: "http://sdk.example.com", - connect_timeout: 0.2, - socket_factory: socket_factory, - logger: $null_log - )) - - request_thread = Thread.new do - requestor.request_all_data - rescue StandardError - # A timed-out connect raises; only the blocked duration matters here. - end - begin - # Without the connect timeout, the attempt blocks until the thread is killed. - blocked = socket_factory.first_blocked_duration(3) - expect(blocked).not_to be_nil - expect(blocked).to be < 1 - ensure - request_thread.kill unless request_thread.join(5) - requestor.stop - end - end - - describe "request_all_flags", flaky: true do - it "uses expected URI and headers" do - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - requestor.instance_variable_get(:@config).instance_id = 'instance-id' - server.setup_ok_response("/", "{}") - requestor.request_all_data - expect(server.requests.count).to eq 1 - expect(server.requests[0].unparsed_uri).to eq "/sdk/latest-all" - expect(server.requests[0].header).to include({ - "authorization" => [ sdk_key ], - "user-agent" => [ "RubyClient/" + VERSION ], - "x-launchdarkly-tags" => [ "application-id/id application-version/version" ], - "x-launchdarkly-instance-id" => [ "instance-id" ], - }) - end - end - end - - it "parses response" do - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_ok_response("/", expected_data.to_json) - data = requestor.request_all_data - expect(data).to eq expected_data.to_store_data - end - end - end - - it "returns the response headers" do - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_ok_response("/", expected_data.to_json, "application/json", { "X-LD-EnvID" => "env-abc" }) - data, headers = requestor.request_all_data_with_headers - expect(data).to eq expected_data.to_store_data - expect(headers["X-LD-EnvID"]).to eq "env-abc" - end - end - end - - it "logs debug output" do - logger = ::Logger.new($stdout) - logger.level = ::Logger::DEBUG - with_server do |server| - with_requestor(server.base_uri.to_s, { logger: logger }) do |requestor| - server.setup_ok_response("/", FlagBuilder.new("x").build.to_json) - expect do - requestor.request_all_data - end.to output(/\[LDClient\] Got response from uri:/).to_stdout_from_any_process - end - end - end - - it "sends etag from previous response" do - etag = "xyz" - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_response("/") do |_, res| - res.status = 200 - res.body = "{}" - res["ETag"] = etag - end - requestor.request_all_data - expect(server.requests.count).to eq 1 - - requestor.request_all_data - expect(server.requests.count).to eq 2 - expect(server.requests[1].header).to include({ "if-none-match" => [ etag ] }) - end - end - end - - it "sends wrapper header if configured" do - with_server do |server| - with_requestor(server.base_uri.to_s, { wrapper_name: 'MyWrapper', wrapper_version: '1.0' }) do |requestor| - server.setup_ok_response("/", "{}") - requestor.request_all_data - expect(server.requests.count).to eq 1 - expect(server.requests[0].header).to include({ - "x-launchdarkly-wrapper" => [ "MyWrapper/1.0" ], - }) - end - end - end - - it "can reuse cached data" do - etag = "xyz" - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_response("/") do |_, res| - res.status = 200 - res.body = expected_data.to_json - res["ETag"] = etag - end - requestor.request_all_data - expect(server.requests.count).to eq 1 - - server.setup_response("/") do |_, res| - res.status = 304 - end - data = requestor.request_all_data - expect(server.requests.count).to eq 2 - expect(server.requests[1].header).to include({ "if-none-match" => [ etag ] }) - expect(data).to eq expected_data.to_store_data - end - end - end - - it "replaces cached data with new data" do - etag1 = "abc" - etag2 = "xyz" - expected_data1 = DataSetBuilder.new.flag(FlagBuilder.new("x").build) - expected_data2 = DataSetBuilder.new.flag(FlagBuilder.new("y").build) - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_response("/") do |_, res| - res.status = 200 - res.body = expected_data1.to_json - res["ETag"] = etag1 - end - data = requestor.request_all_data - expect(data).to eq expected_data1.to_store_data - expect(server.requests.count).to eq 1 - - server.setup_response("/") do |_, res| - res.status = 304 - end - data = requestor.request_all_data - expect(data).to eq expected_data1.to_store_data - expect(server.requests.count).to eq 2 - expect(server.requests[1].header).to include({ "if-none-match" => [ etag1 ] }) - - server.setup_response("/") do |_, res| - res.status = 200 - res.body = expected_data2.to_json - res["ETag"] = etag2 - end - data = requestor.request_all_data - expect(data).to eq expected_data2.to_store_data - expect(server.requests.count).to eq 3 - expect(server.requests[2].header).to include({ "if-none-match" => [ etag1 ] }) - - server.setup_response("/") do |_, res| - res.status = 304 - end - data = requestor.request_all_data - expect(data).to eq expected_data2.to_store_data - expect(server.requests.count).to eq 4 - expect(server.requests[3].header).to include({ "if-none-match" => [ etag2 ] }) - end - end - end - - it "uses UTF-8 encoding by default" do - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("flagkey").variations("blue", "grėeń").build) - with_server do |server| - server.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json") - with_requestor(server.base_uri.to_s) do |requestor| - data = requestor.request_all_data - expect(data).to eq expected_data.to_store_data - end - end - end - - it "detects other encodings from Content-Type" do - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("flagkey").variations("proszę", "dziękuję").build) - with_server do |server| - server.setup_ok_response("/sdk/latest-all", expected_data.to_json.encode(Encoding::ISO_8859_2), - "text/plain; charset=ISO-8859-2") - with_requestor(server.base_uri.to_s) do |requestor| - data = requestor.request_all_data - expect(data).to eq expected_data.to_store_data - end - end - end - - it "throws exception for error status" do - with_server do |server| - with_requestor(server.base_uri.to_s) do |requestor| - server.setup_response("/") do |_, res| - res.status = 400 - end - expect { requestor.request_all_data }.to raise_error(LaunchDarkly::Impl::DataSource::UnexpectedResponseError) - end - end - end - - it "can use a proxy server" do - fake_target_uri = "http://request-will-not-really-go-here" - # Instead of a real proxy server, we just create a basic test HTTP server that - # pretends to be a proxy. The proof that the proxy logic is working correctly is - # that the request goes to that server, instead of to fake_target_uri. We can't - # use a real proxy that really forwards requests to another test server, because - # that test server would be at localhost, and proxy environment variables are - # ignored if the target is localhost. - expected_data = DataSetBuilder.new.flag(FlagBuilder.new("x").build) - with_server do |proxy| - proxy.setup_ok_response("/sdk/latest-all", expected_data.to_json, "application/json", { "etag" => "x" }) - begin - ENV["http_proxy"] = proxy.base_uri.to_s - with_requestor(fake_target_uri) do |requestor| - data = requestor.request_all_data - expect(data).to eq expected_data.to_store_data - end - ensure - ENV["http_proxy"] = nil - end - end - end - end - end -end diff --git a/spec/impl/data_source/status_provider_spec.rb b/spec/impl/data_source/status_provider_spec.rb index 97f22fb0..a870e054 100644 --- a/spec/impl/data_source/status_provider_spec.rb +++ b/spec/impl/data_source/status_provider_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "ldclient-rb/impl/data_source/status_provider" +require "ldclient-rb/impl/broadcaster" require "timecop" @@ -106,6 +107,48 @@ def make_error expect(seconds).to eq 20.0 end + describe "listeners" do + let(:broadcaster) { LaunchDarkly::Impl::Broadcaster.new(SynchronousExecutor.new, $null_log) } + let(:provider) { StatusProviderV2.new(broadcaster) } + + it "are called only when the status changes" do + listener = ListenerSpy.new + provider.add_listener(listener) + + provider.update_status(valid, nil) + provider.update_status(valid, nil) + expect(listener.statuses.map(&:state)).to eq([valid]) + + provider.update_status(interrupted, nil) + provider.update_status(interrupted, nil) + expect(listener.statuses.map(&:state)).to eq([valid, interrupted]) + end + + it "are called when the state is the same but there is a new error" do + listener = ListenerSpy.new + provider.add_listener(listener) + error = make_error + + provider.update_status(valid, nil) + provider.update_status(valid, error) + + expect(listener.statuses.map(&:state)).to eq([valid, valid]) + expect(listener.statuses.last.last_error).to eq(error) + end + + it "each get a single change" do + listener1 = ListenerSpy.new + listener2 = ListenerSpy.new + provider.add_listener(listener1) + provider.add_listener(listener2) + + provider.update_status(valid, nil) + + expect(listener1.statuses.map(&:state)).to eq([valid]) + expect(listener2.statuses.map(&:state)).to eq([valid]) + end + end + it "is unaffected by wall-clock steps" do provider = StatusProviderV2.new(broadcaster) provider.update_status(valid, nil) diff --git a/spec/impl/data_source/stream_spec.rb b/spec/impl/data_source/stream_spec.rb deleted file mode 100644 index 5438bd4b..00000000 --- a/spec/impl/data_source/stream_spec.rb +++ /dev/null @@ -1,592 +0,0 @@ -require "ldclient-rb/impl/data_source/stream" -require "ld-eventsource" -require "model_builders" -require "spec_helper" -require "http_util" - -module LaunchDarkly - describe Impl::DataSource::StreamProcessor do - subject { Impl::DataSource::StreamProcessor } - let(:executor) { SynchronousExecutor.new } - let(:status_broadcaster) { Impl::Broadcaster.new(executor, $null_log) } - let(:flag_change_broadcaster) { Impl::Broadcaster.new(executor, $null_log) } - let(:config) { - config = Config.new - config.data_source_update_sink = Impl::DataSource::UpdateSink.new(config.feature_store, status_broadcaster, flag_change_broadcaster) - config.data_source_update_sink.update_status(Interfaces::DataSource::Status::VALID, nil) - config - } - let(:processor) { subject.new("sdk_key", config) } - - describe '#process_message' do - let(:put_message) { SSE::StreamEvent.new(:put, '{"data":{"flags":{"asdf": {"key": "asdf"}},"segments":{"segkey": {"key": "segkey"}}}}') } - let(:patch_flag_message) { SSE::StreamEvent.new(:patch, '{"path": "/flags/key", "data": {"key": "asdf", "version": 1}}') } - let(:patch_seg_message) { SSE::StreamEvent.new(:patch, '{"path": "/segments/key", "data": {"key": "asdf", "version": 1}}') } - let(:delete_flag_message) { SSE::StreamEvent.new(:delete, '{"path": "/flags/key", "version": 2}') } - let(:delete_seg_message) { SSE::StreamEvent.new(:delete, '{"path": "/segments/key", "version": 2}') } - let(:invalid_message) { SSE::StreamEvent.new(:put, '{Hi there}') } - - it "will accept PUT methods" do - processor.send(:process_message, put_message) - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, "asdf")).to eq(Flags.from_hash(key: "asdf")) - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::SEGMENTS, "segkey")).to eq(Segments.from_hash(key: "segkey")) - end - it "will accept PATCH methods for flags" do - processor.send(:process_message, patch_flag_message) - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, "asdf")).to eq(Flags.from_hash(key: "asdf", version: 1)) - end - it "will accept PATCH methods for segments" do - processor.send(:process_message, patch_seg_message) - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::SEGMENTS, "asdf")).to eq(Segments.from_hash(key: "asdf", version: 1)) - end - it "will accept DELETE methods for flags" do - processor.send(:process_message, patch_flag_message) - processor.send(:process_message, delete_flag_message) - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, "key")).to eq(nil) - end - it "will accept DELETE methods for segments" do - processor.send(:process_message, patch_seg_message) - processor.send(:process_message, delete_seg_message) - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::SEGMENTS, "key")).to eq(nil) - end - it "will log a warning if the method is not recognized" do - expect(processor.instance_variable_get(:@config).logger).to receive :warn - processor.send(:process_message, SSE::StreamEvent.new(type: :get, data: "", id: nil)) - end - it "raises for invalid JSON and leaves the status to the error handler" do - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - - expect { processor.send(:process_message, invalid_message) }.to raise_error(JSON::ParserError) - expect(listener.statuses.map(&:state)).not_to include(Interfaces::DataSource::Status::INTERRUPTED) - end - end - - # Replaces the SSE client with a double, and yields the handlers the processor registers on it. - def with_handlers(processor, sse_client = double("SSE::Client", close: nil)) - handlers = {} - connection = double("connection") - %i[on_connect on_event on_error].each do |name| - allow(connection).to receive(name) { |&block| handlers[name] = block } - end - allow(SSE::Client).to receive(:new) do |_uri, **opts, &block| - handlers[:opts] = opts - block.call(connection) - sse_client - end - - processor.start - begin - yield handlers, sse_client - ensure - processor.stop - end - end - - describe 'environment ID' do - it 'is recorded from the connection response headers' do - with_handlers(processor) do |handlers| - handlers[:on_connect].call({ "X-LD-EnvID" => "env-abc" }) - expect(config.data_source_update_sink.environment_id).to eq("env-abc") - end - end - - it 'is not recorded when the header is absent' do - with_handlers(processor) do |handlers| - handlers[:on_connect].call({}) - expect(config.data_source_update_sink.environment_id).to be_nil - end - end - end - - describe 'retry' do - let(:put_message) { SSE::StreamEvent.new(:put, '{"data":{"flags":{},"segments":{}}}') } - let(:listener) { ListenerSpy.new } - let(:logger) { double("logger").as_null_object } - let(:config) { - config = Config.new(logger: logger) - config.data_source_update_sink = Impl::DataSource::UpdateSink.new(config.feature_store, status_broadcaster, flag_change_broadcaster) - config.data_source_update_sink.update_status(Interfaces::DataSource::Status::VALID, nil) - status_broadcaster.add_listener(listener) - config - } - # Delays short enough that a failure handler returns at once. - let(:fast_retry_state) { - Impl::RetryState.new(normal_initial_delay: 0.001, normal_ceiling_delay: 0.001, extended_initial_delay: 0.002, - extended_ceiling_delay: 0.002, reset_policy: Impl::RetryState::AfterConsecutiveSuccesses.new(2)) - } - - def use_fast_retry_state - allow(Impl::RetryState).to receive(:for_streaming).and_return(fast_retry_state) - end - - def http_error(status) - SSE::Errors::HTTPStatusError.new(status, "") - end - - def states - listener.statuses.map(&:state) - end - - it 'passes an Integer zero reconnect time to the SSE client' do - with_handlers(processor) do |handlers| - expect(handlers[:opts][:reconnect_time]).to eql(0) - end - end - - [401, 403, 404].each do |status| - it "keeps retrying after error #{status}" do - use_fast_retry_state - with_handlers(processor) do |handlers, sse_client| - 3.times { handlers[:on_error].call(http_error(status)) } - - expect(sse_client).not_to have_received(:close) - expect(states).to eq([Interfaces::DataSource::Status::INTERRUPTED] * 3) - expect(listener.statuses.last.last_error.status_code).to eq(status) - expect(processor.instance_variable_get(:@ready).set?).to be false - end - end - end - - it 'waits in the extended regime after an unexpected error' do - with_handlers(processor) do |handlers| - waiter = Thread.new { handlers[:on_error].call(http_error(401)) } - begin - expect(waiter.join(0.2)).to be_nil - expect(processor.instance_variable_get(:@retry_state).next_delay).to be_between(150, 300) - expect(states).to eq([Interfaces::DataSource::Status::INTERRUPTED]) - ensure - processor.stop - waiter.join(1) - end - end - end - - it 'ends a long wait at once when stopped' do - with_handlers(processor) do |handlers| - waiter = Thread.new { handlers[:on_error].call(http_error(401)) } - expect(waiter.join(0.2)).to be_nil - - started_at = Time.now - processor.stop - expect(waiter.join(1)).not_to be_nil - expect(Time.now - started_at).to be < 1 - expect(states.last).to eq(Interfaces::DataSource::Status::OFF) - end - end - - it 'waits the normal delay after a recoverable error' do - with_handlers(processor) do |handlers| - waiter = Thread.new { handlers[:on_error].call(http_error(503)) } - begin - expect(waiter.join(0.2)).to be_nil - expect(processor.instance_variable_get(:@retry_state).next_delay).to be_between(0.5, 1) - ensure - processor.stop - waiter.join(1) - end - end - end - - it 'backs off and warns when the server closes the stream' do - use_fast_retry_state - expect(logger).to receive(:warn) do |&block| - expect(block.call).to match(/server closed the streaming connection - will retry in 0\.0s/) - end - expect(fast_retry_state).to receive(:record_failure).with(:normal).and_call_original - - with_handlers(processor) do |handlers| - handlers[:on_error].call(SSE::Errors::StreamClosedByServerError.new) - expect(states).to eq([Interfaces::DataSource::Status::INTERRUPTED]) - expect(listener.statuses.last.last_error.kind).to eq(Interfaces::DataSource::ErrorInfo::NETWORK_ERROR) - end - end - - [ - ["error 401", -> { SSE::Errors::HTTPStatusError.new(401, "") }, :error, - "[LDClient] HTTP error 401 (invalid SDK key) for streaming connection - will retry in 0.3s"], - ["error 503", -> { SSE::Errors::HTTPStatusError.new(503, "") }, :warn, - "[LDClient] HTTP error 503 for streaming connection - will retry in 0.0s"], - ["invalid data", -> { JSON::ParserError.new("bad") }, :error, - "[LDClient] Invalid data on streaming connection: bad - will retry in 0.0s"], - ["a server close", -> { SSE::Errors::StreamClosedByServerError.new }, :warn, - "[LDClient] The server closed the streaming connection - will retry in 0.0s"], - ["another error", -> { StandardError.new("boom") }, :warn, - "[LDClient] Error on streaming connection: boom - will retry in 0.0s"], - ].each do |desc, make_error, level, message| - it "logs #{desc} once at #{level}, with the delay" do - allow(Impl::RetryState).to receive(:for_streaming).and_return( - Impl::RetryState.new(normal_initial_delay: 0.001, normal_ceiling_delay: 0.001, extended_initial_delay: 0.3, - extended_ceiling_delay: 0.3, reset_policy: Impl::RetryState::AfterConsecutiveSuccesses.new(2), - random: double("random", rand: 0.0))) - logs = [] - %i[error warn].each do |name| - allow(logger).to receive(name) { |&block| logs << [name, block.call] } - end - - with_handlers(processor) do |handlers| - handlers[:on_error].call(make_error.call) - end - expect(logs).to eq([[level, message]]) - end - end - - it 'resets to the normal regime 60 seconds after the first healthy event that follows a failure' do - now = 0.0 - retry_state = Impl::RetryState.for_streaming(1, logger, clock: -> { now }, random: double("random", rand: 0.0)) - allow(Impl::RetryState).to receive(:for_streaming).and_return(retry_state) - retry_state.record_failure(:unexpected) - - with_handlers(processor) do |handlers| - handlers[:on_connect].call({}) - handlers[:on_event].call(put_message) - now += 30 - handlers[:on_event].call(put_message) - now += 30 - - waiter = Thread.new { handlers[:on_error].call(http_error(503)) } - begin - expect(waiter.join(0.2)).to be_nil - expect(retry_state.next_delay).to eq(1) - ensure - processor.stop - waiter.join(1) - end - end - end - - it 'does not record a success for an event that fails' do - use_fast_retry_state - expect(fast_retry_state).not_to receive(:record_success) - - with_handlers(processor) do |handlers| - handlers[:on_connect].call({}) - expect { handlers[:on_event].call(SSE::StreamEvent.new(:put, '{Hi there}')) }.to raise_error(JSON::ParserError) - end - end - - it 'does not report a status after OFF from a handler that is already running' do - use_fast_retry_state - with_handlers(processor) do |handlers| - # The handler passed its stopped check just before stop reported OFF. - config.data_source_update_sink.update_status(Interfaces::DataSource::Status::OFF, nil) - handlers[:on_error].call(http_error(503)) - handlers[:on_error].call(SSE::Errors::StreamClosedByServerError.new) - - expect(states).to eq([Interfaces::DataSource::Status::OFF]) - end - end - - # A retry state with a fixed normal delay and no jitter. - def retry_state_with_delay(delay) - Impl::RetryState.new(normal_initial_delay: delay, normal_ceiling_delay: delay, extended_initial_delay: delay, - extended_ceiling_delay: delay, reset_policy: Impl::RetryState::AfterConsecutiveSuccesses.new(2), - random: double("random", rand: 0.0)) - end - - # Sends an event the way the SSE client does: an error raised by the handler goes to on_error. - def deliver_event(handlers, event) - handlers[:on_event].call(event) - rescue StandardError => e - handlers[:on_error].call(e) - end - - it 'reports invalid data as one failure, one INVALID_DATA status and one wait' do - use_fast_retry_state - expect(fast_retry_state).to receive(:record_failure).once.with(:normal).and_call_original - expect(processor.instance_variable_get(:@stop_event)).to receive(:wait).once.and_call_original - - with_handlers(processor) do |handlers| - handlers[:on_connect].call({}) - deliver_event(handlers, SSE::StreamEvent.new(:put, '{Hi there}')) - - expect(states).to eq([Interfaces::DataSource::Status::INTERRUPTED]) - expect(listener.statuses.last.last_error.kind).to eq(Interfaces::DataSource::ErrorInfo::INVALID_DATA) - end - end - - it 'waits the recorded delay before it reconnects after invalid data' do - allow(Impl::RetryState).to receive(:for_streaming).and_return(retry_state_with_delay(0.2)) - - with_handlers(processor) do |handlers| - started_at = Time.now - deliver_event(handlers, SSE::StreamEvent.new(:put, '{Hi there}')) - expect(Time.now - started_at).to be >= 0.15 - end - end - - it 'does not count an unknown event type as healthy' do - use_fast_retry_state - expect(fast_retry_state).not_to receive(:record_success) - - with_handlers(processor) do |handlers| - handlers[:on_connect].call({}) - handlers[:on_event].call(SSE::StreamEvent.new(:unknown, "{}")) - end - end - - it 'waits before it reconnects even if reporting the failure raises' do - allow(Impl::RetryState).to receive(:for_streaming).and_return(retry_state_with_delay(0.2)) - sink = config.data_source_update_sink - allow(sink).to receive(:update_status).and_call_original - allow(sink).to receive(:update_status).with(Interfaces::DataSource::Status::INTERRUPTED, anything) - .and_raise(StandardError.new("sink failure")) - - with_handlers(processor) do |handlers| - started_at = Time.now - expect { handlers[:on_error].call(http_error(503)) }.to raise_error(StandardError, "sink failure") - expect(Time.now - started_at).to be >= 0.15 - end - end - - it 'can be stopped before it is started' do - expect { processor.stop }.not_to raise_error - expect(states).to eq([Interfaces::DataSource::Status::OFF]) - expect(logger).not_to have_received(:error) - end - - it 'closes the SSE client at once if it was stopped before the client existed' do - processor.stop - with_handlers(processor) do |_handlers, sse_client| - expect(sse_client).to have_received(:close) - end - end - - it 'reports OFF and wakes a waiting handler when closing the SSE client raises' do - sse_client = double("SSE::Client") - allow(sse_client).to receive(:close).and_raise(IOError.new("close failure")) - - with_handlers(processor, sse_client) do |handlers| - waiter = Thread.new { handlers[:on_error].call(http_error(401)) } - expect(waiter.join(0.2)).to be_nil - - expect { processor.stop }.not_to raise_error - expect(waiter.join(1)).not_to be_nil - expect(states.last).to eq(Interfaces::DataSource::Status::OFF) - end - end - - it 'does not report a status or wait after it is stopped' do - with_handlers(processor) do |handlers| - processor.stop - started_at = Time.now - handlers[:on_error].call(http_error(401)) - handlers[:on_error].call(SSE::Errors::StreamClosedByServerError.new) - - expect(Time.now - started_at).to be < 1 - expect(states).to eq([Interfaces::DataSource::Status::OFF]) - end - end - end - - describe 'with a real SSE client' do - # The SSE worker thread can log after the example ends, so this cannot be a test double. - let(:logger) { $null_log } - - # Not a test double, because the SSE worker thread can use it after the example ends. - let(:no_jitter) { Object.new.tap { |r| r.define_singleton_method(:rand) { 0.0 } } } - - # Starts a processor against a server that answers every stream request with 503, and yields the - # processor, the times at which the requests arrived, and the SSE worker thread. - def with_failing_stream(retry_delay) - allow(Impl::RetryState).to receive(:for_streaming).and_return( - Impl::RetryState.new(normal_initial_delay: retry_delay, normal_ceiling_delay: retry_delay, - extended_initial_delay: retry_delay, extended_ceiling_delay: retry_delay, - reset_policy: Impl::RetryState::AfterConsecutiveSuccesses.new(2), random: no_jitter)) - request_times = Queue.new - with_server do |server| - server.setup_response("/all") do |_req, res| - request_times << Time.now - res.status = 503 - end - config = Config.new(stream_uri: server.base_uri.to_s, logger: logger) - config.data_source_update_sink = Impl::DataSource::UpdateSink.new(config.feature_store, status_broadcaster, flag_change_broadcaster) - processor = subject.new("sdk_key", config) - threads_before = Thread.list - processor.start - sse_thread = (Thread.list - threads_before).detect { |t| t.name == "LD/SSEClient" } - begin - yield processor, request_times, sse_thread - ensure - processor.stop - end - end - end - - def pop_within(queue, seconds) - deadline = Time.now + seconds - while Time.now < deadline - return queue.pop(true) unless queue.empty? - sleep(0.01) - end - nil - end - - it 'does not reconnect before the retry delay' do - with_failing_stream(0.5) do |_processor, request_times| - first = pop_within(request_times, 5) - second = pop_within(request_times, 5) - expect(first).not_to be_nil - expect(second).not_to be_nil - expect(second - first).to be >= 0.45 - end - end - - it 'ends the retry wait at once when stopped, with no further request' do - with_failing_stream(30) do |processor, request_times, sse_thread| - expect(pop_within(request_times, 5)).not_to be_nil - sleep(0.2) - expect(sse_thread).to be_alive - - processor.stop - expect(sse_thread.join(1)).not_to be_nil - expect(pop_within(request_times, 0.5)).to be_nil - end - end - end - - describe 'SSE client options' do - def captured_sse_options(processor) - captured = nil - allow(SSE::Client).to receive(:new) do |_uri, **opts| - captured = opts - double("SSE::Client", close: nil) - end - - processor.start - processor.stop - captured - end - - it 'passes the default connect timeout' do - opts = captured_sse_options(processor) - expect(opts[:connect_timeout]).to eq(Config.default_connect_timeout) - end - - it 'passes a configured connect timeout' do - config = Config.new(connect_timeout: 0.5) - opts = captured_sse_options(subject.new("sdk_key", config)) - expect(opts[:connect_timeout]).to eq(0.5) - end - - it 'passes the configured client options' do - socket_factory = Object.new - config = Config.new(socket_factory: socket_factory, initial_reconnect_delay: 3, logger: $null_log) - opts = captured_sse_options(subject.new("sdk_key", config)) - expect(opts).to eq( - headers: Impl::Util.default_http_headers("sdk_key", config), - read_timeout: Impl::DataSource::READ_TIMEOUT_SECONDS, - connect_timeout: Config.default_connect_timeout, - logger: $null_log, - socket_factory: socket_factory, - # The SDK owns the reconnect delay, so the configured initial_reconnect_delay does not reach the SSE client. - reconnect_time: 0 - ) - end - - it 'abandons a connect attempt after the connect timeout' do - socket_factory = HangingSocketFactory.new - config = Config.new( - stream_uri: "http://stream.example.com", - connect_timeout: 0.2, - socket_factory: socket_factory, - logger: $null_log - ) - processor = subject.new("sdk_key", config) - - processor.start - begin - # Without the connect timeout, the attempt blocks for the SSE client default of 10 seconds. - blocked = socket_factory.first_blocked_duration(3) - expect(blocked).not_to be_nil - expect(blocked).to be < 1 - ensure - processor.stop - end - end - end - - describe '#log_connection_result' do - it "logs successful connection when diagnostic_accumulator is provided" do - diagnostic_accumulator = double("DiagnosticAccumulator") - expect(diagnostic_accumulator).to receive(:record_stream_init).with( - kind_of(Integer), - false, - kind_of(Integer) - ) - - processor = subject.new("sdk_key", config, diagnostic_accumulator) - processor.send(:log_connection_started) - processor.send(:log_connection_result, true) - end - - it "logs failed connection when diagnostic_accumulator is provided" do - diagnostic_accumulator = double("DiagnosticAccumulator") - expect(diagnostic_accumulator).to receive(:record_stream_init).with( - kind_of(Integer), - true, - kind_of(Integer) - ) - - processor = subject.new("sdk_key", config, diagnostic_accumulator) - processor.send(:log_connection_started) - processor.send(:log_connection_result, false) - end - - it "logs connection metrics with correct timestamp and duration" do - diagnostic_accumulator = double("DiagnosticAccumulator") - - processor = subject.new("sdk_key", config, diagnostic_accumulator) - - expect(diagnostic_accumulator).to receive(:record_stream_init) do |timestamp, failed, duration| - expect(timestamp).to be_a(Integer) - expect(timestamp).to be > 0 - expect(failed).to eq(false) - expect(duration).to be_a(Integer) - expect(duration).to be >= 0 - end - - processor.send(:log_connection_started) - sleep(0.01) # Small delay to ensure measurable duration - processor.send(:log_connection_result, true) - end - - it "measures duration on the monotonic clock, unaffected by wall-clock steps" do - diagnostic_accumulator = double("DiagnosticAccumulator") - - processor = subject.new("sdk_key", config, diagnostic_accumulator) - - expect(diagnostic_accumulator).to receive(:record_stream_init) do |_timestamp, _failed, duration| - # A backward wall-clock step used to produce a negative duration here. - expect(duration).to be >= 0 - expect(duration).to be < 1000 - end - - processor.send(:log_connection_started) - Timecop.freeze(Time.now - 120) do - processor.send(:log_connection_result, true) - end - end - - it "only logs once per connection attempt" do - diagnostic_accumulator = double("DiagnosticAccumulator") - expect(diagnostic_accumulator).to receive(:record_stream_init).once - - processor = subject.new("sdk_key", config, diagnostic_accumulator) - processor.send(:log_connection_started) - processor.send(:log_connection_result, true) - # Second call should not trigger another log - processor.send(:log_connection_result, true) - end - - it "works gracefully when no diagnostic_accumulator is provided" do - processor = subject.new("sdk_key", config, nil) - - expect { - processor.send(:log_connection_started) - processor.send(:log_connection_result, true) - }.not_to raise_error - end - end - end -end diff --git a/spec/impl/data_source_spec.rb b/spec/impl/data_source_spec.rb deleted file mode 100644 index a3a20b29..00000000 --- a/spec/impl/data_source_spec.rb +++ /dev/null @@ -1,388 +0,0 @@ -require "spec_helper" - -module LaunchDarkly - module Impl - describe DataSource::UpdateSink do - subject { DataSource::UpdateSink } - let(:store) { InMemoryFeatureStore.new } - let(:executor) { SynchronousExecutor.new } - let(:status_broadcaster) { LaunchDarkly::Impl::Broadcaster.new(executor, $null_log) } - let(:flag_change_broadcaster) { LaunchDarkly::Impl::Broadcaster.new(executor, $null_log) } - let(:sink) { subject.new(store, status_broadcaster, flag_change_broadcaster) } - - it "ignores every status after OFF" do - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - error = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( - LaunchDarkly::Interfaces::DataSource::ErrorInfo::NETWORK_ERROR, 0, "late", Time.now) - - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::OFF, nil) - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, error) - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error) - - expect(listener.statuses.map(&:state)).to eq([LaunchDarkly::Interfaces::DataSource::Status::OFF]) - expect(sink.current_status.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) - expect(sink.current_status.last_error).to be_nil - end - - it "ignores a store error after OFF" do - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - allow(store).to receive(:init).and_raise(StandardError.new("store failure")) - - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::OFF, nil) - expect { sink.init({}) }.to raise_error(StandardError) - - expect(listener.statuses.map(&:state)).to eq([LaunchDarkly::Interfaces::DataSource::Status::OFF]) - end - - it "defaults to initializing" do - expect(sink.current_status.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING) - expect(sink.current_status.last_error).to be_nil - end - - it "has no environment ID until one is reported" do - expect(sink.environment_id).to be_nil - end - - it "records the environment ID from response headers" do - DataSource.record_environment_id(sink, { "X-LD-EnvID" => "env-abc" }) - expect(sink.environment_id).to eq("env-abc") - end - - it "ignores responses without a usable environment ID" do - DataSource.record_environment_id(sink, nil) - DataSource.record_environment_id(sink, {}) - DataSource.record_environment_id(sink, { "X-LD-EnvID" => "" }) - expect(sink.environment_id).to be_nil - end - - it "does not overwrite a known environment ID with an unusable one" do - DataSource.record_environment_id(sink, { "X-LD-EnvID" => "env-abc" }) - DataSource.record_environment_id(sink, { "X-LD-EnvID" => "" }) - DataSource.record_environment_id(sink, {}) - expect(sink.environment_id).to eq("env-abc") - end - - it "setting status to interrupted while initializing maintains initializing state" do - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, nil) - expect(sink.current_status.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING) - expect(sink.current_status.last_error).to be_nil - end - - it "listener is triggered only for state changes" do - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - expect(listener.statuses.count).to eq(1) - - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, nil) - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED, nil) - expect(listener.statuses.count).to eq(2) - end - - it "all listeners are called for a single change" do - listener1 = ListenerSpy.new - status_broadcaster.add_listener(listener1) - - listener2 = ListenerSpy.new - status_broadcaster.add_listener(listener2) - - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - expect(listener1.statuses.count).to eq(1) - expect(listener2.statuses.count).to eq(1) - end - - describe "simple flag change listener" do - let(:all_data) { - { - LaunchDarkly::Interfaces::DataStore::FEATURES => { - flag1: LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag1', version: 1 }), - flag2: LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag2', version: 1 }), - flag3: LaunchDarkly::Impl::Model::FeatureFlag.new( - { - key: 'flag3', - version: 1, - variation: 0, - rules: [ - { - clauses: [ - { - contextKind: 'user', - attribute: 'segmentMatch', - op: 'segmentMatch', - values: [ - 'segment2', - ], - negate: false, - }, - ], - }, - ], - } - ), - }, - LaunchDarkly::Interfaces::DataStore::SEGMENTS => { - segment1: LaunchDarkly::Impl::Model::Segment.new({ key: 'segment1', version: 1 }), - segment2: LaunchDarkly::Impl::Model::Segment.new({ key: 'segment2', version: 1 }), - }, - } - } - - it "is called once per flag changed during init" do - sink.init(all_data) - - listener = ListenerSpy.new - flag_change_broadcaster.add_listener(listener) - - updated_data = { - LaunchDarkly::Interfaces::DataStore::FEATURES => { - flag1: LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag1', version: 2 }), - flag4: LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag4', version: 1 }), - }, - } - - sink.init(updated_data) - - expect(listener.statuses.count).to eq(4) - expect(listener.statuses[0].key).to eq('flag1') # Version update - expect(listener.statuses[1].key).to eq('flag2') # Deleted - expect(listener.statuses[2].key).to eq('flag3') # Deleted - expect(listener.statuses[3].key).to eq('flag4') # Newly created - end - - it "is called if flag changes through upsert" do - sink.init(all_data) - - listener = ListenerSpy.new - flag_change_broadcaster.add_listener(listener) - - sink.upsert(LaunchDarkly::Interfaces::DataStore::FEATURES, LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag1', version: 2 })) - # A duplicate update does not change the store, so it sends no event. - sink.upsert(LaunchDarkly::Interfaces::DataStore::FEATURES, LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag1', version: 2 })) - - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].key).to eq('flag1') - end - - it "is called if flag is deleted" do - sink.init(all_data) - - listener = ListenerSpy.new - flag_change_broadcaster.add_listener(listener) - - sink.delete(LaunchDarkly::Interfaces::DataStore::FEATURES, "flag1", 2) - # A duplicate update does not change the store, so it sends no event. - sink.delete(LaunchDarkly::Interfaces::DataStore::FEATURES, "flag1", 2) - - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].key).to eq("flag1") - end - - it "is called if the segment is updated" do - sink.init(all_data) - - listener = ListenerSpy.new - flag_change_broadcaster.add_listener(listener) - - sink.upsert(LaunchDarkly::Interfaces::DataStore::SEGMENTS, LaunchDarkly::Impl::Model::Segment.new({ key: 'segment2', version: 2 })) - # A duplicate update does not change the store, so it sends no event. - sink.upsert(LaunchDarkly::Interfaces::DataStore::SEGMENTS, LaunchDarkly::Impl::Model::Segment.new({ key: 'segment2', version: 2 })) - - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].key).to eq('flag3') - end - end - - describe "prerequisite flag change listener" do - let(:all_data) { - { - LaunchDarkly::Interfaces::DataStore::FEATURES => { - flag1: LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag1', version: 1, prerequisites: [{key: 'flag2', variation: 0}] }), - flag2: LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag2', version: 1, -prerequisites: [{key: 'flag3', variation: 0}, {key: 'flag4', variation: 0}, {key: 'flag6', variation: 0}] }), - flag3: LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag3', version: 1 }), - flag4: LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag4', version: 1 }), - flag5: LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag5', version: 1 }), - flag6: LaunchDarkly::Impl::Model::FeatureFlag.new( - { - key: 'flag6', - version: 1, - variation: 0, - rules: [ - { - clauses: [ - { - contextKind: 'user', - attribute: 'segmentMatch', - op: 'segmentMatch', - values: [ - 'segment2', - ], - negate: false, - }, - ], - }, - ], - } - ), - }, - LaunchDarkly::Interfaces::DataStore::SEGMENTS => { - segment1: LaunchDarkly::Impl::Model::Segment.new({ key: 'segment1', version: 1 }), - segment2: LaunchDarkly::Impl::Model::Segment.new( - { - key: 'segment2', - version: 1, - rules: [ - { - clauses: [ - { - contextKind: 'user', - attribute: 'segmentMatch', - op: 'segmentMatch', - values: [ - 'segment1', - ], - negate: false, - }, - ], - rolloutContextKind: 'user', - }, - ], - - } - ), - }, - } - } - - it "triggers for entire dependency stack if top of chain is changed" do - sink.init(all_data) - - listener = ListenerSpy.new - flag_change_broadcaster.add_listener(listener) - - sink.upsert(LaunchDarkly::Interfaces::DataStore::FEATURES, LaunchDarkly::Impl::Model::FeatureFlag.new({ key: 'flag4', version: 2 })) - expect(listener.statuses.count).to eq(3) - expect(listener.statuses[0].key).to eq('flag4') - expect(listener.statuses[1].key).to eq('flag2') - expect(listener.statuses[2].key).to eq('flag1') - end - - it "triggers when new pre-requisites are added" do - sink.init(all_data) - - listener = ListenerSpy.new - flag_change_broadcaster.add_listener(listener) - - sink.upsert(LaunchDarkly::Interfaces::DataStore::FEATURES, LaunchDarkly::Impl::Model::FeatureFlag.new({ - key: 'flag3', version: 2, prerequisities: [{key: 'flag4', variation: 0}] - })) - expect(listener.statuses.count).to eq(3) - expect(listener.statuses[0].key).to eq('flag3') - expect(listener.statuses[1].key).to eq('flag2') - expect(listener.statuses[2].key).to eq('flag1') - end - - it "triggers when new pre-requisites are removed" do - sink.init(all_data) - - listener = ListenerSpy.new - flag_change_broadcaster.add_listener(listener) - - sink.upsert(LaunchDarkly::Interfaces::DataStore::FEATURES, LaunchDarkly::Impl::Model::FeatureFlag.new({ - key: 'flag2', version: 2, prerequisities: [{key: 'flag3', variation: 0}] - })) - expect(listener.statuses.count).to eq(2) - expect(listener.statuses[0].key).to eq('flag2') - expect(listener.statuses[1].key).to eq('flag1') - end - - it "triggers for entire dependency stack if top of chain is deleted" do - sink.init(all_data) - - listener = ListenerSpy.new - flag_change_broadcaster.add_listener(listener) - - sink.delete(LaunchDarkly::Interfaces::DataStore::FEATURES, "flag4", 2) - expect(listener.statuses.count).to eq(3) - expect(listener.statuses[0].key).to eq('flag4') - expect(listener.statuses[1].key).to eq('flag2') - expect(listener.statuses[2].key).to eq('flag1') - end - - it "triggers if dependent segment is modified" do - sink.init(all_data) - - listener = ListenerSpy.new - flag_change_broadcaster.add_listener(listener) - - sink.upsert(LaunchDarkly::Interfaces::DataStore::SEGMENTS, LaunchDarkly::Impl::Model::Segment.new({ key: 'segment1', version: 2 })) - # A duplicate update does not change the store, so it sends no event. - sink.upsert(LaunchDarkly::Interfaces::DataStore::SEGMENTS, LaunchDarkly::Impl::Model::Segment.new({ key: 'segment1', version: 2 })) - - expect(listener.statuses.count).to eq(3) - expect(listener.statuses[0].key).to eq('flag6') - expect(listener.statuses[1].key).to eq('flag2') - expect(listener.statuses[2].key).to eq('flag1') - end - - it "triggers if dependent segment rule is removed" do - sink.init(all_data) - - listener = ListenerSpy.new - flag_change_broadcaster.add_listener(listener) - - sink.delete(LaunchDarkly::Interfaces::DataStore::SEGMENTS, 'segment2', 2) - # A duplicate update does not change the store, so it sends no event. - sink.delete(LaunchDarkly::Interfaces::DataStore::SEGMENTS, 'segment2', 2) - - expect(listener.statuses.count).to eq(3) - expect(listener.statuses[0].key).to eq('flag6') - expect(listener.statuses[1].key).to eq('flag2') - expect(listener.statuses[2].key).to eq('flag1') - end - end - - describe "listeners are triggered for store errors" do - def confirm_store_error(error_type) - # Make it valid first so the error changes from initializing - sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - - listener = ListenerSpy.new - status_broadcaster.add_listener(listener) - - allow(store).to receive(:init).and_raise(StandardError.new("init error")) - allow(store).to receive(:upsert).and_raise(StandardError.new("upsert error")) - allow(store).to receive(:delete).and_raise(StandardError.new("delete error")) - - begin - yield - rescue - # ignored - end - - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) - expect(listener.statuses[0].last_error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::STORE_ERROR) - expect(listener.statuses[0].last_error.message).to eq("#{error_type} error") - end - - it "when calling init" do - confirm_store_error("init") { sink.init({}) } - end - - it "when calling upsert" do - confirm_store_error("upsert") { sink.upsert("flag", nil) } - end - - it "when calling delete" do - confirm_store_error("delete") { sink.delete("flag", "flag-key", 1) } - end - end - end - end -end diff --git a/spec/impl/data_store/feature_store_client_wrapper_spec.rb b/spec/impl/data_store/feature_store_client_wrapper_spec.rb index 94d6cf4a..eed7eca5 100644 --- a/spec/impl/data_store/feature_store_client_wrapper_spec.rb +++ b/spec/impl/data_store/feature_store_client_wrapper_spec.rb @@ -2,6 +2,8 @@ require "spec_helper" require "ldclient-rb/impl/data_store/feature_store_client_wrapper" +require "ldclient-rb/impl/data_store/status_provider" +require "ldclient-rb/impl/broadcaster" module LaunchDarkly module Impl @@ -32,6 +34,90 @@ module DataStore end end + describe "availability monitoring" do + # Calls the block and ignores the error that it raises. + def ignore_error + yield + raise "the block did not raise an error" + rescue StandardError => e + raise if e.message == "the block did not raise an error" + end + + it "does not notify the sink when the store does not support monitoring" do + inner = double("store", stop: nil, monitoring_enabled?: false) + allow(inner).to receive(:init).and_raise(StandardError.new("init error")) + sink = double("sink") + expect(sink).not_to receive(:update_status) + expect(LaunchDarkly::Impl::RepeatingTask).not_to receive(:new) + + ensure_stop(described_class.new(inner, sink, logger)) do |wrapper| + ignore_error { wrapper.init({}) } + end + end + + it "notifies the status provider when the store becomes unavailable and when it recovers" do + listener = ListenerSpy.new + broadcaster = LaunchDarkly::Impl::Broadcaster.new(SynchronousExecutor.new, $null_log) + broadcaster.add_listener(listener) + inner = double("store", stop: nil, monitoring_enabled?: true) + allow(inner).to receive(:available?).and_return(false, true) + allow(inner).to receive(:init).and_raise(StandardError.new("init error")) + sink = LaunchDarkly::Impl::DataStore::StatusProviderV2.new(inner, broadcaster) + + ensure_stop(described_class.new(inner, sink, logger)) do |wrapper| + ignore_error { wrapper.init({}) } + + statuses = listener.wait_for_count(2) + expect(statuses.map(&:available)).to eq([false, true]) + expect(statuses.map(&:stale)).to eq([true, true]) + expect(sink.status.available).to be true + end + end + + it "can stop while the availability poller is running" do + checking = Concurrent::Event.new + release = Concurrent::Event.new + inner = double("store", stop: nil, monitoring_enabled?: true) + allow(inner).to receive(:all).and_raise(StandardError.new("read error")) + # Hold the poller thread inside its availability check while stop runs. + allow(inner).to receive(:available?) do + checking.set + release.wait(5) + true + end + + wrapper = described_class.new(inner, status_sink, logger) + ignore_error { wrapper.all(:features) } + expect(checking.wait(2)).to be true + + stopped = Concurrent::Event.new + stopper = Thread.new do + wrapper.stop + stopped.set + end + # Stop waits for the poller thread to finish its check. + expect(stopped.wait(0.1)).to be false + release.set + + expect(stopped.wait(5)).to be true + stopper.join(1) + expect(inner).to have_received(:stop).once + end + + it "does not start the availability poller after stop" do + inner = double("store", stop: nil, monitoring_enabled?: true, available?: true) + allow(inner).to receive(:all).and_raise(StandardError.new("read error")) + sink = double("sink") + expect(sink).not_to receive(:update_status) + expect(LaunchDarkly::Impl::RepeatingTask).not_to receive(:new) + + wrapper = described_class.new(inner, sink, logger) + wrapper.stop + + ignore_error { wrapper.all(:features) } + end + end + describe "#disable_cache" do it "forwards to the underlying store when supported" do inner = double("store", disable_cache: nil, init: nil, get: nil, all: nil, delete: nil, upsert: nil, initialized?: false, diff --git a/spec/impl/data_store/in_memory_feature_store_v2_spec.rb b/spec/impl/data_store/in_memory_feature_store_v2_spec.rb index f627df4e..1cc8f610 100644 --- a/spec/impl/data_store/in_memory_feature_store_v2_spec.rb +++ b/spec/impl/data_store/in_memory_feature_store_v2_spec.rb @@ -1,6 +1,5 @@ require "spec_helper" require "ldclient-rb/impl/data_store/in_memory_feature_store" -require "ldclient-rb/impl/data_store/status" module LaunchDarkly module Impl diff --git a/spec/impl/data_store/store_spec.rb b/spec/impl/data_store/store_spec.rb index 680e66bb..2d6e9f62 100644 --- a/spec/impl/data_store/store_spec.rb +++ b/spec/impl/data_store/store_spec.rb @@ -3,7 +3,6 @@ require "spec_helper" require "ldclient-rb/impl/data_store/store" require "ldclient-rb/impl/broadcaster" -require "ldclient-rb/impl/data_store/status" require "ldclient-rb/interfaces/data_system" module LaunchDarkly diff --git a/spec/impl/data_system/fdv1_spec.rb b/spec/impl/data_system/fdv1_spec.rb deleted file mode 100644 index bc654b1d..00000000 --- a/spec/impl/data_system/fdv1_spec.rb +++ /dev/null @@ -1,306 +0,0 @@ -require "spec_helper" -require "mock_components" -require "ldclient-rb/impl/data_system/fdv1" - -module LaunchDarkly - module Impl - module DataSystem - describe FDv1 do - let(:sdk_key) { "test-sdk-key" } - let(:config) { LaunchDarkly::Config.new } - subject { FDv1.new(sdk_key, config) } - - describe "#initialize" do - it "injects data_source_update_sink into config" do - subject # Force creation of FDv1 instance - expect(config.data_source_update_sink).to be_a(LaunchDarkly::Impl::DataSource::UpdateSink) - end - - it "wraps the feature store with FeatureStoreClientWrapper" do - original_store = config.feature_store - subject # Force creation of FDv1 instance - - wrapped_store = config.feature_store - expect(wrapped_store).to be_a(LaunchDarkly::Impl::FeatureStoreClientWrapper) - expect(wrapped_store.instance_variable_get(:@store)).to eq(original_store) - end - - it "avoids nested wrappers when config.feature_store is already wrapped" do - # First initialization wraps the store - original_store = config.feature_store - first_fdv1 = FDv1.new(sdk_key, config) - first_wrapper = config.feature_store - expect(first_wrapper).to be_a(LaunchDarkly::Impl::FeatureStoreClientWrapper) - - # Second initialization (simulating postfork) should unwrap and re-wrap the original - second_fdv1 = FDv1.new(sdk_key, config) - second_wrapper = config.feature_store - expect(second_wrapper).to be_a(LaunchDarkly::Impl::FeatureStoreClientWrapper) - - # The inner store should be the original, not the first wrapper - inner_store = second_wrapper.instance_variable_get(:@store) - expect(inner_store).to eq(original_store) - expect(inner_store).not_to be_a(LaunchDarkly::Impl::FeatureStoreClientWrapper) - end - end - - describe "#start" do - # Some examples start a real processor. Stop it so no thread outlives the example. - after { subject.stop } - - it "returns a Concurrent::Event" do - ready_event = subject.start - expect(ready_event).to be_a(Concurrent::Event) - end - - it "creates streaming processor by default" do - allow(LaunchDarkly::Impl::DataSource::StreamProcessor).to receive(:new).and_call_original - subject.start - expect(LaunchDarkly::Impl::DataSource::StreamProcessor).to have_received(:new).with(sdk_key, config, nil) - end - - context "with polling mode" do - let(:config) { LaunchDarkly::Config.new(stream: false) } - - it "creates polling processor" do - allow(LaunchDarkly::Impl::DataSource::PollingProcessor).to receive(:new).and_call_original - subject.start - expect(LaunchDarkly::Impl::DataSource::PollingProcessor).to have_received(:new) - end - end - - context "with offline mode" do - let(:config) { LaunchDarkly::Config.new(offline: true) } - - it "creates null processor" do - expect(LaunchDarkly::Impl::DataSource::NullUpdateProcessor).to receive(:new).and_call_original - ready_event = subject.start - expect(ready_event.set?).to be true - end - end - - context "with LDD mode" do - let(:config) { LaunchDarkly::Config.new(use_ldd: true) } - - it "creates null processor" do - expect(LaunchDarkly::Impl::DataSource::NullUpdateProcessor).to receive(:new).and_call_original - ready_event = subject.start - expect(ready_event.set?).to be true - end - end - - context "with custom data source factory" do - let(:custom_processor) { MockUpdateProcessor.new } - let(:factory) { ->(sdk_key, config, diag) { custom_processor } } - let(:config) { LaunchDarkly::Config.new(data_source: factory) } - - it "calls factory with sdk_key, config, and diagnostic_accumulator" do - expect(factory).to receive(:call).with(sdk_key, config, nil).and_return(custom_processor) - subject.start - end - - it "passes diagnostic_accumulator if set" do - diagnostic_accumulator = double("DiagnosticAccumulator") - subject.set_diagnostic_accumulator(diagnostic_accumulator) - expect(factory).to receive(:call).with(sdk_key, config, diagnostic_accumulator).and_return(custom_processor) - subject.start - end - - context "with arity 2 factory" do - let(:factory) { ->(sdk_key, config) { custom_processor } } - - it "calls factory without diagnostic_accumulator" do - expect(factory).to receive(:call).with(sdk_key, config).and_return(custom_processor) - subject.start - end - end - end - - context "with custom data source instance" do - let(:custom_processor) { MockUpdateProcessor.new } - let(:config) { LaunchDarkly::Config.new(data_source: custom_processor) } - - it "uses the instance directly" do - ready_event = subject.start - expect(ready_event).to be_a(Concurrent::Event) - end - end - - it "returns the same event on multiple calls" do - first_event = subject.start - second_event = subject.start - third_event = subject.start - - expect(second_event).to be(first_event) - expect(third_event).to be(first_event) - end - - it "does not create a new processor on subsequent calls" do - processor = MockUpdateProcessor.new - allow(subject).to receive(:make_update_processor).and_return(processor) - - subject.start - expect(subject).to have_received(:make_update_processor).once - - subject.start - subject.start - # Should still only be called once - expect(subject).to have_received(:make_update_processor).once - end - end - - describe "#stop" do - it "stops the update processor" do - processor = MockUpdateProcessor.new - allow(subject).to receive(:make_update_processor).and_return(processor) - subject.start - expect(processor).to receive(:stop) - subject.stop - end - - it "shuts down the executor" do - executor = subject.instance_variable_get(:@shared_executor) - expect(executor).to receive(:shutdown) - subject.stop - end - - it "does nothing if not started" do - expect { subject.stop }.not_to raise_error - end - end - - describe "#store" do - it "returns the store wrapper" do - expect(subject.store).to be_a(LaunchDarkly::Impl::FeatureStoreClientWrapper) - end - end - - describe "#set_diagnostic_accumulator" do - it "stores the diagnostic accumulator" do - diagnostic_accumulator = double("DiagnosticAccumulator") - expect { subject.set_diagnostic_accumulator(diagnostic_accumulator) }.not_to raise_error - end - end - - describe "#data_source_status_provider" do - it "returns the data source status provider" do - expect(subject.data_source_status_provider).to be_a(LaunchDarkly::Impl::DataSource::StatusProvider) - end - end - - describe "#data_store_status_provider" do - it "returns the data store status provider" do - expect(subject.data_store_status_provider).to be_a(LaunchDarkly::Impl::DataStore::StatusProvider) - end - end - - describe "#flag_change_broadcaster" do - it "returns the flag change broadcaster" do - expect(subject.flag_change_broadcaster).to be_a(LaunchDarkly::Impl::Broadcaster) - end - end - - describe "#data_availability" do - context "when offline" do - let(:config) { LaunchDarkly::Config.new(offline: true) } - - it "returns DEFAULTS" do - expect(subject.data_availability).to eq(DataAvailability::DEFAULTS) - end - end - - context "when update processor is initialized" do - it "returns REFRESHED" do - processor = MockUpdateProcessor.new - allow(subject).to receive(:make_update_processor).and_return(processor) - subject.start - processor.ready.set - - expect(subject.data_availability).to eq(DataAvailability::REFRESHED) - end - end - - context "when store is initialized but processor is not" do - it "returns CACHED" do - # Initialize the store - subject.store.init({}) - - expect(subject.data_availability).to eq(DataAvailability::CACHED) - end - end - - context "when neither processor nor store are initialized" do - it "returns DEFAULTS" do - expect(subject.data_availability).to eq(DataAvailability::DEFAULTS) - end - end - - context "in LDD mode" do - let(:config) { LaunchDarkly::Config.new(use_ldd: true) } - - it "always returns REFRESHED for backwards compatibility" do - subject.start - # Returns REFRESHED even when store is empty - expect(subject.data_availability).to eq(DataAvailability::REFRESHED) - - # Still returns REFRESHED when store is initialized - subject.store.init({}) - expect(subject.data_availability).to eq(DataAvailability::REFRESHED) - end - end - end - - describe "#target_availability" do - context "when offline" do - let(:config) { LaunchDarkly::Config.new(offline: true) } - - it "returns DEFAULTS" do - expect(subject.target_availability).to eq(DataAvailability::DEFAULTS) - end - end - - context "when not offline" do - it "returns REFRESHED" do - expect(subject.target_availability).to eq(DataAvailability::REFRESHED) - end - end - - context "with LDD mode" do - let(:config) { LaunchDarkly::Config.new(use_ldd: true) } - - it "returns REFRESHED" do - expect(subject.target_availability).to eq(DataAvailability::REFRESHED) - end - end - end - - describe "integration with diagnostic accumulator" do - # These examples start a real processor. Stop it so no thread outlives the example. - after { subject.stop } - - it "passes diagnostic accumulator to streaming processor" do - diagnostic_accumulator = double("DiagnosticAccumulator") - subject.set_diagnostic_accumulator(diagnostic_accumulator) - - expect(LaunchDarkly::Impl::DataSource::StreamProcessor).to receive(:new).with(sdk_key, config, diagnostic_accumulator).and_call_original - subject.start - end - - context "with polling mode" do - let(:config) { LaunchDarkly::Config.new(stream: false) } - - it "does not pass diagnostic accumulator to polling processor" do - diagnostic_accumulator = double("DiagnosticAccumulator") - subject.set_diagnostic_accumulator(diagnostic_accumulator) - - # PollingProcessor doesn't accept diagnostic_accumulator - expect(LaunchDarkly::Impl::DataSource::PollingProcessor).to receive(:new).with(config, anything).and_call_original - subject.start - end - end - end - end - end - end -end - diff --git a/spec/impl/data_system/fdv2_datasystem_spec.rb b/spec/impl/data_system/fdv2_datasystem_spec.rb index 82d8e9ad..fc9f2980 100644 --- a/spec/impl/data_system/fdv2_datasystem_spec.rb +++ b/spec/impl/data_system/fdv2_datasystem_spec.rb @@ -1,4 +1,5 @@ require "spec_helper" +require "mock_components" require "ldclient-rb/impl/data_system/fdv2" require "ldclient-rb/integrations/test_data_v2" require "ldclient-rb/data_system" @@ -123,6 +124,74 @@ def build(_sdk_key, _config) fdv2.stop end + + def initialized_store + store = LaunchDarkly::MockFeatureStore.new + store.init({}) + store + end + + it "reports cached availability when the store is initialized but no data source has sent data" do + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .synchronizers([LaunchDarkly::SilentSynchronizerBuilder.new]) + .data_store(initialized_store, LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_WRITE) + .build + + ensure_stop(FDv2.new(sdk_key, config, data_system_config)) do |fdv2| + fdv2.start + + expect(fdv2.data_availability).to eq(DataAvailability::CACHED) + expect(fdv2.target_availability).to eq(DataAvailability::REFRESHED) + end + end + + it "reports cached availability in daemon mode" do + data_system_config = LaunchDarkly::DataSystem.daemon(initialized_store).build + + ensure_stop(FDv2.new(sdk_key, config, data_system_config)) do |fdv2| + expect(fdv2.start.wait(1)).to be true + + expect(fdv2.data_availability).to eq(DataAvailability::CACHED) + expect(fdv2.target_availability).to eq(DataAvailability::CACHED) + end + end + + it "reports cached availability in daemon mode even when the store is not initialized" do + data_system_config = LaunchDarkly::DataSystem.daemon(LaunchDarkly::MockFeatureStore.new).build + + ensure_stop(FDv2.new(sdk_key, config, data_system_config)) do |fdv2| + fdv2.start + + expect(fdv2.data_availability).to eq(DataAvailability::CACHED) + expect(fdv2.target_availability).to eq(DataAvailability::CACHED) + end + end + + it "reports defaults availability when data sources are configured but the store is not initialized" do + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .synchronizers([LaunchDarkly::SilentSynchronizerBuilder.new]) + .build + + ensure_stop(FDv2.new(sdk_key, config, data_system_config)) do |fdv2| + fdv2.start + + expect(fdv2.data_availability).to eq(DataAvailability::DEFAULTS) + expect(fdv2.target_availability).to eq(DataAvailability::REFRESHED) + end + end + + it "reports defaults availability with an uninitialized persistent store" do + data_system_config = LaunchDarkly::DataSystem::ConfigBuilder.new + .synchronizers([LaunchDarkly::SilentSynchronizerBuilder.new]) + .data_store(LaunchDarkly::MockFeatureStore.new, LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_WRITE) + .build + + ensure_stop(FDv2.new(sdk_key, config, data_system_config)) do |fdv2| + fdv2.start + + expect(fdv2.data_availability).to eq(DataAvailability::DEFAULTS) + end + end end describe "environment ID" do @@ -647,7 +716,7 @@ def build(_sdk_key, _config) error_fetch = LaunchDarkly::Interfaces::DataSystem::FetchResult.new( result: LaunchDarkly::Result.fail( "boom", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(500) + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(500) ), fallback_to_fdv1: true ) @@ -675,29 +744,6 @@ def build(_sdk_key, _config) end end - describe "payload filter key" do - let(:data_system_config) do - LaunchDarkly::DataSystem::ConfigBuilder.new - .initializers([]) - .synchronizers([]) - .build - end - - it "warns when a payload filter key is configured" do - logger = double.as_null_object - expect(logger).to receive(:warn) - - FDv2.new(sdk_key, LaunchDarkly::Config.new(logger: logger, payload_filter_key: "microservice-1"), data_system_config) - end - - it "does not warn when no payload filter key is configured" do - logger = double.as_null_object - expect(logger).not_to receive(:warn) - - FDv2.new(sdk_key, LaunchDarkly::Config.new(logger: logger), data_system_config) - end - end - describe "data source status" do it "publishes the valid status before releasing ready waiters" do td = LaunchDarkly::Integrations::TestDataV2.data_source diff --git a/spec/impl/data_system/fdv2_persistence_spec.rb b/spec/impl/data_system/fdv2_persistence_spec.rb index d6807568..082e0f45 100644 --- a/spec/impl/data_system/fdv2_persistence_spec.rb +++ b/spec/impl/data_system/fdv2_persistence_spec.rb @@ -5,7 +5,6 @@ require "ldclient-rb/integrations/test_data_v2" require "ldclient-rb/data_system" require "ldclient-rb/impl/data_system" -require "ldclient-rb/impl/data_store/status" module LaunchDarkly module Impl diff --git a/spec/impl/data_system/polling_http_requester_spec.rb b/spec/impl/data_system/polling_http_requester_spec.rb new file mode 100644 index 00000000..b0453569 --- /dev/null +++ b/spec/impl/data_system/polling_http_requester_spec.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +require "spec_helper" +require "http_util" +require "ldclient-rb/impl/data_system/polling" +require "ldclient-rb/interfaces" +require "json" +require "stringio" +require "zlib" + +module LaunchDarkly + module Impl + module DataSystem + module PollingHTTPRequesterSpec + SDK_KEY = "secret" + + def self.fdv2_payload(flag_key, version) + { + events: [ + { + event: "server-intent", + data: { payloads: [{ id: "id", target: version, intentCode: "xfer-full", reason: "payload-missing" }] }, + }, + { + event: "put-object", + data: { key: flag_key, kind: "flag", version: version, object: { key: flag_key, version: version } }, + }, + { + event: "payload-transferred", + data: { state: "(p:id:#{version})", id: "id", version: version }, + }, + ], + }.to_json + end + + def self.fdv1_payload(flag_key, version) + { flags: { flag_key => { key: flag_key, version: version } }, segments: {} }.to_json + end + + def self.gzip(body) + io = StringIO.new + gz = Zlib::GzipWriter.new(io) + gz.write(body) + gz.close + io.string + end + end + + [ + { requester_class: HTTPPollingRequester, path: FDV2_POLLING_ENDPOINT, payload: PollingHTTPRequesterSpec.method(:fdv2_payload) }, + { requester_class: HTTPFDv1PollingRequester, path: FDV1_POLLING_ENDPOINT, payload: PollingHTTPRequesterSpec.method(:fdv1_payload) }, + ].each do |params| + requester_class = params[:requester_class] + path = params[:path] + payload = params[:payload] + + RSpec.describe requester_class do + let(:sdk_key) { PollingHTTPRequesterSpec::SDK_KEY } + + def with_requester(base_uri, config_opts = {}) + http_config = HttpConfigOptions.new(base_uri: base_uri) + config = Config.new({ logger: $null_log }.merge(config_opts)) + requester = described_class.new(sdk_key, http_config, config) + begin + yield requester + ensure + requester.stop + end + end + + def change_keys(result) + result.value.changes.map { |change| change.key.to_s } + end + + def setup_etag_response(server, body, etag) + server.setup_response("/") do |_, res| + res.status = 200 + res.body = body + res["ETag"] = etag + end + end + + it "uses the expected URI and headers" do + with_server do |server| + server.setup_ok_response("/", payload.call("flagkey", 1)) + with_requester(server.base_uri.to_s, application: { id: "id", version: "version" }) do |requester| + requester.fetch(nil) + end + + expect(server.requests.count).to eq 1 + expect(server.requests[0].unparsed_uri).to eq path + expect(server.requests[0].header).to include({ + "authorization" => [sdk_key], + "user-agent" => ["RubyClient/" + VERSION], + "accept-encoding" => ["gzip"], + "x-launchdarkly-tags" => ["application-id/id application-version/version"], + }) + end + end + + it "sends the wrapper header if configured" do + with_server do |server| + server.setup_ok_response("/", payload.call("flagkey", 1)) + with_requester(server.base_uri.to_s, wrapper_name: "MyWrapper", wrapper_version: "1.0") do |requester| + requester.fetch(nil) + end + + expect(server.requests.count).to eq 1 + expect(server.requests[0].header).to include({ "x-launchdarkly-wrapper" => ["MyWrapper/1.0"] }) + end + end + + it "parses the response into a change set" do + with_server do |server| + server.setup_ok_response("/", payload.call("flagkey", 1), "application/json") + with_requester(server.base_uri.to_s) do |requester| + result = requester.fetch(nil) + + expect(result.success?).to be true + expect(result.value.intent_code).to eq Interfaces::DataSystem::IntentCode::TRANSFER_FULL + expect(change_keys(result)).to eq ["flagkey"] + end + end + end + + it "returns the response headers" do + with_server do |server| + server.setup_ok_response("/", payload.call("flagkey", 1), "application/json", { "X-LD-EnvID" => "env-abc" }) + with_requester(server.base_uri.to_s) do |requester| + result = requester.fetch(nil) + + expect(result.success?).to be true + expect(result.headers["x-ld-envid"]).to eq "env-abc" + end + end + end + + it "decodes a gzip-compressed response" do + with_server do |server| + server.setup_response("/") do |_, res| + res.status = 200 + res["Content-Encoding"] = "gzip" + res.body = PollingHTTPRequesterSpec.gzip(payload.call("flagkey", 1)) + end + with_requester(server.base_uri.to_s) do |requester| + result = requester.fetch(nil) + + expect(result.success?).to be true + expect(change_keys(result)).to eq ["flagkey"] + end + end + end + + it "sends the ETag from the previous response and treats 304 as no change" do + with_server do |server| + with_requester(server.base_uri.to_s) do |requester| + setup_etag_response(server, payload.call("flagkey", 1), "abc") + expect(requester.fetch(nil).success?).to be true + expect(server.requests[0].header).not_to include("if-none-match") + + server.setup_status_response("/", 304) + result = requester.fetch(nil) + + expect(server.requests.count).to eq 2 + expect(server.requests[1].header).to include({ "if-none-match" => ["abc"] }) + expect(result.success?).to be true + expect(result.value.intent_code).to eq Interfaces::DataSystem::IntentCode::TRANSFER_NONE + expect(result.value.changes).to be_empty + end + end + end + + it "replaces the stored ETag when a response has a new ETag" do + with_server do |server| + with_requester(server.base_uri.to_s) do |requester| + setup_etag_response(server, payload.call("flag1", 1), "abc") + expect(change_keys(requester.fetch(nil))).to eq ["flag1"] + + setup_etag_response(server, payload.call("flag2", 2), "xyz") + expect(change_keys(requester.fetch(nil))).to eq ["flag2"] + expect(server.requests[1].header).to include({ "if-none-match" => ["abc"] }) + + server.setup_status_response("/", 304) + requester.fetch(nil) + expect(server.requests.count).to eq 3 + expect(server.requests[2].header).to include({ "if-none-match" => ["xyz"] }) + end + end + end + + it "returns a failed result for an error status" do + with_server do |server| + server.setup_status_response("/", 400, { "X-LD-EnvID" => "env-abc" }) + with_requester(server.base_uri.to_s) do |requester| + result = requester.fetch(nil) + + expect(result.success?).to be false + expect(result.exception).to be_a(UnexpectedResponseError) + expect(result.exception.status).to eq 400 + expect(result.headers["x-ld-envid"]).to eq "env-abc" + end + end + end + + it "can use a proxy server" do + # The stub server acts as the proxy. The request reaches it instead of the fake target. + fake_target_uri = "http://request-will-not-really-go-here.invalid" + with_server do |proxy| + proxy.setup_ok_response(path, payload.call("flagkey", 1), "application/json") + begin + ENV["http_proxy"] = proxy.base_uri.to_s + with_requester(fake_target_uri) do |requester| + result = requester.fetch(nil) + + expect(result.success?).to be true + expect(change_keys(result)).to eq ["flagkey"] + end + ensure + ENV["http_proxy"] = nil + end + end + end + end + end + + RSpec.describe HTTPPollingRequester do + let(:selector) { Interfaces::DataSystem::Selector.new(state: "(p:id:5)", version: 5) } + + def fetch_with(selector) + with_server do |server| + server.setup_ok_response("/", PollingHTTPRequesterSpec.fdv2_payload("flagkey", 1)) + requester = HTTPPollingRequester.new( + PollingHTTPRequesterSpec::SDK_KEY, + HttpConfigOptions.new(base_uri: server.base_uri.to_s), + Config.new(logger: $null_log) + ) + begin + requester.fetch(selector) + ensure + requester.stop + end + expect(server.requests.count).to eq 1 + yield server.requests[0] + end + end + + it "sends the basis query parameter when the selector is defined" do + fetch_with(selector) do |request| + expect(request.path).to eq FDV2_POLLING_ENDPOINT + expect(request.query).to eq({ "basis" => "(p:id:5)" }) + end + end + + it "does not send the basis query parameter when the selector is not defined" do + fetch_with(Interfaces::DataSystem::Selector.no_selector) do |request| + expect(request.unparsed_uri).to eq FDV2_POLLING_ENDPOINT + end + end + + it "does not send the basis query parameter when there is no selector" do + fetch_with(nil) do |request| + expect(request.unparsed_uri).to eq FDV2_POLLING_ENDPOINT + end + end + end + + RSpec.describe HTTPFDv1PollingRequester do + it "does not send the basis query parameter when the selector is defined" do + with_server do |server| + server.setup_ok_response("/", PollingHTTPRequesterSpec.fdv1_payload("flagkey", 1)) + requester = HTTPFDv1PollingRequester.new( + PollingHTTPRequesterSpec::SDK_KEY, + HttpConfigOptions.new(base_uri: server.base_uri.to_s), + Config.new(logger: $null_log) + ) + begin + requester.fetch(Interfaces::DataSystem::Selector.new(state: "(p:id:5)", version: 5)) + ensure + requester.stop + end + + expect(server.requests.count).to eq 1 + expect(server.requests[0].unparsed_uri).to eq FDV1_POLLING_ENDPOINT + end + end + end + end + end +end diff --git a/spec/impl/data_system/polling_initializer_spec.rb b/spec/impl/data_system/polling_initializer_spec.rb index a4fddfba..7dfcdabb 100644 --- a/spec/impl/data_system/polling_initializer_spec.rb +++ b/spec/impl/data_system/polling_initializer_spec.rb @@ -113,7 +113,7 @@ def selector mock_requester = MockPollingRequester.new( LaunchDarkly::Result.fail( "failure message", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(408) + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(408) ) ) ds = PollingDataSource.new(1.0, mock_requester, logger) @@ -128,7 +128,7 @@ def selector mock_requester = MockPollingRequester.new( LaunchDarkly::Result.fail( "failure message", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(401) + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(401) ) ) ds = PollingDataSource.new(1.0, mock_requester, logger) @@ -207,7 +207,7 @@ def selector headers_with_fallback = { LD_FD_FALLBACK_HEADER => 'true' } error_result = LaunchDarkly::Result.fail( "failure message", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(500), + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(500), headers_with_fallback ) mock_requester = MockPollingRequester.new(error_result) @@ -244,7 +244,7 @@ def selector headers_with_fallback = { 'x-ld-fd-fallback' => 'true' } error_result = LaunchDarkly::Result.fail( "failure message", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(500), + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(500), headers_with_fallback ) mock_requester = MockPollingRequester.new(error_result) @@ -260,7 +260,7 @@ def selector mock_requester = MockPollingRequester.new( LaunchDarkly::Result.fail( "failure message", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(500) + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(500) ) ) ds = PollingDataSource.new(1.0, mock_requester, logger) diff --git a/spec/impl/data_system/polling_synchronizer_spec.rb b/spec/impl/data_system/polling_synchronizer_spec.rb index b2538843..410f5b8f 100644 --- a/spec/impl/data_system/polling_synchronizer_spec.rb +++ b/spec/impl/data_system/polling_synchronizer_spec.rb @@ -263,7 +263,7 @@ def selector failure = LaunchDarkly::Result.fail( "error for test", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(408) + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(408) ) synchronizer = PollingDataSource.new( @@ -308,7 +308,7 @@ def selector it "unrecoverable error shuts down" do failure = LaunchDarkly::Result.fail( "error for test", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(401) + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(401) ) synchronizer = PollingDataSource.new( @@ -415,7 +415,7 @@ def selector headers_error = { LD_ENVID_HEADER => 'test-env-408' } failure = LaunchDarkly::Result.fail( "error for test", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(408), + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(408), headers_error ) @@ -453,7 +453,7 @@ def selector headers_error = { LD_ENVID_HEADER => 'test-env-401' } failure = LaunchDarkly::Result.fail( "error for test", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(401), + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(401), headers_error ) @@ -489,7 +489,7 @@ def selector } failure = LaunchDarkly::Result.fail( "error for test", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(503), + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(503), headers_error ) @@ -605,7 +605,7 @@ def selector # 408 is a recoverable error error_result = LaunchDarkly::Result.fail( "error for test", - LaunchDarkly::Impl::DataSource::UnexpectedResponseError.new(408), + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(408), headers_with_fallback ) @@ -715,6 +715,51 @@ def selector expect(requester.stop_called).to eq(true) end end + + describe "#stop" do + # The poll interval is long. Only stop can end the wait within the test. + def expect_stop_ends_wait(result) + requester = RequesterWithCleanup.new([result]) + synchronizer = PollingDataSource.new(1000, requester, logger) + updates = Queue.new + + thread = Thread.new do + synchronizer.sync(MockSelectorStore.new(LaunchDarkly::Interfaces::DataSystem::Selector.no_selector)) do |update| + updates << update + end + end + + begin + first = updates.pop(timeout: 1) + expect(first).not_to be_nil + + synchronizer.stop + expect(thread.join(1)).not_to be_nil + expect(updates).to be_empty + expect(requester.stop_called).to eq(true) + first + ensure + thread.kill + end + end + + it "ends the poll interval wait after a success promptly" do + result = LaunchDarkly::Result.success(LaunchDarkly::Interfaces::DataSystem::ChangeSetBuilder.no_changes, {}) + + update = expect_stop_ends_wait(result) + expect(update.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + end + + it "ends the poll interval wait after a recoverable error promptly" do + result = LaunchDarkly::Result.fail( + "error for test", + LaunchDarkly::Impl::DataSystem::UnexpectedResponseError.new(503) + ) + + update = expect_stop_ends_wait(result) + expect(update.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + end + end end end end diff --git a/spec/impl/data_system/streaming_headers_spec.rb b/spec/impl/data_system/streaming_headers_spec.rb index f7c79417..6da13874 100644 --- a/spec/impl/data_system/streaming_headers_spec.rb +++ b/spec/impl/data_system/streaming_headers_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "spec_helper" +require "http_util" +require "json" require "ldclient-rb/impl/data_system/streaming" require "ldclient-rb/interfaces" @@ -57,21 +59,23 @@ module DataSystem end it "does not trigger fallback when header value is not 'true'" do - error = SSE::Errors::HTTPStatusError.new( - 503, - "Not a fallback", - { - "x-launchdarkly-fd-fallback" => "false", - } - ) - - # Simulate the header extraction logic from on_error callback (lines 128-135) - fallback = false - if error.respond_to?(:headers) && error.headers - fallback = true if error.headers["x-launchdarkly-fd-fallback"] == 'true' + with_server do |server| + server.setup_status_response(FDV2_STREAMING_ENDPOINT, 503, { "X-LD-FD-Fallback" => "false" }) + synchronizer = LaunchDarkly::DataSystem::StreamingDataSourceBuilder.new + .base_uri(server.base_uri.to_s) + .build(sdk_key, LaunchDarkly::Config.new(logger: $null_log)) + updates = Queue.new + thread = Thread.new { synchronizer.sync(Struct.new(:selector).new(nil)) { |update| updates << update } } + begin + update = updates.pop(timeout: 5) + ensure + synchronizer.stop + thread.join(5) + end + + expect(update.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + expect(update.fallback_to_fdv1).to be_falsy end - - expect(fallback).to be false end it "handles errors without headers gracefully" do @@ -86,47 +90,62 @@ module DataSystem end describe "on_connect callback" do - it "extracts environment ID from connection headers" do - # Simulate HTTP::Headers object from SSE client - headers = double("HTTP::Headers") - allow(headers).to receive(:[]).with("x-launchdarkly-env-id").and_return("env-from-connect") - allow(headers).to receive(:[]).with("x-launchdarkly-fd-fallback").and_return(nil) - - # Verify the logic that would be in on_connect - envid = nil - if headers - envid_from_headers = headers["x-launchdarkly-env-id"] - envid = envid_from_headers if envid_from_headers + let(:config) { LaunchDarkly::Config.new(logger: $null_log) } + let(:selector_store) { Struct.new(:selector).new(nil) } + let(:transfer_none_event) do + intent = LaunchDarkly::Interfaces::DataSystem::ServerIntent.new( + payload: LaunchDarkly::Interfaces::DataSystem::Payload.new( + id: "id", + target: 1, + code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_NONE, + reason: "up-to-date" + ) + ) + "event: server-intent\ndata: #{JSON.generate(intent.to_h)}\n\n" + end + + # Serves one server-intent event with the given response headers. Returns the first update. + def first_update_with_headers(headers) + with_server do |server| + server.setup_ok_response(FDV2_STREAMING_ENDPOINT, transfer_none_event, "text/event-stream", headers) + synchronizer = LaunchDarkly::DataSystem::StreamingDataSourceBuilder.new + .base_uri(server.base_uri.to_s) + .build(sdk_key, config) + updates = Queue.new + thread = Thread.new { synchronizer.sync(selector_store) { |update| updates << update } } + begin + updates.pop(timeout: 5) + ensure + synchronizer.stop + thread.join(5) + end end + end + + it "extracts environment ID from connection headers" do + update = first_update_with_headers({ "X-LD-EnvID" => "env-from-connect" }) + + expect(update.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + expect(update.environment_id).to eq("env-from-connect") + end - expect(envid).to eq("env-from-connect") + it "has no environment ID when the header is missing" do + update = first_update_with_headers({}) + + expect(update.environment_id).to be_nil end it "detects fallback header on connection" do - headers = double("HTTP::Headers") - allow(headers).to receive(:[]).with("x-launchdarkly-env-id").and_return("env-123") - allow(headers).to receive(:[]).with("x-launchdarkly-fd-fallback").and_return("true") - - # Verify the logic that would trigger fallback on connect - should_fallback = false - if headers && headers["x-launchdarkly-fd-fallback"] == 'true' - should_fallback = true - end + update = first_update_with_headers({ "X-LD-EnvID" => "env-123", "X-LD-FD-Fallback" => "true" }) - expect(should_fallback).to be true + expect(update.fallback_to_fdv1).to be true + expect(update.environment_id).to eq("env-123") end it "does not trigger fallback when header is missing on connection" do - headers = double("HTTP::Headers") - allow(headers).to receive(:[]).with("x-launchdarkly-env-id").and_return("env-456") - allow(headers).to receive(:[]).with("x-launchdarkly-fd-fallback").and_return(nil) - - should_fallback = false - if headers && headers["x-launchdarkly-fd-fallback"] == 'true' - should_fallback = true - end + update = first_update_with_headers({ "X-LD-EnvID" => "env-456" }) - expect(should_fallback).to be false + expect(update.fallback_to_fdv1).to be false end end end diff --git a/spec/impl/data_system/streaming_synchronizer_spec.rb b/spec/impl/data_system/streaming_synchronizer_spec.rb index 261b7368..3e69b0dd 100644 --- a/spec/impl/data_system/streaming_synchronizer_spec.rb +++ b/spec/impl/data_system/streaming_synchronizer_spec.rb @@ -483,6 +483,141 @@ def initialize(type, data = nil) end end + describe '#sync' do + let(:config) { LaunchDarkly::Config.new(logger: $null_log) } + let(:selector_store) { Struct.new(:selector).new(nil) } + let(:transfer_none_event) do + intent = LaunchDarkly::Interfaces::DataSystem::ServerIntent.new( + payload: LaunchDarkly::Interfaces::DataSystem::Payload.new( + id: "id", + target: 1, + code: LaunchDarkly::Interfaces::DataSystem::IntentCode::TRANSFER_NONE, + reason: "up-to-date" + ) + ) + "event: server-intent\ndata: #{JSON.generate(intent.to_h)}\n\n" + end + + def build_synchronizer(base_uri) + LaunchDarkly::DataSystem::StreamingDataSourceBuilder.new + .base_uri(base_uri.to_s) + .initial_reconnect_delay(0.01) + .build(sdk_key, config) + end + + # Runs sync in a thread. Yields a queue that receives each update, and the sync thread. + def with_sync(synchronizer) + updates = Queue.new + thread = Thread.new { synchronizer.sync(selector_store) { |update| updates << update } } + begin + yield updates, thread + ensure + synchronizer.stop + thread.join(5) + end + end + + # Replaces the SSE client with a double. Yields the handlers that sync registers on it. + def with_handlers(synchronizer) + handlers = {} + registered = Queue.new + connection = double("connection") + %i[on_connect on_event on_error query_params].each do |name| + allow(connection).to receive(name) { |&block| handlers[name] = block } + end + allow(SSE::Client).to receive(:new) do |_uri, **_opts, &block| + block.call(connection) + registered << true + double("SSE::Client", close: nil) + end + + with_sync(synchronizer) do |updates, thread| + expect(registered.pop(timeout: 5)).to be true + yield handlers, updates, thread + end + end + + it "reports invalid data as INTERRUPTED and restarts the stream" do + request_count = 0 + with_server do |server| + server.setup_response(FDV2_STREAMING_ENDPOINT) do |_req, res| + request_count += 1 + res.status = 200 + res.content_type = "text/event-stream" + res.body = request_count == 1 ? "event: server-intent\ndata: {not json\n\n" : transfer_none_event + end + + with_sync(build_synchronizer(server.base_uri)) do |updates| + first = updates.pop(timeout: 5) + expect(first.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + expect(first.error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA) + + valid = nil + while (update = updates.pop(timeout: 5)) + if update.state == LaunchDarkly::Interfaces::DataSource::Status::VALID + valid = update + break + end + end + expect(valid).not_to be_nil + expect(request_count).to be >= 2 + end + end + end + + it "raises the parse error to the SSE client so that it restarts the stream" do + with_handlers(build_synchronizer("http://stream.example.com")) do |handlers, updates| + event = MockSSEEvent.new(LaunchDarkly::Interfaces::DataSystem::EventName::SERVER_INTENT, "{not json") + + expect { handlers[:on_event].call(event) }.to raise_error(JSON::ParserError) + expect(updates.pop(timeout: 1).error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA) + end + end + + [401, 403].each do |status| + it "reports OFF and stops after unrecoverable error #{status}" do + with_server do |server| + server.setup_status_response(FDV2_STREAMING_ENDPOINT, status) + + with_sync(build_synchronizer(server.base_uri)) do |updates, thread| + update = updates.pop(timeout: 5) + expect(update.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) + expect(update.error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE) + expect(update.error.status_code).to eq(status) + + expect(thread.join(5)).not_to be_nil + expect(updates).to be_empty + expect(server.requests.length).to eq(1) + end + end + end + end + + it "reports INTERRUPTED and keeps the stream open after recoverable error 503" do + with_handlers(build_synchronizer("http://stream.example.com")) do |handlers, updates, thread| + handlers[:on_error].call(SSE::Errors::HTTPStatusError.new(503, "")) + + update = updates.pop(timeout: 1) + expect(update.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) + expect(update.error.status_code).to eq(503) + expect(thread.join(0.1)).to be_nil + end + end + + it "yields nothing after it is stopped" do + synchronizer = build_synchronizer("http://stream.example.com") + with_handlers(synchronizer) do |handlers, updates, thread| + synchronizer.stop + expect(thread.join(5)).not_to be_nil + + handlers[:on_error].call(SSE::Errors::HTTPStatusError.new(503, "")) + handlers[:on_error].call(SSE::Errors::StreamClosedByServerError.new) + + expect(updates).to be_empty + end + end + end + describe 'SSE client options' do let(:config) { LaunchDarkly::Config.new(logger: $null_log) } diff --git a/spec/impl/retry_state_spec.rb b/spec/impl/retry_state_spec.rb deleted file mode 100644 index bca6c189..00000000 --- a/spec/impl/retry_state_spec.rb +++ /dev/null @@ -1,224 +0,0 @@ -require "ldclient-rb/impl/retry_state" - -require "spec_helper" - -module LaunchDarkly - module Impl - describe RetryState do - # A random source with no jitter, so a delay is the computed value exactly. - let(:no_jitter) { double("random", rand: 0.0) } - let(:logger) { double.as_null_object } - - before { @now = 0.0 } - let(:clock) { -> { @now } } - - def streaming(delay = 1, random: no_jitter) - RetryState.for_streaming(delay, logger, clock: clock, random: random) - end - - def polling(interval = 30, random: no_jitter) - RetryState.for_polling(interval, logger, random: random) - end - - def delays_after(state, *kinds) - kinds.map do |kind| - state.record_failure(kind) - state.next_delay - end - end - - describe "streaming" do - it "waits nothing before any failure" do - expect(streaming.next_delay).to eq(0) - end - - it "doubles the normal delay up to 30 seconds" do - expect(delays_after(streaming, *[:normal] * 7)).to eq([1, 2, 4, 8, 16, 30, 30]) - end - - it "moves to the extended regime after an unexpected failure" do - expect(delays_after(streaming, :unexpected, :unexpected, :unexpected, :unexpected, :unexpected, :unexpected)) - .to eq([300, 600, 1200, 2400, 3600, 3600]) - end - - it "starts the extended sequence over on the move, and keeps counting after it" do - state = streaming - expect(delays_after(state, :normal, :normal, :normal)).to eq([1, 2, 4]) - expect(delays_after(state, :unexpected, :unexpected)).to eq([300, 600]) - end - - it "keeps the extended bounds for a normal failure that follows" do - state = streaming - state.record_failure(:unexpected) - expect(delays_after(state, :normal, :normal, :normal)).to eq([600, 1200, 2400]) - end - - it "uses a configured delay above the ceilings as the bounds" do - expect(delays_after(streaming(600), :normal, :normal)).to eq([600, 600]) - expect(delays_after(streaming(7200), :unexpected, :unexpected)).to eq([7200, 7200]) - end - - it "does not reset before 60 seconds of health" do - state = streaming - state.record_failure(:unexpected) - state.record_success - @now += 59 - state.record_failure(:normal) - expect(state.next_delay).to eq(600) - end - - it "does not reset after successes alone, because its reset is time-based" do - state = streaming - state.record_failure(:unexpected) - state.record_success - state.record_success - state.record_failure(:normal) - expect(state.next_delay).to eq(600) - end - - it "resets after 60 seconds of health, noticed at the next failure" do - state = streaming - state.record_failure(:unexpected) - state.record_success - @now += 60 - state.record_failure(:normal) - expect(state.next_delay).to eq(1) - end - - it "measures health from the first success, not the last" do - state = streaming - state.record_failure(:normal) - state.record_failure(:normal) - state.record_success - @now += 30 - state.record_success - @now += 30 - state.record_failure(:normal) - expect(state.next_delay).to eq(1) - end - - it "starts a new healthy stretch after a failure" do - state = streaming - state.record_failure(:normal) - state.record_success - @now += 50 - state.record_failure(:normal) - state.record_success - @now += 50 - state.record_failure(:normal) - expect(state.next_delay).to eq(4) - end - - it "waits nothing after a success" do - state = streaming - state.record_failure(:unexpected) - state.record_success - expect(state.next_delay).to eq(0) - end - - it "survives a very long outage" do - state = streaming(1.0) - 2000.times { state.record_failure(:normal) } - expect(state.next_delay).to eq(30) - 2000.times { state.record_failure(:unexpected) } - expect(state.next_delay).to eq(3600) - end - end - - describe "polling" do - it "waits the poll interval before any outcome" do - expect(polling.next_delay).to eq(30) - end - - it "waits the poll interval after a normal failure" do - expect(delays_after(polling, :normal, :normal, :normal)).to eq([30, 30, 30]) - end - - it "backs off in the extended regime after an unexpected failure" do - expect(delays_after(polling, :unexpected, :unexpected, :unexpected, :unexpected, :unexpected, :unexpected)) - .to eq([300, 600, 1200, 2400, 3600, 3600]) - end - - it "restores the poll interval after one success" do - state = polling - state.record_failure(:unexpected) - state.record_success - expect(state.next_delay).to eq(30) - end - - it "keeps the extended bounds after one success" do - state = polling - state.record_failure(:unexpected) - state.record_success - state.record_failure(:normal) - expect(state.next_delay).to eq(600) - end - - it "resets after two successes in a row" do - state = polling - state.record_failure(:unexpected) - state.record_success - state.record_success - state.record_failure(:normal) - expect(state.next_delay).to eq(30) - end - - it "does not count successes across a failure" do - state = polling - state.record_failure(:unexpected) - state.record_success - state.record_failure(:normal) - state.record_success - state.record_failure(:normal) - expect(state.next_delay).to eq(1200) - end - - it "never waits less than the poll interval, even with the most jitter" do - state = polling(3000, random: double("random", rand: 0.999)) - expect(delays_after(state, :normal, :unexpected, :unexpected)).to all(eq(3000)) - end - - it "uses a poll interval above the extended ceiling as every bound" do - expect(delays_after(polling(7200), :unexpected, :unexpected, :normal)).to eq([7200, 7200, 7200]) - end - end - - describe "jitter" do - it "subtracts up to half of the delay" do - state = streaming(random: double("random", rand: 0.5)) - expect(delays_after(state, :normal, :normal, :unexpected)).to eq([0.75, 1.5, 225.0]) - end - - it "stays within bounds for a real random source" do - state = streaming(random: Random.new(1234)) - 10.times { state.record_failure(:normal) } - 100.times do - state.record_failure(:normal) - expect(state.next_delay).to be_between(15, 30).inclusive - end - end - end - - describe "invalid input" do - [0, -1, Float::NAN, Float::INFINITY, -Float::INFINITY, nil, "5"].each do |value| - it "uses the default reconnect delay for #{value.inspect} and warns" do - expect(logger).to receive(:warn).once - expect(delays_after(streaming(value), :normal)).to eq([Config.default_initial_reconnect_delay]) - end - - it "uses the default poll interval for #{value.inspect} and warns" do - expect(logger).to receive(:warn).once - state = polling(value) - expect(state.next_delay).to eq(Config.default_poll_interval) - end - end - - it "does not warn for a valid value" do - expect(logger).not_to receive(:warn) - streaming(0.5) - polling(60) - end - end - end - end -end diff --git a/spec/impl/store_client_wrapper_spec.rb b/spec/impl/store_client_wrapper_spec.rb deleted file mode 100644 index 3f5d2ace..00000000 --- a/spec/impl/store_client_wrapper_spec.rb +++ /dev/null @@ -1,138 +0,0 @@ -require "ldclient-rb/impl/store_client_wrapper" -require "spec_helper" - -module LaunchDarkly - module Impl - describe FeatureStoreClientWrapper do - describe "store listener" do - it "will not notify sink if wrapped store does not support monitoring" do - store = double - sink = double - - allow(store).to receive(:stop) - allow(store).to receive(:monitoring_enabled?).and_return(false) - allow(store).to receive(:init).and_raise(StandardError.new('init error')) - - ensure_stop(FeatureStoreClientWrapper.new(store, sink, $null_log)) do |wrapper| - begin - wrapper.init({}) - raise "init should have raised exception" - rescue StandardError - # Ignored - end - - expect(sink).not_to receive(:update_status) - end - end - - it "sink will be notified when store is back online" do - event = Concurrent::Event.new - statuses = [] - listener = CallbackListener.new(->(status) { - statuses << status - event.set if status.available? - }) - - broadcaster = Broadcaster.new(SynchronousExecutor.new, $null_log) - broadcaster.add_listener(listener) - sink = DataStore::UpdateSink.new(broadcaster) - store = double - - allow(store).to receive(:stop) - allow(store).to receive(:monitoring_enabled?).and_return(true) - allow(store).to receive(:available?).and_return(false, true) - allow(store).to receive(:init).and_raise(StandardError.new('init error')) - - ensure_stop(FeatureStoreClientWrapper.new(store, sink, $null_log)) do |wrapper| - begin - wrapper.init({}) - raise "init should have raised exception" - rescue StandardError - # Ignored - end - - event.wait(2) - - expect(statuses.count).to eq(2) - expect(statuses[0].available).to be false - expect(statuses[1].available).to be true - end - end - - it "can stop while the availability poller is running" do - sink = double - store = double - checking = Concurrent::Event.new - - allow(store).to receive(:stop) - allow(store).to receive(:monitoring_enabled?).and_return(true) - allow(store).to receive(:all).and_raise(StandardError.new('read error')) - allow(sink).to receive(:update_status) - # Hold the poller's thread inside its availability check, so that stop has to wait - # for a thread that still needs the lock stop holds. - allow(store).to receive(:available?) do - checking.set - sleep 0.25 - true - end - - wrapper = FeatureStoreClientWrapper.new(store, sink, $null_log) - - begin - wrapper.all(:features) - raise "all should have raised exception" - rescue StandardError - # Ignored. The failed read starts the availability poller. - end - - expect(checking.wait(2)).to be true - - stopped = Concurrent::Event.new - Thread.new do - wrapper.stop - stopped.set - end - - expect(stopped.wait(5)).to be true - end - - it "does not start the availability poller after stop" do - sink = double - store = double - checks = Concurrent::AtomicFixnum.new(0) - - allow(store).to receive(:stop) - allow(store).to receive(:monitoring_enabled?).and_return(true) - allow(store).to receive(:all).and_raise(StandardError.new('read error')) - allow(sink).to receive(:update_status) - allow(store).to receive(:available?) { checks.increment; true } - - wrapper = FeatureStoreClientWrapper.new(store, sink, $null_log) - wrapper.stop - - begin - wrapper.all(:features) - raise "all should have raised exception" - rescue StandardError - # Ignored. On a running wrapper this would start the poller. - end - - # The poller is the only caller of available?, so it never ran. - sleep 1 - expect(checks.value).to eq 0 - end - end - - describe "required store methods" do - [true, false].each do |available| - it "gets available? #{available} from the store" do - store = double(monitoring_enabled?: true, available?: available, stop: nil) - ensure_stop(FeatureStoreClientWrapper.new(store, double, $null_log)) do |wrapper| - expect(wrapper.available?).to be available - end - end - end - end - end - end -end diff --git a/spec/impl/util_spec.rb b/spec/impl/util_spec.rb index d87d49fe..a78f796f 100644 --- a/spec/impl/util_spec.rb +++ b/spec/impl/util_spec.rb @@ -33,53 +33,6 @@ module Impl end end end - - describe "payload filter key validation" do - let(:logger) { double } - - it "silently discards nil" do - expect(logger).not_to receive(:warn) - expect(Util.validate_payload_filter_key(nil, logger)).to be_nil - end - - [true, 1, 1.0, [], {}].each do |value| - it "returns nil for invalid type #{value.class}" do - expect(logger).to receive(:warn) - expect(Util.validate_payload_filter_key(value, logger)).to be_nil - end - end - - [ - "", - "-cannot-start-with-dash", - "_cannot-start-with-underscore", - "-cannot-start-with-period", - "no spaces for you", - "org@special/characters", - ].each do |value| - it "returns nil for invalid value #{value}" do - expect(logger).to receive(:warn) - expect(Util.validate_payload_filter_key(value, logger)).to be_nil - end - end - - [ - "camelCase", - "snake_case", - "kebab-case", - "with.dots", - "with_underscores", - "with-hyphens", - "with1234numbers", - "with.many_1234-mixtures", - "1start-with-number", - ].each do |value| - it "passes for value #{value}" do - expect(logger).not_to receive(:warn) - expect(Util.validate_payload_filter_key(value, logger)).to eq(value) - end - end - end end end end diff --git a/spec/in_memory_feature_store_spec.rb b/spec/in_memory_feature_store_spec.rb deleted file mode 100644 index d3c663b6..00000000 --- a/spec/in_memory_feature_store_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -require "feature_store_spec_base" -require "spec_helper" - -module LaunchDarkly - class InMemoryStoreTester - def create_feature_store - InMemoryFeatureStore.new - end - end - - describe InMemoryFeatureStore do - subject { InMemoryFeatureStore } - - include_examples "any_feature_store", InMemoryStoreTester.new - - it "does not provide status monitoring support" do - store = subject.new - - expect(store.monitoring_enabled?).to be false - end - - it "is always available" do - expect(subject.new.available?).to be true - end - end -end diff --git a/spec/integrations/file_data_source_spec.rb b/spec/integrations/file_data_source_spec.rb deleted file mode 100644 index d15f122f..00000000 --- a/spec/integrations/file_data_source_spec.rb +++ /dev/null @@ -1,352 +0,0 @@ -require "spec_helper" -require "tempfile" - -# see does not allow Ruby objects in YAML" for the purpose of the following two things -$created_bad_class = false -class BadClassWeShouldNotInstantiate < Hash - def []=(key, value) - $created_bad_class = true - end -end - -module LaunchDarkly - module Integrations - describe FileData do - let(:full_flag_1_key) { "flag1" } - let(:full_flag_1_value) { "on" } - let(:flag_value_1_key) { "flag2" } - let(:flag_value_1) { "value2" } - let(:all_flag_keys) { [ full_flag_1_key.to_sym, flag_value_1_key.to_sym ] } - let(:full_segment_1_key) { "seg1" } - let(:all_segment_keys) { [ full_segment_1_key.to_sym ] } - - let(:invalid_json) { "My invalid JSON" } - let(:flag_only_json) { <<-EOF -{ - "flags": { - "flag1": { - "key": "flag1", - "on": true, - "fallthrough": { - "variation": 2 - }, - "variations": [ "fall", "off", "on" ] - } - } -} -EOF - } - - let(:segment_only_json) { <<-EOF -{ - "segments": { - "seg1": { - "key": "seg1", - "include": ["user1"] - } - } -} -EOF - } - - let(:all_properties_json) { <<-EOF -{ - "flags": { - "flag1": { - "key": "flag1", - "on": true, - "fallthrough": { - "variation": 2 - }, - "variations": [ "fall", "off", "on" ] - } - }, - "flagValues": { - "flag2": "value2" - }, - "segments": { - "seg1": { - "key": "seg1", - "include": ["user1"] - } - } -} -EOF - } - - let(:all_properties_yaml) { <<-EOF ---- -flags: - flag1: - key: flag1 - "on": true -flagValues: - flag2: value2 -segments: - seg1: - key: seg1 - include: ["user1"] -EOF - } - - let(:unsafe_yaml) { <<-EOF ---- !ruby/hash:BadClassWeShouldNotInstantiate -foo: bar -EOF - } - - let(:bad_file_path) { "no-such-file" } - - before do - @config = LaunchDarkly::Config.new(logger: $null_log) - @store = @config.feature_store - - @executor = SynchronousExecutor.new - @status_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@executor, $null_log) - @flag_change_broadcaster = LaunchDarkly::Impl::Broadcaster.new(@executor, $null_log) - @config.data_source_update_sink = LaunchDarkly::Impl::DataSource::UpdateSink.new(@store, @status_broadcaster, @flag_change_broadcaster) - - @tmp_dir = Dir.mktmpdir - end - - after do - FileUtils.rm_rf(@tmp_dir) - end - - def make_temp_file(content) - # Note that we don't create our files in the default temp file directory, but rather in an empty directory - # that we made. That's because (depending on the platform) the temp file directory may contain huge numbers - # of files, which can make the file watcher perform poorly enough to break the tests. - file = Tempfile.new('flags', @tmp_dir) - IO.write(file, content) - file - end - - def with_data_source(options, initialize_to_valid = false) - factory = FileData.data_source(options) - - if initialize_to_valid - # If the update sink receives an interrupted signal when the state is - # still initializing, it will continue staying in the initializing phase. - # Therefore, we set the state to valid before this test so we can - # determine if the interrupted signal is actually generated. - @config.data_source_update_sink.update_status(LaunchDarkly::Interfaces::DataSource::Status::VALID, nil) - end - - ds = factory.call('', @config) - - begin - yield ds - ensure - ds.stop - end - end - - it "doesn't load flags prior to start" do - file = make_temp_file('{"flagValues":{"key":"value"}}') - with_data_source({ paths: [ file.path ] }) do |_| - expect(@store.initialized?).to eq(false) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::FEATURES)).to eq({}) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::SEGMENTS)).to eq({}) - end - end - - it "loads flags on start - from JSON" do - file = make_temp_file(all_properties_json) - with_data_source({ paths: [ file.path ] }) do |ds| - listener = ListenerSpy.new - @status_broadcaster.add_listener(listener) - - ds.start - expect(@store.initialized?).to eq(true) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::FEATURES).keys).to eq(all_flag_keys) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::SEGMENTS).keys).to eq(all_segment_keys) - - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) - end - end - - it "loads flags on start - from YAML" do - file = make_temp_file(all_properties_yaml) - with_data_source({ paths: [ file.path ] }) do |ds| - ds.start - expect(@store.initialized?).to eq(true) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::FEATURES).keys).to eq(all_flag_keys) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::SEGMENTS).keys).to eq(all_segment_keys) - end - end - - it "does not allow Ruby objects in YAML" do - # This tests for the vulnerability described here: https://trailofbits.github.io/rubysec/yaml/index.html - # The file we're loading contains a hash with a custom Ruby class, BadClassWeShouldNotInstantiate (see top - # of file). If we're not loading in safe mode, it will create an instance of that class and call its []= - # method, which we've defined to set $created_bad_class to true. In safe mode, it refuses to parse this file. - file = make_temp_file(unsafe_yaml) - with_data_source({ paths: [file.path ] }) do |ds| - event = ds.start - expect(event.set?).to eq(true) - expect(ds.initialized?).to eq(false) - expect($created_bad_class).to eq(false) - end - end - - it "does not execute commands via malicious filenames" do - # This tests that filenames containing shell metacharacters are treated as literal paths - # and do not result in command execution. The file reading should use safe methods - # that don't pass paths through a shell. - marker_file = File.join(@tmp_dir, "command_injection_marker") - - # Various command injection attempts - if any command executed, it would create the marker file - malicious_paths = [ - "|touch #{marker_file}", - ";touch #{marker_file}", - "$(touch #{marker_file})", - "`touch #{marker_file}`", - "& touch #{marker_file}", - "\ntouch #{marker_file}\n", - ] - - malicious_paths.each do |malicious_path| - with_data_source({ paths: [malicious_path] }) do |ds| - event = ds.start - expect(event.set?).to eq(true) - # Should fail to initialize because the file doesn't exist (treated as literal path) - expect(ds.initialized?).to eq(false) - # Most importantly: no command should have been executed - expect(File.exist?(marker_file)).to eq(false), "Command injection detected with path: #{malicious_path}" - end - end - end - - it "sets start event and initialized on successful load" do - file = make_temp_file(all_properties_json) - with_data_source({ paths: [ file.path ] }) do |ds| - event = ds.start - expect(event.set?).to eq(true) - expect(ds.initialized?).to eq(true) - end - end - - it "sets start event and does not set initialized on unsuccessful load" do - with_data_source({ paths: [ bad_file_path ] }) do |ds| - event = ds.start - expect(event.set?).to eq(true) - expect(ds.initialized?).to eq(false) - end - end - - it "can load multiple files" do - file1 = make_temp_file(flag_only_json) - file2 = make_temp_file(segment_only_json) - with_data_source({ paths: [ file1.path, file2.path ] }) do |ds| - ds.start - expect(@store.initialized?).to eq(true) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::FEATURES).keys).to eq([ full_flag_1_key.to_sym ]) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::SEGMENTS).keys).to eq([ full_segment_1_key.to_sym ]) - end - end - - it "file loading failure results in interrupted status" do - file1 = make_temp_file(flag_only_json) - file2 = make_temp_file(invalid_json) - with_data_source({ paths: [ file1.path, file2.path ] }, true) do |ds| - listener = ListenerSpy.new - @status_broadcaster.add_listener(listener) - - ds.start - expect(@store.initialized?).to eq(false) - expect(listener.statuses.count).to eq(1) - expect(listener.statuses[0].state).to eq(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED) - end - end - - it "does not allow duplicate keys" do - file1 = make_temp_file(flag_only_json) - file2 = make_temp_file(flag_only_json) - with_data_source({ paths: [ file1.path, file2.path ] }) do |ds| - ds.start - expect(@store.initialized?).to eq(false) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::FEATURES).keys).to eq([]) - end - end - - it "does not reload modified file if auto-update is off" do - file = make_temp_file(flag_only_json) - - with_data_source({ paths: [ file.path ] }) do |ds| - event = ds.start - expect(event.set?).to eq(true) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::SEGMENTS).keys).to eq([]) - - IO.write(file, all_properties_json) - sleep(0.5) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::SEGMENTS).keys).to eq([]) - end - end - - def test_auto_reload(options) - file = make_temp_file(flag_only_json) - options[:paths] = [ file.path ] - - with_data_source(options) do |ds| - event = ds.start - expect(event.set?).to eq(true) - expect(@store.all(LaunchDarkly::Interfaces::DataStore::SEGMENTS).keys).to eq([]) - - sleep(1) - IO.write(file, all_properties_json) - - max_time = 10 - ok = wait_for_condition(10) { @store.all(LaunchDarkly::Interfaces::DataStore::SEGMENTS).keys == all_segment_keys } - expect(ok).to eq(true), "Waited #{max_time}s after modifying file and it did not reload" - end - end - - it "reloads modified file if auto-update is on" do - test_auto_reload({ auto_update: true }) - end - - it "reloads modified file in polling mode" do - test_auto_reload({ auto_update: true, force_polling: true, poll_interval: 0.1 }) - end - - it "evaluates simplified flag with client as expected" do - file = make_temp_file(all_properties_json) - factory = FileData.data_source({ paths: file.path }) - config = LaunchDarkly::Config.new(send_events: false, data_source: factory) - client = LaunchDarkly::LDClient.new('sdkKey', config) - - begin - value = client.variation(flag_value_1_key, { key: 'user', kind: 'user' }, '') - expect(value).to eq(flag_value_1) - ensure - client.close - end - end - - it "evaluates full flag with client as expected" do - file = make_temp_file(all_properties_json) - factory = FileData.data_source({ paths: file.path }) - config = LaunchDarkly::Config.new(send_events: false, data_source: factory) - client = LaunchDarkly::LDClient.new('sdkKey', config) - - begin - value = client.variation(full_flag_1_key, { key: 'user', kind: 'user' }, '') - expect(value).to eq(full_flag_1_value) - ensure - client.close - end - end - - def wait_for_condition(max_time) - deadline = Time.now + max_time - while Time.now < deadline - return true if yield - sleep(0.1) - end - false - end - end - end -end diff --git a/spec/integrations/file_data_source_v2_spec.rb b/spec/integrations/file_data_source_v2_spec.rb index 670e9adf..5c4c3733 100644 --- a/spec/integrations/file_data_source_v2_spec.rb +++ b/spec/integrations/file_data_source_v2_spec.rb @@ -5,6 +5,15 @@ require "ldclient-rb/impl/integrations/file_data_source_v2" require "ldclient-rb/integrations/file_data" require "ldclient-rb/interfaces/data_system" +require "mock_components" + +# The "does not allow Ruby objects in YAML" example uses this class and flag. +$created_bad_class_v2 = false +class BadClassWeShouldNotInstantiateV2 < Hash + def []=(key, value) + $created_bad_class_v2 = true + end +end module LaunchDarkly module Integrations @@ -515,6 +524,204 @@ def no_selector_store end end end + + describe "security" do + let(:unsafe_yaml) { <<-EOF +--- !ruby/hash:BadClassWeShouldNotInstantiateV2 +foo: bar +EOF + } + + it "does not allow Ruby objects in YAML" do + # The file holds a hash of a custom Ruby class. An unsafe YAML load creates the object and calls its + # []= method, which sets $created_bad_class_v2. A safe load refuses the file. + $created_bad_class_v2 = false + file = make_temp_file(unsafe_yaml) + + source = Impl::Integrations::FileDataSourceV2.new(logger, paths: [file.path]) + + begin + result = source.fetch(no_selector_store) + + expect(result.success?).to eq(false) + expect(result.error).to include("Unable to load flag data") + expect($created_bad_class_v2).to eq(false) + ensure + source.stop + end + end + + it "does not execute commands via malicious filenames" do + # The source must treat each path as a literal file name. If a shell runs a command, the marker file exists. + marker_file = File.join(@tmp_dir, "command_injection_marker") + + malicious_paths = [ + "|touch #{marker_file}", + ";touch #{marker_file}", + "$(touch #{marker_file})", + "`touch #{marker_file}`", + "& touch #{marker_file}", + "\ntouch #{marker_file}\n", + ] + + malicious_paths.each do |malicious_path| + source = Impl::Integrations::FileDataSourceV2.new(logger, paths: [malicious_path]) + + begin + result = source.fetch(no_selector_store) + + expect(result.success?).to eq(false) + expect(File.exist?(marker_file)).to eq(false), "Command injection detected with path: #{malicious_path}" + ensure + source.stop + end + end + end + end + + describe "reload without the listen gem" do + around do |example| + klass = Impl::Integrations::FileDataSourceV2 + saved = klass.class_variable_get(:@@have_listen) + klass.class_variable_set(:@@have_listen, false) + begin + example.run + ensure + klass.class_variable_set(:@@have_listen, saved) + end + end + + it "detects file changes with the polling watcher" do + expect(Impl::Integrations::FileDataSourcePollerV2).to receive(:new).and_call_original + file = make_temp_file(flag_only_json) + + source = Impl::Integrations::FileDataSourceV2.new(logger, paths: [file.path], poll_interval: 0.1) + updates = Queue.new + + begin + sync_thread = Thread.new do + source.sync(no_selector_store) { |update| updates << update } + end + + initial = updates.pop(timeout: 5) + expect(initial).not_to be_nil, "Did not receive initial update" + expect(initial.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + + # The poller compares modification times. Set a new time so that the change is visible. + IO.write(file, segment_only_json) + File.utime(Time.now + 5, Time.now + 5, file.path) + + changed = updates.pop(timeout: 5) + expect(changed).not_to be_nil, "Did not receive update after file change" + expect(changed.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + kinds = changed.change_set.changes.map(&:kind) + expect(kinds).to eq([LaunchDarkly::Interfaces::DataSystem::ObjectKind::SEGMENT]) + ensure + source.stop + sync_thread&.join(2) + end + end + end + + describe "initial failure in sync" do + def first_sync_update(source) + updates = Queue.new + sync_thread = Thread.new do + source.sync(no_selector_store) { |update| updates << update } + end + update = updates.pop(timeout: 5) + expect(sync_thread.join(2)).not_to be_nil, "sync did not return after the initial failure" + expect(updates.empty?).to eq(true) + update + end + + it "reports OFF for a missing file" do + source = Impl::Integrations::FileDataSourceV2.new(logger, paths: ['no-such-file.json']) + + begin + update = first_sync_update(source) + + expect(update.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) + expect(update.change_set).to be_nil + expect(update.error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA) + expect(update.error.message).to include("no-such-file.json") + ensure + source.stop + end + end + + it "reports OFF for a malformed file" do + file = make_temp_file('{"flagValues":{') + source = Impl::Integrations::FileDataSourceV2.new(logger, paths: [file.path]) + + begin + update = first_sync_update(source) + + expect(update.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) + expect(update.change_set).to be_nil + expect(update.error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA) + expect(update.error.message).to include("Unable to load flag data") + ensure + source.stop + end + end + end + + describe "flagValues" do + it "produces a full flag" do + file = make_temp_file(flag_values_only_json) + + source = Impl::Integrations::FileDataSourceV2.new(logger, paths: [file.path]) + + begin + result = source.fetch(no_selector_store) + + expect(result.success?).to eq(true) + change = result.value.change_set.changes[0] + expect(change.version).to eq(1) + expect(change.object).to eq({ + key: "flag2", + on: true, + version: 1, + fallthrough: { variation: 0 }, + variations: ["value2"], + }) + expect(change.object).not_to have_key(:offVariation) + ensure + source.stop + end + end + end + + describe "through LDClient" do + let(:user) { LaunchDarkly::LDContext.create({ key: "user", kind: "user" }) } + + it "evaluates flags from a synchronizer" do + file = make_temp_file(all_properties_json) + data_system = LaunchDarkly::DataSystem.custom + .synchronizers([FileData.data_source_v2(paths: [file.path])]) + .build + + with_client(test_config(data_system_config: data_system)) do |client| + expect(client.initialized?).to eq(true) + expect(client.variation("flag1", user, "default")).to eq("on") + expect(client.variation("flag2", user, "default")).to eq("value2") + end + end + + it "evaluates flags from an initializer" do + file = make_temp_file(all_properties_json) + data_system = LaunchDarkly::DataSystem.custom + .initializers([FileData.data_source_v2(paths: [file.path])]) + .build + + with_client(test_config(data_system_config: data_system)) do |client| + expect(client.initialized?).to eq(true) + expect(client.variation("flag1", user, "default")).to eq("on") + expect(client.variation("flag2", user, "default")).to eq("value2") + end + end + end end end end diff --git a/spec/integrations/test_data_spec.rb b/spec/integrations/test_data_spec.rb deleted file mode 100644 index d4d323a5..00000000 --- a/spec/integrations/test_data_spec.rb +++ /dev/null @@ -1,243 +0,0 @@ -require "ldclient-rb" - -module LaunchDarkly - module Integrations - describe 'TestData' do - it 'is a valid datasource' do - td = Integrations::TestData.data_source - config = Config.new(send_events: false, data_source: td) - client = LDClient.new('sdkKey', config) - expect(config.feature_store.all(LaunchDarkly::Interfaces::DataStore::FEATURES)).to eql({}) - client.close - end - - it 'initializes the feature store with existing flags' do - td = Integrations::TestData.data_source - td.update(td.flag('flag')) - config = Config.new(send_events: false, data_source: td) - client = LDClient.new('sdkKey', config) - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, 'flag').data).to eql({ - key: 'flag', - variations: [true, false], - fallthrough: { variation: 0 }, - offVariation: 1, - on: true, - version: 1, - }) - client.close - end - - it 'updates the feature store with new flags' do - td = Integrations::TestData.data_source - td.update(td.flag('flag')) - config = Config.new(send_events: false, data_source: td) - client = LDClient.new('sdkKey', config) - config2 = Config.new(send_events: false, data_source: td) - client2 = LDClient.new('sdkKey', config2) - - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, 'flag').data).to eql({ - key: 'flag', - variations: [true, false], - fallthrough: { variation: 0 }, - offVariation: 1, - on: true, - version: 1, - }) - expect(config2.feature_store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, 'flag').data).to eql({ - key: 'flag', - variations: [true, false], - fallthrough: { variation: 0 }, - offVariation: 1, - on: true, - version: 1, - }) - - td.update(td.flag('flag').variation_for_all(false)) - - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, 'flag').data).to eql({ - key: 'flag', - variations: [true, false], - fallthrough: { variation: 1 }, - offVariation: 1, - on: true, - version: 2, - }) - expect(config2.feature_store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, 'flag').data).to eql({ - key: 'flag', - variations: [true, false], - fallthrough: { variation: 1 }, - offVariation: 1, - on: true, - version: 2, - }) - - client.close - client2.close - end - - it 'can include preconfigured items' do - td = Integrations::TestData.data_source - td.use_preconfigured_flag({ key: 'my-flag', version: 1000, on: true }) - td.use_preconfigured_segment({ key: 'my-segment', version: 2000 }) - - config = Config.new(send_events: false, data_source: td) - client = LDClient.new('sdkKey', config) - - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, 'my-flag').data).to eql({ - key: 'my-flag', version: 1000, on: true - }) - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::SEGMENTS, 'my-segment').data).to eql({ - key: 'my-segment', version: 2000 - }) - - td.use_preconfigured_flag({ key: 'my-flag', on: false }) - - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::FEATURES, 'my-flag').data).to eql({ - key: 'my-flag', version: 1001, on: false - }) - - td.use_preconfigured_segment({ key: 'my-segment', included: [ 'x' ] }) - - expect(config.feature_store.get(LaunchDarkly::Interfaces::DataStore::SEGMENTS, 'my-segment').data).to eql({ - key: 'my-segment', version: 2001, included: [ 'x' ] - }) - - client.close - end - - it 'TestData.flag defaults to a boolean flag' do - td = TestData.new - f = td.flag('flag').build(0) - expect(f[:variations]).to eq([true, false]) - expect(f[:fallthrough][:variation]).to eq(0) - expect(f[:offVariation]).to eq(1) - end - - it 'TestData.flag returns a copy of the existing flag if it exists' do - td = TestData.new - td.update(td.flag('flag').variation_for_all(true)) - expect(td.flag('flag').build(0)[:fallthrough][:variation]).to eq(0) - - #modify the flag but dont call update - td.flag('flag').variation_for_all(false).build(0) - - expect(td.flag('flag').build(0)[:fallthrough][:variation]).to eq(0) - end - - describe 'FlagBuilder' do - - it 'defaults to targeting on and sets the flag key' do - f = TestData::FlagBuilder.new('flag').build(1) - expect(f[:key]).to eq('flag') - expect(f[:version]).to eq(1) - expect(f[:on]).to eq(true) - expect(f[:variations]).to be_empty - end - - it 'can set targeting off' do - f = TestData::FlagBuilder.new('flag').on(false).build(1) - expect(f[:on]).to eq(false) - end - - it 'can set fallthrough variation' do - f = TestData::FlagBuilder.new('flag').fallthrough_variation(0).build(1) - expect(f[:fallthrough][:variation]).to eq(0) - end - - it 'can set variation for when targeting is off' do - f = TestData::FlagBuilder.new('flag').off_variation(0).build(1) - expect(f[:offVariation]).to eq(0) - end - - it 'can set a list of variations' do - f = TestData::FlagBuilder.new('flag').variations(true, false).build(1) - expect(f[:variations]).to eq([true, false]) - end - - it 'has the boolean_flag shortcut method' do - f = TestData::FlagBuilder.new('flag').boolean_flag.build(1) - expect(f[:variations]).to eq([true, false]) - expect(f[:fallthrough][:variation]).to eq(0) - expect(f[:offVariation]).to eq(1) - end - - it 'can handle boolean or index variation' do - f = TestData::FlagBuilder.new('flag').off_variation(true).build(1) - expect(f[:variations]).to eq([true, false]) - expect(f[:offVariation]).to eq(0) - - f2 = TestData::FlagBuilder.new('flag').fallthrough_variation(true).build(1) - expect(f2[:variations]).to eq([true, false]) - expect(f2[:offVariation]).to eq(1) - end - - it 'can set variation for all users' do - f = TestData::FlagBuilder.new('flag').variation_for_all(true).build(1) - expect(f[:rules]).to be_nil - expect(f[:targets]).to be_nil - expect(f[:fallthrough][:variation]).to be(0) - end - - it 'clears existing rules when setting variation for all users' do - f = TestData::FlagBuilder.new('flag') - .if_match('name', 'ben') - .then_return(false) - .variation_for_user('ben', false) - .variation_for_all(true).build(1) - expect(f.keys).to_not include(:rules) - expect(f.keys).to_not include(:targets) - expect(f[:fallthrough][:variation]).to be(0) - end - - it 'can set a variation for a specific user' do - f = TestData::FlagBuilder.new('flag') - .variation_for_user('ben', false) - f2 = f.clone.variation_for_user('ben', true) - expect(f.build(0)[:targets]).to eql([ { variation: 1, values: ['ben'] } ]) - expect(f2.build(1)[:targets]).to_not include({ variation: 1, values: ['ben'] }) - expect(f2.build(1)[:targets]).to include({ variation: 0, values: ['ben'] }) - end - - it 'can make an immutable copy of its self' do - fb = TestData::FlagBuilder.new('flag').variation_for_all(true) - expect(fb.build(0)).to eql(fb.clone.build(0)) - - fcopy = fb.clone.variation_for_all(false).build(0) - f = fb.build(0) - - expect(f[:key]).to eql(fcopy[:key]) - expect(f[:variations]).to eql(fcopy[:variations]) - expect(f[:fallthrough][:variation]).to be(0) - expect(fcopy[:fallthrough][:variation]).to be(1) - end - - it 'can build rules based on attributes' do - f = TestData::FlagBuilder.new('flag') - .if_match('name', 'ben') - .and_not_match('country', 'fr') - .then_return(true) - .build(1) - expect(f[:rules]).to eql([{ - id: "rule0", - variation: 0, - clauses: [{ - contextKind: "user", - attribute: 'name', - op: 'in', - values: ['ben'], - negate: false, - }, - { - contextKind: "user", - attribute: 'country', - op: 'in', - values: ['fr'], - negate: true, - }, - ], - }]) - end - end - end - end -end diff --git a/spec/integrations/test_data_v2/flag_builder_v2_spec.rb b/spec/integrations/test_data_v2/flag_builder_v2_spec.rb new file mode 100644 index 00000000..4a897962 --- /dev/null +++ b/spec/integrations/test_data_v2/flag_builder_v2_spec.rb @@ -0,0 +1,373 @@ +require "ldclient-rb/integrations/test_data_v2" +require "spec_helper" + +module LaunchDarkly + module Integrations + describe 'TestDataV2' do + def boolean_flag_hash(key, version, overrides = {}) + { + key: key, + version: version, + on: true, + variations: [true, false], + prerequisites: [], + salt: '', + offVariation: 1, + fallthrough: { variation: 0 }, + }.merge(overrides) + end + + it 'flag defaults to a boolean flag' do + td = TestDataV2.data_source + expect(td.flag('flag').build(1)).to eql(boolean_flag_hash('flag', 1)) + end + + it 'flag returns a copy of the stored builder' do + td = TestDataV2.data_source + td.update(td.flag('flag').variation_for_all(true)) + expect(td.flag('flag').build(1)[:fallthrough]).to eql({ variation: 0 }) + + td.flag('flag').variation_for_all(false).if_match('name', 'ben').then_return(true) + + expect(td.flag('flag').build(1)).to eql(boolean_flag_hash('flag', 1)) + end + + it 'update gives a new flag version 1' do + td = TestDataV2.data_source + td.update(td.flag('flag')) + expect(td.make_init_data[:flags][:flag]).to eql(boolean_flag_hash('flag', 1)) + end + + it 'update increments the version of an existing flag' do + td = TestDataV2.data_source + td.update(td.flag('flag')) + td.update(td.flag('flag').variation_for_all(false)) + expect(td.make_init_data[:flags][:flag]).to eql(boolean_flag_hash('flag', 2, fallthrough: { variation: 1 })) + + td.update(td.flag('flag').on(false)) + expect(td.make_init_data[:flags][:flag]).to eql( + boolean_flag_hash('flag', 3, on: false, fallthrough: { variation: 1 }) + ) + end + + it 'update does not store later changes to the builder' do + td = TestDataV2.data_source + builder = td.flag('flag') + td.update(builder) + builder.variation_for_all(false) + + expect(td.make_init_data[:flags][:flag]).to eql(boolean_flag_hash('flag', 1)) + expect(td.flag('flag').build(1)).to eql(boolean_flag_hash('flag', 1)) + end + + describe 'FlagBuilderV2' do + it 'defaults to targeting on with the key and version' do + f = TestDataV2::FlagBuilderV2.new('flag').build(1) + expect(f).to eql({ + key: 'flag', + version: 1, + on: true, + variations: [], + prerequisites: [], + salt: '', + }) + end + + it 'can set targeting off' do + f = TestDataV2::FlagBuilderV2.new('flag').on(false).build(1) + expect(f[:on]).to eq(false) + end + + it 'can set targeting on' do + f = TestDataV2::FlagBuilderV2.new('flag').on(false).on(true).build(1) + expect(f[:on]).to eq(true) + end + + it 'can set the fallthrough variation by index' do + f = TestDataV2::FlagBuilderV2.new('flag').fallthrough_variation(2).build(1) + expect(f[:fallthrough]).to eql({ variation: 2 }) + expect(f[:variations]).to eql([]) + expect(f.keys).to_not include(:offVariation) + end + + it 'can set the off variation by index' do + f = TestDataV2::FlagBuilderV2.new('flag').off_variation(2).build(1) + expect(f[:offVariation]).to eq(2) + expect(f[:variations]).to eql([]) + expect(f.keys).to_not include(:fallthrough) + end + + it 'can set a list of variations' do + f = TestDataV2::FlagBuilderV2.new('flag').variations('red', 'green', 'blue').build(1) + expect(f[:variations]).to eql(%w[red green blue]) + end + + it 'has the boolean_flag shortcut method' do + f = TestDataV2::FlagBuilderV2.new('flag').boolean_flag.build(1) + expect(f).to eql(boolean_flag_hash('flag', 1)) + end + + it 'boolean_flag keeps the settings of a flag that is already boolean' do + f = TestDataV2::FlagBuilderV2.new('flag').boolean_flag.off_variation(0).fallthrough_variation(1) + .boolean_flag.build(1) + expect(f).to eql(boolean_flag_hash('flag', 1, offVariation: 0, fallthrough: { variation: 1 })) + end + + it 'off_variation with a boolean changes the flag to a boolean flag' do + f = TestDataV2::FlagBuilderV2.new('flag').variations('a', 'b', 'c').off_variation(true).build(1) + expect(f).to eql(boolean_flag_hash('flag', 1, offVariation: 0)) + + f2 = TestDataV2::FlagBuilderV2.new('flag').off_variation(false).build(1) + expect(f2).to eql(boolean_flag_hash('flag', 1)) + end + + it 'fallthrough_variation with a boolean changes the flag to a boolean flag' do + f = TestDataV2::FlagBuilderV2.new('flag').variations('a', 'b', 'c').fallthrough_variation(false).build(1) + expect(f).to eql(boolean_flag_hash('flag', 1, fallthrough: { variation: 1 })) + + f2 = TestDataV2::FlagBuilderV2.new('flag').fallthrough_variation(true).build(1) + expect(f2).to eql(boolean_flag_hash('flag', 1)) + end + + it 'can set the variation for all contexts with a boolean' do + f = TestDataV2::FlagBuilderV2.new('flag').on(false).variation_for_all(false).build(1) + expect(f).to eql(boolean_flag_hash('flag', 1, fallthrough: { variation: 1 })) + end + + it 'can set the variation for all contexts with an index' do + f = TestDataV2::FlagBuilderV2.new('flag').variations('a', 'b', 'c').off_variation(0).on(false) + .variation_for_all(2).build(1) + expect(f).to eql({ + key: 'flag', + version: 1, + on: true, + variations: %w[a b c], + prerequisites: [], + salt: '', + offVariation: 0, + fallthrough: { variation: 2 }, + }) + end + + it 'clears existing rules and targets when setting the variation for all contexts' do + f = TestDataV2::FlagBuilderV2.new('flag') + .if_match('name', 'ben') + .then_return(false) + .variation_for_user('ben', false) + .variation_for_key('org', 'acme', true) + .variation_for_all(true) + .build(1) + expect(f).to eql(boolean_flag_hash('flag', 1)) + end + + it 'can set a single value for all contexts' do + f = TestDataV2::FlagBuilderV2.new('flag') + .boolean_flag + .if_match('name', 'ben') + .then_return(false) + .variation_for_user('ben', false) + .on(false) + .value_for_all('x') + .build(1) + expect(f[:on]).to eq(true) + expect(f[:variations]).to eql(['x']) + expect(f[:fallthrough]).to eql({ variation: 0 }) + expect(f.keys).to_not include(:rules, :targets, :contextTargets) + end + + it 'can set a variation for a user key with a boolean' do + f = TestDataV2::FlagBuilderV2.new('flag').variation_for_user('ben', false).build(1) + expect(f[:variations]).to eql([true, false]) + expect(f[:targets]).to eql([{ variation: 1, values: ['ben'] }]) + expect(f[:contextTargets]).to eql([{ contextKind: 'user', variation: 1, values: [] }]) + end + + it 'can set a variation for a user key with an index' do + f = TestDataV2::FlagBuilderV2.new('flag').variations('a', 'b', 'c') + .variation_for_user('ben', 2) + .variation_for_user('amy', 2) + .build(1) + expect(f[:variations]).to eql(%w[a b c]) + expect(f[:targets]).to eql([{ variation: 2, values: %w[amy ben] }]) + expect(f[:contextTargets]).to eql([{ contextKind: 'user', variation: 2, values: [] }]) + end + + it 'can set a variation for a key of another context kind' do + f = TestDataV2::FlagBuilderV2.new('flag').variation_for_key('org', 'acme', true).build(1) + expect(f.keys).to_not include(:targets) + expect(f[:contextTargets]).to eql([{ contextKind: 'org', variation: 0, values: ['acme'] }]) + end + + it 'moves a key to the new variation when its variation changes' do + f = TestDataV2::FlagBuilderV2.new('flag') + .variation_for_user('ben', false) + .variation_for_user('ben', true) + .build(1) + expect(f[:targets]).to contain_exactly( + { variation: 1, values: [] }, + { variation: 0, values: ['ben'] } + ) + end + + it 'can clear targets' do + f = TestDataV2::FlagBuilderV2.new('flag') + .variation_for_user('ben', false) + .variation_for_key('org', 'acme', true) + .clear_targets + .build(1) + expect(f).to eql(boolean_flag_hash('flag', 1)) + end + + it 'clone gives an independent copy of the targets' do + f = TestDataV2::FlagBuilderV2.new('flag').variation_for_user('ben', false) + f2 = f.clone.variation_for_user('ben', true).variation_for_user('amy', false) + + expect(f.build(1)[:targets]).to eql([{ variation: 1, values: ['ben'] }]) + expect(f2.build(1)[:targets]).to contain_exactly( + { variation: 1, values: ['amy'] }, + { variation: 0, values: ['ben'] } + ) + end + + it 'dup gives an independent copy of the settings' do + fb = TestDataV2::FlagBuilderV2.new('flag').variation_for_all(true) + expect(fb.dup.build(1)).to eql(fb.build(1)) + + fcopy = fb.dup.variation_for_all(false).build(1) + f = fb.build(1) + expect(f[:fallthrough]).to eql({ variation: 0 }) + expect(fcopy[:fallthrough]).to eql({ variation: 1 }) + end + + it 'clone gives an independent copy of the variations' do + fb = TestDataV2::FlagBuilderV2.new('flag').variations('a', 'b') + fb.clone.build(1)[:variations] << 'c' + expect(fb.build(1)[:variations]).to eql(%w[a b]) + end + + it 'clone gives an independent copy of the rules' do + fb = TestDataV2::FlagBuilderV2.new('flag').if_match('name', 'ben').then_return(true) + fcopy = fb.clone.if_match('name', 'amy').then_return(false) + + expect(fb.build(1)[:rules].length).to eq(1) + expect(fcopy.build(1)[:rules].length).to eq(2) + + fcopy.clear_rules + expect(fb.build(1)[:rules].length).to eq(1) + end + + describe 'rules' do + def clause(attribute, values, negate, context_kind = 'user') + { contextKind: context_kind, attribute: attribute, op: 'in', values: values, negate: negate } + end + + it 'can build a rule with if_match' do + f = TestDataV2::FlagBuilderV2.new('flag').if_match('name', 'ben', 'amy').then_return(true).build(1) + expect(f[:rules]).to eql([ + { id: 'rule0', variation: 0, clauses: [clause('name', %w[ben amy], false)] }, + ]) + end + + it 'can build a rule with if_not_match' do + f = TestDataV2::FlagBuilderV2.new('flag').if_not_match('name', 'ben').then_return(false).build(1) + expect(f[:rules]).to eql([ + { id: 'rule0', variation: 1, clauses: [clause('name', ['ben'], true)] }, + ]) + end + + it 'can build a rule with if_match_context' do + f = TestDataV2::FlagBuilderV2.new('flag').if_match_context('org', 'name', 'acme').then_return(true).build(1) + expect(f[:rules]).to eql([ + { id: 'rule0', variation: 0, clauses: [clause('name', ['acme'], false, 'org')] }, + ]) + end + + it 'can build a rule with if_not_match_context' do + f = TestDataV2::FlagBuilderV2.new('flag').if_not_match_context('org', 'name', 'acme').then_return(true) + .build(1) + expect(f[:rules]).to eql([ + { id: 'rule0', variation: 0, clauses: [clause('name', ['acme'], true, 'org')] }, + ]) + end + + it 'can add clauses with and_match and and_not_match' do + f = TestDataV2::FlagBuilderV2.new('flag') + .if_match('name', 'ben') + .and_match('country', 'us') + .and_not_match('state', 'ca') + .then_return(true) + .build(1) + expect(f[:rules]).to eql([ + { + id: 'rule0', + variation: 0, + clauses: [ + clause('name', ['ben'], false), + clause('country', ['us'], false), + clause('state', ['ca'], true), + ], + }, + ]) + end + + it 'can add clauses with and_match_context and and_not_match_context' do + f = TestDataV2::FlagBuilderV2.new('flag') + .if_match('name', 'ben') + .and_match_context('org', 'name', 'acme') + .and_not_match_context('org', 'country', 'fr') + .then_return(true) + .build(1) + expect(f[:rules]).to eql([ + { + id: 'rule0', + variation: 0, + clauses: [ + clause('name', ['ben'], false), + clause('name', ['acme'], false, 'org'), + clause('country', ['fr'], true, 'org'), + ], + }, + ]) + end + + it 'then_return with a boolean changes the flag to a boolean flag' do + f = TestDataV2::FlagBuilderV2.new('flag').variations('a', 'b', 'c').if_match('name', 'ben') + .then_return(false).build(1) + expect(f).to eql(boolean_flag_hash('flag', 1, rules: [ + { id: 'rule0', variation: 1, clauses: [clause('name', ['ben'], false)] }, + ])) + end + + it 'then_return with an index keeps the variations' do + f = TestDataV2::FlagBuilderV2.new('flag').variations('a', 'b', 'c').if_match('name', 'ben') + .then_return(2).build(1) + expect(f[:variations]).to eql(%w[a b c]) + expect(f[:rules]).to eql([ + { id: 'rule0', variation: 2, clauses: [clause('name', ['ben'], false)] }, + ]) + end + + it 'numbers the rule ids in order' do + f = TestDataV2::FlagBuilderV2.new('flag') + .if_match('name', 'ben').then_return(true) + .if_not_match('name', 'amy').then_return(false) + .build(1) + expect(f[:rules]).to eql([ + { id: 'rule0', variation: 0, clauses: [clause('name', ['ben'], false)] }, + { id: 'rule1', variation: 1, clauses: [clause('name', ['amy'], true)] }, + ]) + end + + it 'can clear rules' do + f = TestDataV2::FlagBuilderV2.new('flag') + .if_match('name', 'ben').then_return(true) + .if_match('name', 'amy').then_return(false) + .clear_rules + .build(1) + expect(f).to eql(boolean_flag_hash('flag', 1)) + end + end + end + end + end +end diff --git a/spec/ldclient_fork_spec.rb b/spec/ldclient_fork_spec.rb index 7e8cdcb3..41e020a1 100644 --- a/spec/ldclient_fork_spec.rb +++ b/spec/ldclient_fork_spec.rb @@ -15,13 +15,22 @@ def pretend_forked allow(Process).to receive(:pid).and_return(real_pid + 1) end + # A data source runs on its own thread, so a fork breaks the client. + def client_config + test_config(logger: logger, data_system_config: test_data_system(Integrations::TestDataV2.data_source)) + end + + def daemon_config(send_events:) + Config.new(data_system_config: DataSystem.daemon(MockFeatureStore.new).build, send_events: send_events, logger: logger) + end + def fork_warnings(logger) logger.output.lines.grep(warning_pattern) end context "when the pid has not changed" do it "does not warn" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| client.variation("flag", context, false) client.identify(context) client.track("event", context) @@ -34,7 +43,7 @@ def fork_warnings(logger) context "when the pid has changed" do it "warns on variation" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked client.variation("flag", context, false) expect(fork_warnings(logger).length).to eq(1) @@ -42,7 +51,7 @@ def fork_warnings(logger) end it "warns on variation_detail" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked client.variation_detail("flag", context, false) expect(fork_warnings(logger).length).to eq(1) @@ -50,7 +59,7 @@ def fork_warnings(logger) end it "warns on all_flags_state" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked client.all_flags_state(context) expect(fork_warnings(logger).length).to eq(1) @@ -58,7 +67,7 @@ def fork_warnings(logger) end it "warns on migration_variation" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked client.migration_variation("flag", context, LaunchDarkly::Migrations::STAGE_OFF) expect(fork_warnings(logger).length).to eq(1) @@ -66,7 +75,7 @@ def fork_warnings(logger) end it "warns on identify" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked client.identify(context) expect(fork_warnings(logger).length).to eq(1) @@ -74,7 +83,7 @@ def fork_warnings(logger) end it "warns on track" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked client.track("event", context) expect(fork_warnings(logger).length).to eq(1) @@ -82,7 +91,7 @@ def fork_warnings(logger) end it "warns on flush" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked client.flush expect(fork_warnings(logger).length).to eq(1) @@ -90,14 +99,14 @@ def fork_warnings(logger) end it "warns on close" do - client = LDClient.new(sdk_key, test_config(logger: logger)) + client = LDClient.new(sdk_key, client_config) pretend_forked client.close expect(fork_warnings(logger).length).to eq(1) end it "warns only once per process across many calls" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked 5.times { client.variation("flag", context, false) } client.identify(context) @@ -109,7 +118,7 @@ def fork_warnings(logger) end it "warns only once when many threads evaluate at the same time" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked threads = Array.new(16) do Thread.new { 50.times { client.variation("flag", context, false) } } @@ -120,7 +129,7 @@ def fork_warnings(logger) end it "names postfork and says flag updates and analytics events do not work" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked client.variation("flag", context, false) end @@ -129,8 +138,8 @@ def fork_warnings(logger) expect(warning).to include("flag updates and analytics events will not work") end - it "warns in LDD mode when events are enabled" do - config = Config.new(use_ldd: true, send_events: true, logger: logger) + it "warns in daemon mode when events are enabled" do + config = daemon_config(send_events: true) with_client(config) do |client| pretend_forked client.variation("flag", context, false) @@ -138,8 +147,8 @@ def fork_warnings(logger) expect(fork_warnings(logger).length).to eq(1) end - it "does not warn in LDD mode when events are disabled" do - config = Config.new(use_ldd: true, send_events: false, logger: logger) + it "does not warn in daemon mode when events are disabled" do + config = daemon_config(send_events: false) with_client(config) do |client| pretend_forked client.variation("flag", context, false) @@ -162,7 +171,7 @@ def fork_warnings(logger) end it "stops warning after postfork" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| pretend_forked client.variation("flag", context, false) expect(fork_warnings(logger).length).to eq(1) @@ -175,7 +184,7 @@ def fork_warnings(logger) end it "warns again in a second forked process even if the first one already warned" do - with_client(test_config(logger: logger)) do |client| + with_client(client_config) do |client| real_pid = Process.pid allow(Process).to receive(:pid).and_return(real_pid + 1) client.variation("flag", context, false) @@ -197,7 +206,7 @@ def fork_supported? it "warns in the child and stops after postfork" do skip "fork is not supported on this platform" unless fork_supported? - client = LDClient.new(sdk_key, test_config(logger: logger)) + client = LDClient.new(sdk_key, client_config) begin reader, writer = IO.pipe pid = fork do diff --git a/spec/ldclient_spec.rb b/spec/ldclient_spec.rb index 0a1a3a3b..0587f667 100644 --- a/spec/ldclient_spec.rb +++ b/spec/ldclient_spec.rb @@ -5,18 +5,25 @@ module LaunchDarkly describe LDClient do subject { LDClient } + def daemon_data_system + DataSystem.daemon(MockFeatureStore.new).build + end + + def file_data_system + DataSystem.custom.synchronizers([Integrations::FileData.data_source_v2(paths: [])]).build + end + context "constructor requirement of non-nil sdk key" do it "is not enforced when offline" do subject.new(nil, Config.new(offline: true)) end - it "is not enforced if use_ldd is true and send_events is false" do - subject.new(nil, Config.new({ use_ldd: true, send_events: false })) + it "is not enforced in daemon mode if send_events is false" do + ensure_close(subject.new(nil, Config.new({ data_system_config: daemon_data_system, send_events: false }))) {} end it "is not enforced if using file data and send_events is false" do - source = LaunchDarkly::Integrations::FileData.data_source({}) - subject.new(nil, Config.new({ data_source: source, send_events: false })) + ensure_close(subject.new(nil, Config.new({ data_system_config: file_data_system, send_events: false }))) {} end it "is enforced in streaming mode even if send_events is false" do @@ -27,20 +34,26 @@ module LaunchDarkly it "is enforced in polling mode even if send_events is false" do expect { - subject.new(nil, Config.new({ stream: false, send_events: false })) + subject.new(nil, Config.new({ data_system_config: DataSystem.polling.build, send_events: false })) }.to raise_error(ArgumentError) end - it "is enforced if use_ldd is true and send_events is true" do + it "is enforced in daemon mode if send_events is true" do expect { - subject.new(nil, Config.new({ use_ldd: true })) + subject.new(nil, Config.new({ data_system_config: daemon_data_system })) }.to raise_error(ArgumentError) end it "is enforced if using file data and send_events is true" do - source = LaunchDarkly::Integrations::FileData.data_source({}) expect { - subject.new(nil, Config.new({ data_source: source })) + subject.new(nil, Config.new({ data_system_config: file_data_system })) + }.to raise_error(ArgumentError) + end + + it "is enforced if the FDv1 fallback needs the SDK key even if send_events is false" do + data_system = DataSystem.custom.fdv1_compatible_synchronizer(DataSystem.fdv1_fallback_ds_builder).build + expect { + subject.new(nil, Config.new({ data_system_config: data_system, send_events: false })) }.to raise_error(ArgumentError) end end @@ -56,8 +69,8 @@ module LaunchDarkly client.close() end - it "when in ldd mode" do - client = subject.new("sdk-key", Config.new(use_ldd: true)) + it "when in daemon mode" do + client = subject.new("sdk-key", Config.new(data_system_config: daemon_data_system)) client.close() end end From 54b1e96af60d69ec274a8d41fec4028abfb3cd4a Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 10 Nov 2026 11:53:58 -0500 Subject: [PATCH 09/11] chore: Trigger CI for the merged FDv1 removal From 4c207659091b0a61bd8ab7cd69b07d5c673b5435 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 10 Nov 2026 12:25:59 -0500 Subject: [PATCH 10/11] test: Report the stream parse error from on_error in the FDv2 streaming spec The invalid stream event fix moved the INVALID_DATA status from on_event to on_error. The FDv1 removal added a spec that expected on_event to report it. The spec now passes the raised error to on_error, as the SSE client does. --- spec/impl/data_system/streaming_synchronizer_spec.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/spec/impl/data_system/streaming_synchronizer_spec.rb b/spec/impl/data_system/streaming_synchronizer_spec.rb index 666f6201..9eed6157 100644 --- a/spec/impl/data_system/streaming_synchronizer_spec.rb +++ b/spec/impl/data_system/streaming_synchronizer_spec.rb @@ -618,11 +618,15 @@ def with_handlers(synchronizer) end end - it "raises the parse error to the SSE client so that it restarts the stream" do + it "raises the parse error to the SSE client and reports it from on_error" do with_handlers(build_synchronizer("http://stream.example.com")) do |handlers, updates| event = MockSSEEvent.new(LaunchDarkly::Interfaces::DataSystem::EventName::SERVER_INTENT, "{not json") - expect { handlers[:on_event].call(event) }.to raise_error(JSON::ParserError) + error = nil + expect { handlers[:on_event].call(event) }.to raise_error(JSON::ParserError) { |e| error = e } + expect(updates).to be_empty + + handlers[:on_error].call(error) expect(updates.pop(timeout: 1).error.kind).to eq(LaunchDarkly::Interfaces::DataSource::ErrorInfo::INVALID_DATA) end end From c148842b646aca3118314242d87c40e377ee98e7 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 10 Nov 2026 12:33:37 -0500 Subject: [PATCH 11/11] test: Wait for the file poller to start before changing the file The FDv2 file source yields its initial data before it starts the poller, and the poller reads the first modification times when it starts. On a slow runner the spec changed the file before the poller started, so the poller saw no change. The spec now waits until the poller exists. --- spec/integrations/file_data_source_v2_spec.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/spec/integrations/file_data_source_v2_spec.rb b/spec/integrations/file_data_source_v2_spec.rb index 5c4c3733..2927eaff 100644 --- a/spec/integrations/file_data_source_v2_spec.rb +++ b/spec/integrations/file_data_source_v2_spec.rb @@ -592,7 +592,10 @@ def no_selector_store end it "detects file changes with the polling watcher" do - expect(Impl::Integrations::FileDataSourcePollerV2).to receive(:new).and_call_original + poller_started = Queue.new + expect(Impl::Integrations::FileDataSourcePollerV2).to receive(:new).and_wrap_original do |original, *args| + original.call(*args).tap { poller_started << true } + end file = make_temp_file(flag_only_json) source = Impl::Integrations::FileDataSourceV2.new(logger, paths: [file.path], poll_interval: 0.1) @@ -607,6 +610,9 @@ def no_selector_store expect(initial).not_to be_nil, "Did not receive initial update" expect(initial.state).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + # The poller reads the first modification times when it starts. Change the file after that. + expect(poller_started.pop(timeout: 5)).to be true + # The poller compares modification times. Set a new time so that the change is visible. IO.write(file, segment_only_json) File.utime(Time.now + 5, Time.now + 5, file.path)