Skip to content
Merged
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
36 changes: 35 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,38 @@ 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.
#
# When the client is offline, the source is not built or started: no overrides are served,
# and a configuration error in the source is not reported. The SDK logs a warning instead.
#
# @example
# overrides = LaunchDarkly::Integrations::FileData.override_source(paths: ["/etc/launchdarkly/overrides.json"])
# config = LaunchDarkly::Config.new(data_system_config: LaunchDarkly::DataSystem.default.overrides(overrides).build)
#
# @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 +129,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
41 changes: 37 additions & 4 deletions lib/ldclient-rb/evaluation_detail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ class EvaluationReason
# @return [Symbol]
attr_reader :big_segments_status

# True if an override affected this evaluation, directly or transitively. It is true when the
# evaluated flag came from the SDK's override store. It is also true when a prerequisite flag at
# any depth, or a segment read during the evaluation, came from the override store. In the JSON
# representation, the `overrideAffected` property appears only when this is true.
#
# Flag overrides are currently experimental and subject to change.
# @return [Boolean]
attr_reader :override_affected

# Returns an instance whose {#kind} is {#OFF}.
# @return [EvaluationReason]
def self.off
Expand Down Expand Up @@ -216,12 +225,14 @@ def ==(other)
if other.is_a? EvaluationReason
@kind == other.kind && @rule_index == other.rule_index && @rule_id == other.rule_id &&
@prerequisite_key == other.prerequisite_key && @error_kind == other.error_kind &&
@big_segments_status == other.big_segments_status
@big_segments_status == other.big_segments_status &&
@override_affected == other.override_affected
elsif other.is_a? Hash
@kind.to_s == other[:kind] && @rule_index == other[:ruleIndex] && @rule_id == other[:ruleId] &&
@prerequisite_key == other[:prerequisiteKey] &&
(other[:errorKind] == @error_kind.nil? ? nil : @error_kind.to_s) &&
(other[:bigSegmentsStatus] == @big_segments_status.nil? ? nil : @big_segments_status.to_s)
(other[:bigSegmentsStatus] == @big_segments_status.nil? ? nil : @big_segments_status.to_s) &&
!!other[:overrideAffected] == @override_affected
end
end

Expand Down Expand Up @@ -286,6 +297,8 @@ def as_json(*) # parameter is unused, but may be passed if we're using the json
unless @big_segments_status.nil?
ret[:bigSegmentsStatus] = @big_segments_status
end
# The property is written only when true, so the wire format of an ordinary evaluation is unchanged.
ret[:overrideAffected] = true if @override_affected
ret
end

Expand All @@ -312,22 +325,41 @@ def [](key)
@error_kind.nil? ? nil : @error_kind.to_s
when :bigSegmentsStatus
@big_segments_status.nil? ? nil : @big_segments_status.to_s
when :overrideAffected
@override_affected
else
nil
end
end

def with_big_segments_status(big_segments_status)
return self if @big_segments_status == big_segments_status
EvaluationReason.new(@kind, @rule_index, @rule_id, @prerequisite_key, @error_kind, @in_experiment, big_segments_status)
EvaluationReason.new(@kind, @rule_index, @rule_id, @prerequisite_key, @error_kind, @in_experiment,
big_segments_status, @override_affected)
end

#
# Returns a reason that is the same as this one apart from the {#override_affected} indicator.
# Returns this instance when the indicator already has the given value.
#
# Flag overrides are currently experimental and subject to change.
#
# @param override_affected [Boolean]
# @return [EvaluationReason]
#
def with_override_affected(override_affected)
override_affected = !!override_affected
return self if @override_affected == override_affected
EvaluationReason.new(@kind, @rule_index, @rule_id, @prerequisite_key, @error_kind, @in_experiment,
@big_segments_status, override_affected)
end

