diff --git a/README.md b/README.md index 466bc84..074fc54 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/lib/ldclient-openfeature/impl/event_listeners.rb b/lib/ldclient-openfeature/impl/event_listeners.rb index a78fc6d..792d4a3 100644 --- a/lib/ldclient-openfeature/impl/event_listeners.rb +++ b/lib/ldclient-openfeature/impl/event_listeners.rb @@ -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. # diff --git a/lib/ldclient-openfeature/provider.rb b/lib/ldclient-openfeature/provider.rb index eb091cd..3b9d9fd 100644 --- a/lib/ldclient-openfeature/provider.rb +++ b/lib/ldclient-openfeature/provider.rb @@ -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 @@ -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 @@ -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] # diff --git a/spec/impl/data_source_outcome_listener_spec.rb b/spec/impl/data_source_outcome_listener_spec.rb new file mode 100644 index 0000000..3be6db2 --- /dev/null +++ b/spec/impl/data_source_outcome_listener_spec.rb @@ -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 diff --git a/spec/provider_spec.rb b/spec/provider_spec.rb index 112f4f4..ecd45c7 100644 --- a/spec/provider_spec.rb +++ b/spec/provider_spec.rb @@ -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 @@ -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)