Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/actions/check/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ runs:
test_service_port: 9000
enable_persistence_tests: true
token: ${{ inputs.token }}
version: v3.0.0-alpha.4
version: v3.0.0-alpha.6
27 changes: 14 additions & 13 deletions contract-tests/client_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ def initialize(log, config)
if sync_configs && !sync_configs.empty?
synchronizer_builders = sync_configs.map { |sync_config| build_synchronizer_builder(sync_config) }.compact
data_system.synchronizers(synchronizer_builders) unless synchronizer_builders.empty?
end

fallback_builder = build_fdv1_fallback_builder(sync_configs)
data_system.fdv1_compatible_synchronizer(fallback_builder)
# The FDv1 Fallback Synchronizer is wired directly from a top-level
# dataSystem.fdv1Fallback config block -- the test harness no longer
# infers it from the FDv2 synchronizer chain.
fdv1_fallback_config = data_system_config[:fdv1Fallback]
if fdv1_fallback_config
data_system.fdv1_compatible_synchronizer(build_fdv1_fallback_builder(fdv1_fallback_config))
end

if data_system_config[:payloadFilter]
Expand Down Expand Up @@ -342,21 +347,17 @@ def close
end

#
# Builds an FDv1 fallback polling data source builder using the first available polling config.
# Builds an FDv1 fallback polling data source builder from the dedicated
# `dataSystem.fdv1Fallback` config block. This block has the same shape as
# a polling config (`baseUri`, `pollIntervalMs`).
#
# @param sync_configs [Array<Hash>] Array of synchronizer configurations
# @param fdv1_fallback_config [Hash] The FDv1 fallback configuration
# @return [Object] Returns the configured FDv1 fallback builder
#
private def build_fdv1_fallback_builder(sync_configs)
private def build_fdv1_fallback_builder(fdv1_fallback_config)
builder = LaunchDarkly::DataSystem.fdv1_fallback_ds_builder

# Use the first available polling config for the fallback base_uri
polling_config = sync_configs.lazy.map { |c| c[:polling] }.detect { |p| p }
if polling_config
builder.base_uri(polling_config[:baseUri]) if polling_config[:baseUri]
builder.poll_interval(polling_config[:pollIntervalMs] / 1_000.0) if polling_config[:pollIntervalMs]
end

builder.base_uri(fdv1_fallback_config[:baseUri]) if fdv1_fallback_config[:baseUri]
builder.poll_interval(fdv1_fallback_config[:pollIntervalMs] / 1_000.0) if fdv1_fallback_config[:pollIntervalMs]
builder
end

Expand Down
1 change: 1 addition & 0 deletions contract-tests/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
'persistent-data-store-redis',
'flag-change-listeners',
'flag-value-change-listeners',
'fdv1-fallback',
],
}.to_json
end
Expand Down
12 changes: 7 additions & 5 deletions lib/ldclient-rb/data_system/polling_data_source_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ module DataSystem
# include LaunchDarkly::DataSystem::Requester
#
# def fetch(selector)
# # Fetch data and return a Result containing [ChangeSet, headers]
# # ...
# LaunchDarkly::Result.success([change_set, {}])
# # Fetch data and return a Result whose value is a ChangeSet and
# # whose headers carry any response metadata (e.g. directives).
# LaunchDarkly::Result.success(change_set, response_headers)
# end
#
# def stop
Expand All @@ -43,8 +43,10 @@ module Requester
# @param selector [LaunchDarkly::Interfaces::DataSystem::Selector, nil]
# The selector describing what data to fetch. May be nil if no
# selector is available (e.g., on the first request).
# @return [LaunchDarkly::Result] A Result containing a tuple of
# [ChangeSet, headers] on success, or an error message on failure.
# @return [LaunchDarkly::Result] A Result whose `value` is a
# {LaunchDarkly::Interfaces::DataSystem::ChangeSet} on success and
# whose `headers` carry response headers in either case (so callers
# can inspect directives such as `X-LD-FD-Fallback`).
#
def fetch(selector)
raise NotImplementedError
Expand Down
11 changes: 6 additions & 5 deletions lib/ldclient-rb/impl/data_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,9 @@ class Update
# @return [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] Error information if applicable
attr_reader :error

# @return [Boolean] Whether to revert to FDv1
attr_reader :revert_to_fdv1
# @return [Boolean] Whether the LaunchDarkly server has instructed the SDK to
# fall back to the FDv1 protocol.
attr_reader :fallback_to_fdv1

# @return [String, nil] The environment ID if available
attr_reader :environment_id
Expand All @@ -257,14 +258,14 @@ class Update
# @param state [Symbol] The state of the data source
# @param change_set [ChangeSet, nil] The change set if available
# @param error [LaunchDarkly::Interfaces::DataSource::ErrorInfo, nil] Error information if applicable
# @param revert_to_fdv1 [Boolean] Whether to revert to FDv1
# @param fallback_to_fdv1 [Boolean] Whether to fall back to FDv1
# @param environment_id [String, nil] The environment ID if available
#
def initialize(state:, change_set: nil, error: nil, revert_to_fdv1: false, environment_id: nil)
def initialize(state:, change_set: nil, error: nil, fallback_to_fdv1: false, environment_id: nil)
@state = state
@change_set = change_set
@error = error
@revert_to_fdv1 = revert_to_fdv1
@fallback_to_fdv1 = fallback_to_fdv1
@environment_id = environment_id
end
end
Expand Down
79 changes: 67 additions & 12 deletions lib/ldclient-rb/impl/data_system/fdv2.rb
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,23 @@ def run_main_loop
nil
)

