Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion lib/ldclient-rb/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -736,14 +736,18 @@ class DataSystemConfig
# @param data_store [LaunchDarkly::Interfaces::FeatureStore, nil] The (optional) data store
# @param fdv1_fallback_synchronizer [#build(String, Config), nil]
# The (optional) builder for FDv1-compatible fallback synchronizer
# @param overrides [#build(String, Config), nil] The (optional) builder for an override source.
# Flag overrides are currently experimental and subject to change.
#
def initialize(initializers: nil, synchronizers: nil,
data_store_mode: LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY, data_store: nil, fdv1_fallback_synchronizer: nil)
data_store_mode: LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY, data_store: nil, fdv1_fallback_synchronizer: nil,
overrides: nil)
@initializers = initializers
@synchronizers = synchronizers
@data_store_mode = data_store_mode
@data_store = data_store
@fdv1_fallback_synchronizer = fdv1_fallback_synchronizer
@overrides = overrides
end

# The initializer builders for the data system. Each builder responds to build(sdk_key, config) and returns an Initializer.
Expand All @@ -765,5 +769,11 @@ def initialize(initializers: nil, synchronizers: nil,
# The FDv1-compatible fallback synchronizer builder. Responds to build(sdk_key, config) and returns a Synchronizer.
# @return [#build(String, Config), nil]
attr_reader :fdv1_fallback_synchronizer

# The override source builder. Responds to build(sdk_key, config) and returns an
# {LaunchDarkly::Interfaces::Overrides::OverrideSource}. Flag overrides are currently
# experimental and subject to change.
# @return [#build(String, Config), nil]
attr_reader :overrides
end
end
33 changes: 32 additions & 1 deletion lib/ldclient-rb/data_system/config_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def initialize
@fdv1_fallback_synchronizer = nil
@data_store_mode = LaunchDarkly::Interfaces::DataSystem::DataStoreMode::READ_ONLY
@data_store = nil
@overrides = nil
end

#
Expand Down Expand Up @@ -85,6 +86,35 @@ def data_store(data_store, store_mode)
self
end

#
# Configures the SDK with an override source. Flag overrides are currently experimental and
# subject to change.
#
# Overrides are flag and segment definitions that take precedence over data received from
# LaunchDarkly at evaluation time, on a per-key basis. They exist for resilience during an
# incident. Overrides let an operator force one or more flags to a known state on a running
# client, whether or not the client can reach LaunchDarkly. Flags not present in the override
# data are completely unaffected.
#
# The override source is not a data source. It has no effect on the client's initialization
# status, data availability, or data source status. Without a configured source, or while
# the source supplies no overrides, the SDK behaves as it would without this feature.
#
# At most one override source can be configured. A later call replaces an earlier one.
#
# @example
# overrides = LaunchDarkly::Integrations::FileData.override_source(paths: ["/etc/launchdarkly/overrides.json"])
# config = LaunchDarkly::Config.new(data_system: LaunchDarkly::DataSystem.default.overrides(overrides))
#
# @param source [#build(String, Config), nil] a builder that responds to build(sdk_key, config)
# and returns a {LaunchDarkly::Interfaces::Overrides::OverrideSource}, or nil to remove one
# @return [ConfigBuilder] self for chaining
#
def overrides(source)
@overrides = source
self
end

#
# Builds the data system configuration.
#
Expand All @@ -96,7 +126,8 @@ def build
synchronizers: @synchronizers,
data_store_mode: @data_store_mode,
data_store: @data_store,
fdv1_fallback_synchronizer: @fdv1_fallback_synchronizer
fdv1_fallback_synchronizer: @fdv1_fallback_synchronizer,
overrides: @overrides
)
end
end
Expand Down
21 changes: 21 additions & 0 deletions lib/ldclient-rb/impl/data_store/store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,27 @@ def initialized?
get_active_store.initialized?
end

#
# Retrieves an item from the active store.
#
# @param kind [LaunchDarkly::Impl::DataStore::DataKind]
# @param key [String, Symbol]
# @return [Object, nil] the item, or nil if it is unknown or deleted
#
def get(kind, key)
get_active_store.get(kind, key)
end

#
# Retrieves all items of a kind from the active store.
#
# @param kind [LaunchDarkly::Impl::DataStore::DataKind]
# @return [Hash{Symbol => Object}]
#
def all(kind)
get_active_store.all(kind)
end

#
# Get the data store status provider for the persistent store, if configured.
#
Expand Down
10 changes: 10 additions & 0 deletions lib/ldclient-rb/impl/data_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ def store
raise NotImplementedError, "#{self.class} must implement #store"
end

#
# Returns whether the data system was built with an override source. The value is fixed at
# construction. Flag overrides are currently experimental and subject to change.
#
# @return [Boolean]
#
def override_source_configured?
raise NotImplementedError, "#{self.class} must implement #override_source_configured?"
end

#
# Sets the diagnostic accumulator for streaming initialization metrics.
# This should be called before start() to ensure metrics are collected.
Expand Down
5 changes: 5 additions & 0 deletions lib/ldclient-rb/impl/data_system/fdv1.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ def store
@store_wrapper
end

# (see DataSystem#override_source_configured?)
def override_source_configured?
false
end