#
# Constructor that sets all properties. Applications should not normally use this constructor,
# but should use class methods like {#off} to avoid creating unnecessary instances.
#
def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind, in_experiment=nil,
big_segments_status = nil)
big_segments_status = nil, override_affected = false)
@kind = kind.to_sym
@rule_index = rule_index
@rule_id = rule_id
Expand All @@ -337,6 +369,7 @@ def initialize(kind, rule_index, rule_id, prerequisite_key, error_kind, in_exper
@error_kind = error_kind
@in_experiment = in_experiment
@big_segments_status = big_segments_status
@override_affected = !!override_affected
end

private_class_method def self.make_error(error_kind)
Expand Down
83 changes: 80 additions & 3 deletions lib/ldclient-rb/impl/data_store/store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ def initialize(flag_change_broadcaster, change_set_broadcaster, logger)
# the form in which the data model names prerequisites and segments.
@dependency_tracker = LaunchDarkly::Impl::DependencyTracker.new(logger)

# A second dependency graph, such as the override layer's, whose items may depend on
# items in this store. Set once when the data system is built, before any data.
@external_dependencies = nil

# Broadcasters for events
@flag_change_broadcaster = flag_change_broadcaster
@change_set_broadcaster = change_set_broadcaster
Expand Down Expand Up @@ -180,6 +184,41 @@ 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

#
# Registers a second dependency graph whose items may depend on items in this store. A
# change to an item in this store then also notifies the external items that depend on
# it, directly or through any chain of dependencies in either graph.
#
# @param dependencies [#add_affected_items, nil] a graph answering `add_affected_items(items_out, { kind:, key: })`
# @return [void]
#
def set_external_dependencies(dependencies)
@lock.synchronize do
@external_dependencies = dependencies
end
end

#
# Get the data store status provider for the persistent store, if configured.
#
Expand Down Expand Up @@ -257,7 +296,7 @@ def get_data_store_status_provider
collection.each do |key, item|
@dependency_tracker.update_dependencies_from(kind, key.to_s, item)
if has_listeners
@dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key.to_s })
add_affected_items(affected_items, { kind: kind, key: key })
end
end
end
Expand Down Expand Up @@ -344,6 +383,44 @@ def get_data_store_status_provider
end
end

#
# Adds the item and everything that depends on it to the set, following this store's own
# dependency graph and the external one. Every item reached through either graph is
# expanded through both, so a dependency chain may cross between them any number of
# times. Keys are compared as strings, so a change keyed by symbol reaches the same
# dependents as one keyed by string. Without an external graph, the store's own tracker
# expands the item in one traversal, as it did before external graphs existed.
#
# @param items_out [Set<Hash>] Set of {kind:, key:} hashes
# @param initial_modified_item [Hash] {kind:, key:} hash
# @return [void]
#
private def add_affected_items(items_out, initial_modified_item)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs for every FDv2 user, including those who never configure overrides. Results are the same as before, but each item reached starts a fresh traversal under the store lock, even when @external_dependencies is nil. A full transfer of a 1000-deep prerequisite chain goes from 0.017 s at the base to 2.3 s, and a 10k-flag data set with a shared segment from 0.30 s to 0.44 s.

Go calls the tracker directly when there is no external graph (internal/datasystem/store.go, addAffectedItems). Can this do the same, so users without overrides keep today's cost?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reply was written by an AI agent (Claude Code) working on behalf of @kinyoklion.

Fixed in a9befc7. When no external graph is set, add_affected_items calls the store's own tracker once with the string key, which is what the store did before external graphs existed, so FDv2 users without overrides keep the previous cost. The fixpoint over both graphs only runs when an override layer is attached. The store specs, including the dependent-flag specs from #470, pass.

start = { kind: initial_modified_item[:kind], key: initial_modified_item[:key].to_s }
if @external_dependencies.nil?
@dependency_tracker.add_affected_items(items_out, start)
return
end

pending = [start]
until pending.empty?
current = pending.pop
# Each graph gets its own working set: a tracker does not expand an item that is
# already present, so a shared set would stop the second graph at the start.
via_store = Set.new
@dependency_tracker.add_affected_items(via_store, current)
via_external = Set.new
@external_dependencies&.add_affected_items(via_external, current)
(via_store | via_external).each do |reached|
item = { kind: reached[:kind], key: reached[:key].to_s }
next if items_out.include?(item)

items_out.add(item)
pending << item
end
end
end

#
# Send flag change events for affected items.
#
Expand Down Expand Up @@ -381,9 +458,9 @@ def get_data_store_status_provider

# If either is missing or versions differ, it's a change
if old_item.nil? || new_item.nil?
@dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key.to_s })
add_affected_items(affected_items, { kind: kind, key: key })
elsif old_item[:version] != new_item[:version]
@dependency_tracker.add_affected_items(affected_items, { kind: kind, key: key.to_s })
add_affected_items(affected_items, { kind: kind, key: key })
end
end
end
Expand Down
13 changes: 13 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,19 @@ def store
raise NotImplementedError, "#{self.class} must implement #store"
end

#
# Returns the override store's entries of the given kind, without LaunchDarkly data. The
# result is empty when no override source is configured or the store holds none. The
# returned hash must not be modified. Flag overrides are currently experimental and subject
# to change.
#
# @param kind [LaunchDarkly::Impl::DataStore::DataKind]
# @return [Hash{Symbol => Object}]
#
def override_items(kind)
raise NotImplementedError, "#{self.class} must implement #override_items"
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_items)
def override_items(_kind)
{}
end

# (see DataSystem#set_diagnostic_accumulator)
def set_diagnostic_accumulator(diagnostic_accumulator)
@diagnostic_accumulator = diagnostic_accumulator
Expand Down
Loading
Loading