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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for Ruby](https:/
| ✅ | Logging | The provider logs through the logger of the `LaunchDarkly::Config` it is given. |
| ✅ | Domains | Domains bind clients to providers in the OpenFeature SDK; a separate provider instance may be registered per domain. |
| ✅ | Eventing | LaunchDarkly data source status changes are emitted as `PROVIDER_READY`, `PROVIDER_STALE`, and `PROVIDER_ERROR`. Flag changes are emitted as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. |
| ✅ | Initialization | `init` reports whether the LaunchDarkly client became ready within the configured wait time; a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. |
| ✅ | Initialization | `init` reports whether the LaunchDarkly client became ready within the configured wait time, and waits without a deadline when that wait time is zero; a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. |
| ✅ | Shutdown | `shutdown` closes the LaunchDarkly client. A closed client cannot be restarted, so a new provider instance is required afterward. |
| ✅ | Tracking | `track` sends a LaunchDarkly custom event for the evaluation context, with the tracking event value and remaining details attached. |
| ✅ | Transaction Context Propagation | Provided by the OpenFeature SDK, which merges the transaction context into the evaluation context before the provider is called; no provider support is required. |
Expand Down
25 changes: 25 additions & 0 deletions lib/ldclient-openfeature/impl/event_listeners.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,31 @@ def update(status)
end
end

#
# Reports the first data source state which decides the outcome of the client's initial connection attempt.
#
class DataSourceOutcomeListener
#
# @param outcome [Queue]
#
def initialize(outcome)
@outcome = outcome
end

#
# @param status [LaunchDarkly::Interfaces::DataSource::Status]
#
# @return [void]
#
def update(status)
case status.state
when ::LaunchDarkly::Interfaces::DataSource::Status::VALID,
::LaunchDarkly::Interfaces::DataSource::Status::OFF
@outcome.push(status.state)
end
end
end

#
# Translates LaunchDarkly flag change events into OpenFeature configuration changed events.
#
Expand Down
31 changes: 29 additions & 2 deletions lib/ldclient-openfeature/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class Provider
def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds = 5)
@client = LaunchDarkly::LDClient.new(sdk_key, config.with_wrapper_information(WRAPPER_NAME, VERSION), wait_for_seconds)

@wait_for_seconds = wait_for_seconds
@logger = config.logger
@context_converter = Impl::EvaluationContextConverter.new(config.logger)
@details_converter = Impl::ResolutionDetailsConverter.new
Expand All @@ -49,14 +50,19 @@ def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds
end

#
# Called by the OpenFeature SDK when this provider is set. The LaunchDarkly client has already been given the
# opportunity to initialize, so this only reports whether that succeeded.
# Called by the OpenFeature SDK when this provider is set.
#
# A positive wait time has already been applied by the LaunchDarkly client constructor, so this reports whether
# that succeeded. A wait time of zero asks for no deadline, so this waits for the data source to become valid or
# to fail permanently.
#
# @param _evaluation_context [::OpenFeature::SDK::EvaluationContext, nil]
#
# @return [void]
#
def init(_evaluation_context = nil)
wait_for_data_source_outcome if @wait_for_seconds.to_f <= 0

return if @client.initialized?

state = @client.data_source_status_provider.status.state
Expand Down Expand Up @@ -170,6 +176,27 @@ def track(tracking_event_name, evaluation_context: nil, tracking_event_details:
@details_converter.to_resolution_details(evaluation_detail)
end

#
# Block until the data source reports an outcome for the client's first connection attempt.
#
# @return [void]
#
private def wait_for_data_source_outcome
outcome = Queue.new
listener = Impl::DataSourceOutcomeListener.new(outcome)
status_provider = @client.data_source_status_provider
status_provider.add_listener(listener)

begin
return if @client.initialized?
return if status_provider.status.state == ::LaunchDarkly::Interfaces::DataSource::Status::OFF

outcome.pop
ensure
status_provider.remove_listener(listener)
end
end

#
# @param default_value [any]
#
Expand Down
29 changes: 29 additions & 0 deletions spec/impl/data_source_outcome_listener_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

RSpec.describe LaunchDarkly::OpenFeature::Impl::DataSourceOutcomeListener do
let(:outcome) { Queue.new }
let(:listener) { described_class.new(outcome) }

def status(state)
LaunchDarkly::Interfaces::DataSource::Status.new(state, Time.now, nil)
end

it "reports a valid data source" do
listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::VALID))

expect(outcome.pop).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID)
end

it "reports a shut down data source" do
listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::OFF))

expect(outcome.pop).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF)
end

it "does not report a state which leaves the outcome undecided" do
listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING))
listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED))

expect(outcome).to be_empty
end
end
42 changes: 40 additions & 2 deletions spec/provider_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
let(:config) { LaunchDarkly::Config.new(data_source: td, send_events: false) }
let(:provider) { described_class.new("example-key", config) }

def data_source_status(state, error = nil)
LaunchDarkly::Interfaces::DataSource::Status.new(state, Time.now, error)
end

it "metadata is set correctly" do
expect(provider.metadata.name).to eq("launchdarkly-openfeature-server")
end
Expand Down Expand Up @@ -48,13 +52,47 @@
end

it "init raises when the client failed to initialize" do
status = LaunchDarkly::Interfaces::DataSource::Status.new(LaunchDarkly::Interfaces::DataSource::Status::OFF, Time.now, nil)
status_provider = double(status: status)
status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::OFF))
allow(provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider)

expect { provider.init(evaluation_context) }.to raise_error(/unable to initialize/)
end

it "init with no wait time waits for the data source to become valid" do
zero_wait_provider = described_class.new("example-key", config, 0)

expect { zero_wait_provider.init(evaluation_context) }.not_to raise_error
expect(zero_wait_provider.client.initialized?).to be(true)
end

it "init with no wait time waits for a data source outcome which arrives later" do
zero_wait_provider = described_class.new("example-key", config, 0)
listeners = []
status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING))
allow(status_provider).to receive(:add_listener) { |listener| listeners << listener }
allow(status_provider).to receive(:remove_listener)
allow(zero_wait_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider)

reporter = Thread.new do
sleep(0.01) while listeners.empty?
listeners.each { |listener| listener.update(data_source_status(LaunchDarkly::Interfaces::DataSource::Status::OFF)) }
end

expect { zero_wait_provider.init(evaluation_context) }.to raise_error(/unable to initialize/)

reporter.join
end

it "init with a wait time does not wait again" do
wait_provider = described_class.new("example-key", config, 5)
status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING))
allow(status_provider).to receive(:add_listener)
allow(wait_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider)

expect { wait_provider.init(evaluation_context) }.to raise_error(/unable to initialize/)
expect(status_provider).not_to have_received(:add_listener)
end

it "shutdown closes the client" do
allow(provider.client).to receive(:close)

Expand Down
Loading