# Run initializers first
run_initializers
# Run initializers first. If an initializer signals the
# server-directed FDv1 Fallback Directive, switch terminally to
# the FDv1 Fallback Synchronizer (or transition to OFF if none
# is configured) before entering the synchronizer phase.
if run_initializers
if @fdv1_fallback_synchronizer_builder
@logger.warn { "[LDClient] Falling back to FDv1 protocol" }
@synchronizer_builders = [@fdv1_fallback_synchronizer_builder]
else
@logger.warn { "[LDClient] Initializer requested FDv1 fallback but none configured" }
@synchronizer_builders = []
@data_source_status_provider.update_status(
LaunchDarkly::Interfaces::DataSource::Status::OFF,
@data_source_status_provider.status.last_error
)
end
end

# Run synchronizers
run_synchronizers
Expand All @@ -228,39 +243,70 @@ def run_main_loop
#
# Run initializers to get initial data.
#
# @return [void]
# Each initializer is tried in order until one succeeds, the system
# is stopped, or an initializer signals the server-directed FDv1
# Fallback Directive. When fallback is signalled alongside a valid
# payload, that payload is applied before returning so evaluations
# can serve the server-provided data while the FDv1 synchronizer
# spins up. The method returns true when fallback was requested so
# that the caller can switch the synchronizer list.
#
# @return [Boolean] true when an initializer requested FDv1 fallback.
#
def run_initializers
return unless @data_system_config.initializers
return false unless @data_system_config.initializers

@data_system_config.initializers.each do |initializer_builder|
return if @stop_event.set?
return false if @stop_event.set?

begin
initializer = initializer_builder.build(@sdk_key, @config)
@logger.info { "[LDClient] Attempting to initialize via #{initializer.name}" }

basis_result = initializer.fetch(@store)
fetch_result = initializer.fetch(@store)
fallback = fetch_result.fallback_to_fdv1
basis_result = fetch_result.result

if basis_result.success?
basis = basis_result.value
@logger.info { "[LDClient] Initialized via #{initializer.name}" }

# Apply the basis to the store
# Apply the basis to the store regardless of whether fallback was signalled.
# If the server returned a valid payload alongside the directive we still want
# evaluations to serve that data while the FDv1 synchronizer spins up.
@store.apply(basis.change_set, basis.persist)

# Set ready event if and only if a selector is defined for the changeset
# Set ready event if and only if a selector is defined for the changeset.
if basis.change_set.selector && basis.change_set.selector.defined?
@ready_event.set
return
return fallback
end
else
@logger.warn { "[LDClient] Initializer #{initializer.name} failed: #{basis_result.error}" }
if fallback
# Record the underlying initializer error so that, if no FDv1 fallback is
# configured, the subsequent transition to OFF carries it as last_error.
@data_source_status_provider.update_status(
LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING,
LaunchDarkly::Interfaces::DataSource::ErrorInfo.new(
LaunchDarkly::Interfaces::DataSource::ErrorInfo::UNKNOWN,
0,
basis_result.error || "",
Time.now
)
)
end
end

# The fallback directive takes precedence over the regular failover algorithm,
# so do not fall through to the next initializer when it is set.
return true if fallback
rescue => e
@logger.error { "[LDClient] Initializer failed with exception: #{e.message}" }
end
end

false
end

#
Expand Down Expand Up @@ -313,12 +359,21 @@ def synchronizer_loop
case sync_result
when SyncResult::FDV1
if @fdv1_fallback_synchronizer_builder
@logger.warn { "[LDClient] Falling back to FDv1 protocol" }
@synchronizer_builders = [@fdv1_fallback_synchronizer_builder]
current_index = 0
next
end
# No FDv1 fallback configured, treat as regular fallback
current_index += 1
# No FDv1 fallback configured: the data system must HALT
# rather than fall through to the next FDv2 synchronizer.
# Continuing to retry would reopen the connection that just
# delivered the directive.
@logger.warn { "[LDClient] Synchronizer requested FDv1 fallback but none configured; halting data system" }
@data_source_status_provider.update_status(
LaunchDarkly::Interfaces::DataSource::Status::OFF,
@data_source_status_provider.status.last_error
)
break
when SyncResult::RECOVER
@logger.info { "[LDClient] Recovery condition met, returning to primary synchronizer" }
current_index = 0
Expand Down Expand Up @@ -410,7 +465,7 @@ def consume_synchronizer_results(synchronizer, check_recovery: false)
# Update status
@data_source_status_provider.update_status(update.state, update.error)

return SyncResult::FDV1 if update.revert_to_fdv1
return SyncResult::FDV1 if update.fallback_to_fdv1

return SyncResult::REMOVE if update.state == LaunchDarkly::Interfaces::DataSource::Status::OFF
end
Expand Down
Loading
Loading