diff --git a/README.md b/README.md index 466bc84..dc78a48 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 `nil`; 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..2cabc2e 100644 --- a/lib/ldclient-openfeature/impl/event_listeners.rb +++ b/lib/ldclient-openfeature/impl/event_listeners.rb @@ -25,14 +25,14 @@ def initialize(provider) def update(status) case status.state when ::LaunchDarkly::Interfaces::DataSource::Status::VALID - @provider.emit_event(::OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + @provider.emit_status_event(::OpenFeature::SDK::ProviderEvent::PROVIDER_READY) when ::LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED - @provider.emit_event( + @provider.emit_status_event( ::OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, message: message(status, "the data source has been interrupted") ) when ::LaunchDarkly::Interfaces::DataSource::Status::OFF - @provider.emit_event( + @provider.emit_status_event( ::OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, error_code: ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, message: message(status, "the data source has been permanently shut down") @@ -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..b4ea7fc 100644 --- a/lib/ldclient-openfeature/provider.rb +++ b/lib/ldclient-openfeature/provider.rb @@ -33,36 +33,85 @@ class Provider # # @param sdk_key [String] # @param config [LaunchDarkly::Config] - # @param wait_for_seconds [Float] + # @param wait_for_seconds [Float, nil] the number of seconds to wait for the client to become ready, or nil to + # wait for it without a deadline # 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) + @client = LaunchDarkly::LDClient.new(sdk_key, config.with_wrapper_information(WRAPPER_NAME, VERSION), wait_for_seconds.nil? ? 0 : wait_for_seconds) + @wait_for_seconds = wait_for_seconds + @status_lock = Mutex.new + @status_event = nil + @initialization_complete = false @logger = config.logger @context_converter = Impl::EvaluationContextConverter.new(config.logger) @details_converter = Impl::ResolutionDetailsConverter.new @metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: "launchdarkly-openfeature-server").freeze - @client.data_source_status_provider.add_listener(Impl::DataSourceStatusListener.new(self)) - @client.flag_tracker.add_listener(Impl::FlagChangeListener.new(self)) + @status_listener = Impl::DataSourceStatusListener.new(self) + @flag_change_listener = Impl::FlagChangeListener.new(self) + + @client.data_source_status_provider.add_listener(@status_listener) + @client.flag_tracker.add_listener(@flag_change_listener) 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 wait time has already been applied by the LaunchDarkly client constructor, so this reports whether that + # succeeded. A nil wait time 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) - return if @client.initialized? + wait_for_data_source_outcome if @wait_for_seconds.nil? + + initialized = @client.initialized? + + @status_lock.synchronize do + # The OpenFeature SDK emits its own event for the outcome of initialization, so the status is recorded + # here without emitting an event. + @status_event = if initialized + ::OpenFeature::SDK::ProviderEvent::PROVIDER_READY + else + ::OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR + end + @initialization_complete = true + end + + return if initialized state = @client.data_source_status_provider.status.state raise "the LaunchDarkly client was unable to initialize; the data source state is #{state}" end + # + # Emit a provider event for a data source state change. + # + # A state which does not change the provider status is not emitted, and neither is the change which completes + # initialization, because the OpenFeature SDK emits its own event for that one. Changes after initialization has + # completed, including after it has failed, are emitted. + # + # @param event [String] + # @param details [Hash] + # + # @return [void] + # + def emit_status_event(event, **details) + @status_lock.synchronize do + return if event == @status_event + + @status_event = event + return unless @initialization_complete + end + + emit_event(event, **details) + end + # # Called by the OpenFeature SDK when this provider is replaced or the SDK is shut down. The LaunchDarkly client # cannot be restarted, so a new provider instance is required afterward. @@ -70,6 +119,8 @@ def init(_evaluation_context = nil) # @return [void] # def shutdown + @client.data_source_status_provider.remove_listener(@status_listener) + @client.flag_tracker.remove_listener(@flag_change_listener) @client.close end @@ -170,6 +221,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/impl/data_source_status_listener_spec.rb b/spec/impl/data_source_status_listener_spec.rb index a879a30..9ce1303 100644 --- a/spec/impl/data_source_status_listener_spec.rb +++ b/spec/impl/data_source_status_listener_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe LaunchDarkly::OpenFeature::Impl::DataSourceStatusListener do - let(:provider) { double(emit_event: nil) } + let(:provider) { double(emit_status_event: nil) } let(:listener) { described_class.new(provider) } def status(state, error = nil) @@ -11,13 +11,13 @@ def status(state, error = nil) it "a valid data source is ready" do listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::VALID)) - expect(provider).to have_received(:emit_event).with(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + expect(provider).to have_received(:emit_status_event).with(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end it "an interrupted data source is stale" do listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED)) - expect(provider).to have_received(:emit_event) + expect(provider).to have_received(:emit_status_event) .with(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, hash_including(:message)) end @@ -28,7 +28,7 @@ def status(state, error = nil) listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error)) - expect(provider).to have_received(:emit_event).with( + expect(provider).to have_received(:emit_status_event).with( OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, hash_including(error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, message: /401/) ) @@ -37,6 +37,6 @@ def status(state, error = nil) it "an initializing data source does not emit an event" do listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING)) - expect(provider).not_to have_received(:emit_event) + expect(provider).not_to have_received(:emit_status_event) end end diff --git a/spec/provider_spec.rb b/spec/provider_spec.rb index 112f4f4..773e0a8 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,18 +52,98 @@ 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 "shutdown closes the client" do - allow(provider.client).to receive(:close) + it "init without a deadline waits for the data source to become valid" do + indefinite_provider = described_class.new("example-key", config, nil) + + expect { indefinite_provider.init(evaluation_context) }.not_to raise_error + expect(indefinite_provider.client.initialized?).to be(true) + end + + it "init without a deadline waits for a data source outcome which arrives later" do + indefinite_provider = described_class.new("example-key", config, nil) + 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(indefinite_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 { indefinite_provider.init(evaluation_context) }.to raise_error(/unable to initialize/) + + reporter.join + end + + it "init with a wait time of zero does not wait" do + zero_wait_provider = described_class.new("example-key", config, 0) + status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING)) + allow(status_provider).to receive(:add_listener) + allow(zero_wait_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider) + + expect { zero_wait_provider.init(evaluation_context) }.to raise_error(/unable to initialize/) + expect(status_provider).not_to have_received(:add_listener) + 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 "a status change before initialization completes is not emitted" do + allow(provider).to receive(:emit_event) + + provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + + expect(provider).not_to have_received(:emit_event) + end + + it "a status change after initialization failed is emitted" do + 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/) + allow(provider).to receive(:emit_event) + + provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + + expect(provider).to have_received(:emit_event).with(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + + it "a status which does not change is not emitted" do + provider.init(evaluation_context) + allow(provider).to receive(:emit_event) + + provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, message: "interrupted") + provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, message: "interrupted again") + + expect(provider).to have_received(:emit_event).once + expect(provider).to have_received(:emit_event) + .with(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, hash_including(:message)) + end + + it "shutdown unsubscribes the listeners and closes the client" do + status_provider = double(remove_listener: nil) + flag_tracker = double(remove_listener: nil) + allow(provider.client).to receive_messages(close: nil, data_source_status_provider: status_provider, flag_tracker: flag_tracker) provider.shutdown + expect(status_provider).to have_received(:remove_listener).with(instance_of(LaunchDarkly::OpenFeature::Impl::DataSourceStatusListener)) + expect(flag_tracker).to have_received(:remove_listener).with(instance_of(LaunchDarkly::OpenFeature::Impl::FlagChangeListener)) expect(provider.client).to have_received(:close) end