# (see DataSystem#set_diagnostic_accumulator)
def set_diagnostic_accumulator(diagnostic_accumulator)
@diagnostic_accumulator = diagnostic_accumulator
Expand Down
38 changes: 38 additions & 0 deletions lib/ldclient-rb/impl/data_system/fdv2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require "ldclient-rb/impl/data_source/status_provider"
require "ldclient-rb/impl/data_store/status_provider"
require "ldclient-rb/impl/broadcaster"
require "ldclient-rb/impl/overrides"
require "ldclient-rb/impl/repeating_task"
require "ldclient-rb/interfaces/data_system"

Expand Down Expand Up @@ -118,6 +119,21 @@ def initialize(sdk_key, config, data_system_config)
# Track configuration
@configured_with_data_sources = (@data_system_config.initializers && !@data_system_config.initializers.empty?) ||
!@synchronizer_builders.empty?

# The following are nil unless an override source is configured. The layer holds override
# entries. The overlay serves them in preference to the store's data at the store read
# boundary. The source populates the layer at runtime. The override system is separate
# from the initializer and synchronizer pipeline and never affects data availability.
@override_layer = nil
@overlay = nil
@override_source = nil
if @data_system_config.overrides && !@disabled
# An invalid override source configuration raises here, so LDClient.new reports it
# the same way as other invalid component configuration.
@override_source = @data_system_config.overrides.build(@sdk_key, @config)
@override_layer = LaunchDarkly::Impl::Overrides::Layer.new
@overlay = LaunchDarkly::Impl::Overrides::Overlay.new(@store, @override_layer)
end
end

# (see DataSystem#start)
Expand All @@ -131,6 +147,13 @@ def start
@stop_event.reset
@ready_event.reset

# The override source starts before the run loop, so a source that loads synchronously has
# its overrides in place before the client begins evaluating.
if @override_source
sink = LaunchDarkly::Impl::Overrides::Sink.new(@override_layer, @store, @flag_change_broadcaster, @logger)
@override_source.start(sink)
end

# Start the main coordination thread
main_thread = Thread.new { run_main_loop }
main_thread.name = "FDv2-main"
Expand All @@ -141,6 +164,14 @@ def start

# (see DataSystem#stop)
def stop
if @override_source
begin
@override_source.stop
rescue => e
@logger.error { "[LDClient] Error stopping override source: #{e.message}" }
end
end

@stop_event.set

@lock.synchronize do
Expand Down Expand Up @@ -175,9 +206,16 @@ def set_diagnostic_accumulator(diagnostic_accumulator)

# (see DataSystem#store)
def store
return @overlay if @overlay

@store.get_active_store
end

# (see DataSystem#override_source_configured?)
def override_source_configured?
!@override_source.nil?
end

# (see DataSystem#data_source_status_provider)
def data_source_status_provider
@data_source_status_provider
Expand Down
5 changes: 5 additions & 0 deletions lib/ldclient-rb/impl/overrides.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

require "ldclient-rb/impl/overrides/layer"
require "ldclient-rb/impl/overrides/overlay"
require "ldclient-rb/impl/overrides/sink"
98 changes: 98 additions & 0 deletions lib/ldclient-rb/impl/overrides/layer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require "ldclient-rb/impl/data_store"

require "concurrent/atomics"

module LaunchDarkly
module Impl
#
# The flag and segment override layer. The layer is a runtime-mutable collection of flag and
# segment definitions, supplied by an override source. Those definitions take precedence over
# LaunchDarkly data at evaluation time.
#
# @private
#
module Overrides
#
# A thread-safe store of override entries, replaced as a whole on each update from an
# override source. Reads are lock-free: the contents are an immutable hash held in an
# atomic reference and swapped on update, so the layer holds exactly one snapshot at any
# instant.
#
class Layer
EMPTY_CONTENTS = {
DataStore::FEATURES => {}.freeze,
DataStore::SEGMENTS => {}.freeze,
}.freeze
private_constant :EMPTY_CONTENTS

def initialize
@contents = Concurrent::AtomicReference.new(EMPTY_CONTENTS)
end

#
# Atomically replaces the entire layer contents. Empty hashes clear the layer.
#
# Each flag or segment is stored as a marked shallow copy. The copy shares its data with the
# caller's object, and the layer never writes to it. The caller's object is never marked.
#
# @param flags [Hash{Symbol => LaunchDarkly::Impl::Model::FeatureFlag}]
# @param segments [Hash{Symbol => LaunchDarkly::Impl::Model::Segment}]
# @return [Array(Hash, Hash)] the previous and the new contents, keyed by data kind. The
# returned hashes must not be modified.
#
def set_all(flags, segments)
replacement = {
DataStore::FEATURES => flags.transform_values(&:as_override).freeze,
DataStore::SEGMENTS => segments.transform_values(&:as_override).freeze,
}.freeze
previous = @contents.get_and_set(replacement)
[previous, replacement]
end

#
# Returns the override entry for a key, or nil.
#
# @param kind [LaunchDarkly::Impl::DataStore::DataKind]
# @param key [String, Symbol]
# @return [Object, nil]
#
def get(kind, key)
items = @contents.get[kind]
return nil if items.nil?

items[key.to_sym]
end

#
# Returns the entries of the given kind. The returned hash must not be modified.
#
# @param kind [LaunchDarkly::Impl::DataStore::DataKind]
# @return [Hash{Symbol => Object}]
#
def all(kind)
@contents.get[kind] || {}
end

#
# Returns the current contents, keyed by data kind. The returned hash must not be modified.
#
# @return [Hash]
#
def contents
@contents.get
end

#
# Returns true if the layer contains no entries.
#
# @return [Boolean]
#
def empty?
@contents.get.each_value.all?(&:empty?)
end
end
end
end
end
Loading
Loading