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/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/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/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 a5c980f9..b279b5fe 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/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 b8dcd54e..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 @@ -325,6 +324,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 +386,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/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 d1934188..9eed6157 100644 --- a/spec/impl/data_system/streaming_synchronizer_spec.rb +++ b/spec/impl/data_system/streaming_synchronizer_spec.rb @@ -536,6 +536,145 @@ 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 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") + + 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 + + [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/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/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..2927eaff 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,210 @@ 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 + 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) + 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 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) + + 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/integrations/test_data_v2_spec.rb b/spec/integrations/test_data_v2_spec.rb index 54f74919..0c519fa8 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,6 +53,261 @@ 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 + it 'returns the value_for_all value whether targeting is on or off' do td = TestDataV2.data_source flag = td.flag('myflag').value_for_all('x').build(1) @@ -63,6 +320,36 @@ module Integrations 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_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_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..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 @@ -90,11 +103,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