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 fe2da447..e742d3d5 100644 --- a/lib/ldclient-rb/integrations/test_data_v2.rb +++ b/lib/ldclient-rb/integrations/test_data_v2.rb @@ -1,7 +1,10 @@ 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' +require 'set' module LaunchDarkly module Integrations @@ -56,6 +59,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 +114,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 @@ -159,13 +167,72 @@ 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 + # + # 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. + # + # 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. + # + # 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) + flag_data = normalize_preconfigured_item(LaunchDarkly::Interfaces::DataStore::FEATURES, flag, "flag") + + @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] = flag_data + @flag_builders.delete(flag_data[:key]) + + instances_copy = @instances.dup + end + + instances_copy.each do |instance| + instance.upsert_flag(flag_data) + end + end + + self + end + # # Copies a full segment data model object into the test data. # @@ -179,41 +246,134 @@ def add_instance(instance) # 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) + @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 + end + + 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. + # + # @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) + raw = + if item.is_a?(String) + 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) + item + elsif item.respond_to?(:as_json) + item.as_json else - segment.as_json + raise ArgumentError, "a preconfigured #{description} must be a Hash, a JSON string, or a model object" end - segment_key = segment_hash[:key].to_sym - old_segment = @current_segments[segment_key] - old_version = old_segment ? old_segment[:version] : 0 + raise ArgumentError, "a preconfigured #{description} must be a JSON object" unless raw.is_a?(Hash) - updated_segment = segment_hash.dup - updated_segment[:version] = old_version + 1 + data = normalize_json_value(raw, description, "", 0) - @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, data) + rescue => e + raise ArgumentError, "preconfigured #{description} '#{key}' is not valid: #{e.message}" end - self + data + 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/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..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 @@ -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 # @@ -572,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/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/integrations/test_data_v2_spec.rb b/spec/integrations/test_data_v2_spec.rb index d157df40..cb76d424 100644 --- a/spec/integrations/test_data_v2_spec.rb +++ b/spec/integrations/test_data_v2_spec.rb @@ -1,6 +1,8 @@ require "ldclient-rb/integrations/test_data_v2" require "ldclient-rb/impl/integrations/test_data/test_data_source_v2" require "spec_helper" +require "json" +require "set" module LaunchDarkly module Integrations @@ -51,7 +53,292 @@ 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) + 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 + + 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 + + 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, + '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'] }, + '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 + 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 '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"]}]}]}')) + 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.subscribe(instance) + first = Thread.new { yield td, 1 } + 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 + + 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) + 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 '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)) 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