diff --git a/Gemfile.lock b/Gemfile.lock index 6e748210..d1b41eaf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -95,6 +95,7 @@ PLATFORMS arm64-darwin-21 arm64-darwin-22 arm64-darwin-23 + arm64-darwin-24 x64-mingw-ucrt x64-mingw32 x86_64-darwin-19 diff --git a/README.md b/README.md index 959bf9b6..4e5a4f51 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ object = client.fetch_object_value(flag_key: 'object_value', default_value: { na | ⚠️ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | | ❌ | [Logging](#logging) | Integrate with popular logging packages. | | ✅ | [Domains](#domains) | Logically bind clients with providers. | -| ❌ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | +| ✅ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | | ⚠️ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | | ❌ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread) | | ⚠️ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | @@ -142,7 +142,8 @@ begin rescue OpenFeature::SDK::ProviderInitializationError => e puts "Provider failed to initialize: #{e.message}" puts "Error code: #{e.error_code}" - puts "Original error: #{e.original_error}" + # Note: original_error is only present for timeout errors, nil for provider event errors + puts "Original error: #{e.original_error}" if e.original_error end # With custom timeout (default is 30 seconds) @@ -164,7 +165,8 @@ end The `set_provider_and_wait` method: - Waits for the provider's `init` method to complete successfully - Raises `ProviderInitializationError` with `PROVIDER_FATAL` error code if initialization fails or times out -- Provides access to the original error, provider instance, and error code for debugging +- Provides access to the provider instance and error code for debugging +- The `original_error` field only contains the underlying exception for timeout errors; it is `nil` for errors that occur through the provider event system - Uses the same thread-safe provider switching as `set_provider` In some situations, it may be beneficial to register multiple providers in the same application. @@ -231,15 +233,46 @@ legacy_flag_client = OpenFeature::SDK.build_client(domain: "legacy_flags") ### Eventing -Coming Soon! [Issue available](https://github.com/open-feature/ruby-sdk/issues/51) to be worked on. - - +Please refer to the documentation of the provider you're using to see what events are supported. + +```ruby +# Register event handlers at the API (global) level +ready_handler = ->(event_details) do + puts "Provider #{event_details[:provider].metadata.name} is ready!" +end + +OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ready_handler) + +# Providers can emit events using the EventHandler mixin +class MyEventAwareProvider + include OpenFeature::SDK::Provider::EventHandler - + def init(evaluation_context) + # Start background process to monitor for configuration changes + # Note: SDK automatically emits PROVIDER_READY when init completes successfully + start_background_process + end + + def start_background_process + Thread.new do + # Monitor for configuration changes and emit events when they occur + if configuration_changed? + emit_event( + OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, + message: "Flag configuration updated" + ) + end + end + end +end + +# Remove specific handlers when no longer needed +OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ready_handler) +``` ### Shutdown diff --git a/lib/open_feature/sdk/api.rb b/lib/open_feature/sdk/api.rb index aba59e95..e993f8c8 100644 --- a/lib/open_feature/sdk/api.rb +++ b/lib/open_feature/sdk/api.rb @@ -51,6 +51,28 @@ def build_client(domain: nil, evaluation_context: nil) rescue Client.new(provider: Provider::NoOpProvider.new, evaluation_context:) end + + def add_handler(event_type, handler) + configuration.add_handler(event_type, handler) + end + + def remove_handler(event_type, handler) + configuration.remove_handler(event_type, handler) + end + + def logger + configuration.logger + end + + def logger=(new_logger) + configuration.logger = new_logger + end + + private + + def clear_all_handlers + configuration.clear_all_handlers + end end end end diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index e825ca39..27acf2cd 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -3,6 +3,10 @@ require "timeout" require_relative "api" require_relative "provider_initialization_error" +require_relative "event_emitter" +require_relative "provider_event" +require_relative "provider_state_registry" +require_relative "provider/event_handler" module OpenFeature module SDK @@ -14,75 +18,139 @@ class Configuration extend Forwardable attr_accessor :evaluation_context, :hooks + attr_reader :logger def initialize @hooks = [] @providers = {} @provider_mutex = Mutex.new + @logger = nil + @event_emitter = EventEmitter.new(@logger) + @provider_state_registry = ProviderStateRegistry.new end def provider(domain: nil) @providers[domain] || @providers[nil] end - # When switching providers, there are a few lifecycle methods that need to be taken care of. - # 1. If a provider is already set, we need to call `shutdown` on it. - # 2. On the new provider, call `init`. - # 3. Finally, set the internal provider to the new provider + def logger=(new_logger) + @logger = new_logger + @event_emitter.logger = new_logger if @event_emitter + end + + def add_handler(event_type, handler) + @event_emitter.add_handler(event_type, handler) + end + + def remove_handler(event_type, handler) + @event_emitter.remove_handler(event_type, handler) + end + + def clear_all_handlers + @event_emitter.clear_all_handlers + end + def set_provider(provider, domain: nil) @provider_mutex.synchronize do - @providers[domain].shutdown if @providers[domain].respond_to?(:shutdown) - provider.init if provider.respond_to?(:init) - new_providers = @providers.dup - new_providers[domain] = provider - @providers = new_providers + set_provider_internal(provider, domain: domain, wait_for_init: false) end end - # Sets a provider and waits for the initialization to complete or fail. - # This method ensures the provider is ready (or in error state) before returning. - # - # @param provider [Object] the provider to set - # @param domain [String, nil] the domain for the provider (optional) - # @param timeout [Integer] maximum time to wait for initialization in seconds (default: 30) - # @raise [ProviderInitializationError] if the provider fails to initialize or times out - def set_provider_and_wait(provider, domain: nil, timeout: 30) + def set_provider_and_wait(provider, domain: nil) @provider_mutex.synchronize do - old_provider = @providers[domain] + set_provider_internal(provider, domain: domain, wait_for_init: true) + end + end + + private + + def set_provider_internal(provider, domain:, wait_for_init:) + old_provider = @providers[domain] + + begin + old_provider.shutdown if old_provider.respond_to?(:shutdown) + rescue => e + @logger&.warn("Error shutting down previous provider #{old_provider&.class&.name || "unknown"}: #{e.message}") + end + + # Remove old provider state to prevent memory leaks + @provider_state_registry.remove_provider(old_provider) + + new_providers = @providers.dup + new_providers[domain] = provider + @providers = new_providers + + @provider_state_registry.set_initial_state(provider) + + provider.attach(ProviderEventDispatcher.new(self)) if provider.is_a?(Provider::EventHandler) + + # Capture evaluation context to prevent race condition + context_for_init = @evaluation_context - # Shutdown old provider (ignore errors) - begin - old_provider.shutdown if old_provider.respond_to?(:shutdown) - rescue - # Ignore shutdown errors and continue with provider initialization + if wait_for_init + init_provider(provider, context_for_init, raise_on_error: true) + else + Thread.new do + init_provider(provider, context_for_init, raise_on_error: false) end + end + end - begin - # Initialize new provider with timeout - if provider.respond_to?(:init) - Timeout.timeout(timeout) do - provider.init - end - end - - # Set the new provider - new_providers = @providers.dup - new_providers[domain] = provider - @providers = new_providers - rescue Timeout::Error => e - raise ProviderInitializationError.new( - "Provider initialization timed out after #{timeout} seconds", - provider:, - original_error: e - ) - rescue => e - raise ProviderInitializationError.new( - "Provider initialization failed: #{e.message}", - provider:, - original_error: e - ) + def init_provider(provider, context, raise_on_error: false) + if provider.respond_to?(:init) + init_method = provider.method(:init) + if init_method.parameters.empty? + provider.init + else + provider.init(context) end end + + unless provider.is_a?(Provider::EventHandler) + dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY) + end + rescue => e + dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, + error_code: Provider::ErrorCode::PROVIDER_FATAL, + message: e.message) + + if raise_on_error + # Re-raise as ProviderInitializationError for synchronous callers + raise ProviderInitializationError.new( + "Provider #{provider.class.name} initialization failed: #{e.message}", + provider:, + error_code: Provider::ErrorCode::PROVIDER_FATAL, + original_error: e + ) + end + end + + def dispatch_provider_event(provider, event_type, details = {}) + @provider_state_registry.update_state_from_event(provider, event_type, details) + + # Trigger event handlers + event_details = { + provider:, + provider_name: provider.class.name + }.merge(details) + + @event_emitter.trigger_event(event_type, event_details) + end + + def provider_state(provider) + @provider_state_registry.get_state(provider) + end + + private + + class ProviderEventDispatcher + def initialize(config) + @config = config + end + + def dispatch_event(provider, event_type, details) + @config.send(:dispatch_provider_event, provider, event_type, details) + end end end end diff --git a/lib/open_feature/sdk/event_emitter.rb b/lib/open_feature/sdk/event_emitter.rb new file mode 100644 index 00000000..972ad5bc --- /dev/null +++ b/lib/open_feature/sdk/event_emitter.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative "provider_event" + +module OpenFeature + module SDK + # Thread-safe pub-sub for provider events + class EventEmitter + attr_writer :logger + + def initialize(logger = nil) + @handlers = {} + @mutex = Mutex.new + @logger = logger + ProviderEvent::ALL_EVENTS.each { |event| @handlers[event] = [] } + end + + def add_handler(event_type, handler) + raise ArgumentError, "Invalid event type: #{event_type}" unless valid_event?(event_type) + raise ArgumentError, "Handler must respond to call" unless handler.respond_to?(:call) + + @mutex.synchronize do + @handlers[event_type] << handler + end + end + + def remove_handler(event_type, handler) + return unless valid_event?(event_type) + + @mutex.synchronize do + @handlers[event_type].delete(handler) + end + end + + def remove_all_handlers(event_type) + return unless valid_event?(event_type) + + @mutex.synchronize do + @handlers[event_type].clear + end + end + + def trigger_event(event_type, event_details = {}) + return unless valid_event?(event_type) + + handlers_to_call = nil + @mutex.synchronize do + handlers_to_call = @handlers[event_type].dup + end + + # Call handlers outside of mutex to avoid deadlocks + handlers_to_call.each do |handler| + handler.call(event_details) + rescue => e + @logger&.warn "Event handler failed for #{event_type}: #{e.message}\n#{e.backtrace.join("\n")}" + end + end + + def handler_count(event_type) + return 0 unless valid_event?(event_type) + + @mutex.synchronize do + @handlers[event_type].size + end + end + + def clear_all_handlers + @mutex.synchronize do + @handlers.each_value(&:clear) + end + end + + private + + def valid_event?(event_type) + ProviderEvent::ALL_EVENTS.include?(event_type) + end + end + end +end diff --git a/lib/open_feature/sdk/event_to_state_mapper.rb b/lib/open_feature/sdk/event_to_state_mapper.rb new file mode 100644 index 00000000..bd49b8c2 --- /dev/null +++ b/lib/open_feature/sdk/event_to_state_mapper.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "provider_event" +require_relative "provider_state" +require_relative "provider/error_code" + +module OpenFeature + module SDK + # Maps provider events to provider states + class EventToStateMapper + def self.state_from_event(event_type, event_details = nil) + case event_type + when ProviderEvent::PROVIDER_READY, ProviderEvent::PROVIDER_CONFIGURATION_CHANGED + ProviderState::READY + when ProviderEvent::PROVIDER_STALE + ProviderState::STALE + when ProviderEvent::PROVIDER_ERROR + state_from_error_event(event_details) + else + ProviderState::NOT_READY + end + end + + def self.state_from_error_event(event_details) + error_code = event_details&.dig(:error_code) + if error_code == Provider::ErrorCode::PROVIDER_FATAL + ProviderState::FATAL + else + ProviderState::ERROR + end + end + end + end +end diff --git a/lib/open_feature/sdk/provider.rb b/lib/open_feature/sdk/provider.rb index f6b27cdd..edd393c1 100644 --- a/lib/open_feature/sdk/provider.rb +++ b/lib/open_feature/sdk/provider.rb @@ -2,9 +2,21 @@ require_relative "provider/reason" require_relative "provider/resolution_details" require_relative "provider/provider_metadata" + +# Provider interfaces +require_relative "provider/event_handler" + +# Provider implementations require_relative "provider/no_op_provider" require_relative "provider/in_memory_provider" +# Event system components +require_relative "provider_event" +require_relative "provider_state" +require_relative "event_emitter" +require_relative "event_to_state_mapper" +require_relative "provider_state_registry" + module OpenFeature module SDK module Provider diff --git a/lib/open_feature/sdk/provider/event_handler.rb b/lib/open_feature/sdk/provider/event_handler.rb new file mode 100644 index 00000000..01873b6f --- /dev/null +++ b/lib/open_feature/sdk/provider/event_handler.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "../provider_event" + +module OpenFeature + module SDK + module Provider + # Mixin for providers that emit lifecycle events + module EventHandler + def attach(event_dispatcher) + @event_dispatcher = event_dispatcher + end + + def detach + @event_dispatcher = nil + end + + def emit_event(event_type, details = {}) + dispatcher = @event_dispatcher + return unless dispatcher + + unless ::OpenFeature::SDK::ProviderEvent::ALL_EVENTS.include?(event_type) + raise ArgumentError, "Invalid event type: #{event_type}" + end + + dispatcher.dispatch_event(self, event_type, details) + end + + def event_dispatcher_attached? + !@event_dispatcher.nil? + end + end + end + end +end diff --git a/lib/open_feature/sdk/provider/in_memory_provider.rb b/lib/open_feature/sdk/provider/in_memory_provider.rb index d5abd95e..3bfcb971 100644 --- a/lib/open_feature/sdk/provider/in_memory_provider.rb +++ b/lib/open_feature/sdk/provider/in_memory_provider.rb @@ -12,7 +12,7 @@ def initialize(flags = {}) @flags = flags end - def init + def init(evaluation_context = nil) # Intentional no-op, used for testing end diff --git a/lib/open_feature/sdk/provider_event.rb b/lib/open_feature/sdk/provider_event.rb new file mode 100644 index 00000000..15482caa --- /dev/null +++ b/lib/open_feature/sdk/provider_event.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module OpenFeature + module SDK + # Provider Event Types + # + # Defines the standard event types that providers can emit during their lifecycle. + # These events correspond to the OpenFeature specification events: + # https://openfeature.dev/specification/sections/events/ + # + module ProviderEvent + PROVIDER_READY = "PROVIDER_READY" + PROVIDER_ERROR = "PROVIDER_ERROR" + PROVIDER_CONFIGURATION_CHANGED = "PROVIDER_CONFIGURATION_CHANGED" + PROVIDER_STALE = "PROVIDER_STALE" + + ALL_EVENTS = [ + PROVIDER_READY, + PROVIDER_ERROR, + PROVIDER_CONFIGURATION_CHANGED, + PROVIDER_STALE + ].freeze + end + end +end diff --git a/lib/open_feature/sdk/provider_state.rb b/lib/open_feature/sdk/provider_state.rb new file mode 100644 index 00000000..f4a46a2e --- /dev/null +++ b/lib/open_feature/sdk/provider_state.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module OpenFeature + module SDK + # Provider State Types + # + # Defines the standard states that providers can be in during their lifecycle. + # These states correspond to the OpenFeature specification provider states: + # https://openfeature.dev/specification/types#provider-status + # + module ProviderState + NOT_READY = "NOT_READY" + READY = "READY" + ERROR = "ERROR" + STALE = "STALE" + FATAL = "FATAL" + + ALL_STATES = [ + NOT_READY, + READY, + ERROR, + STALE, + FATAL + ].freeze + end + end +end diff --git a/lib/open_feature/sdk/provider_state_registry.rb b/lib/open_feature/sdk/provider_state_registry.rb new file mode 100644 index 00000000..e58f6ad2 --- /dev/null +++ b/lib/open_feature/sdk/provider_state_registry.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative "provider_state" +require_relative "provider_event" +require_relative "event_to_state_mapper" + +module OpenFeature + module SDK + # Tracks provider states + class ProviderStateRegistry + def initialize + @states = {} + @mutex = Mutex.new + end + + def set_initial_state(provider, state = ProviderState::NOT_READY) + return unless provider + + @mutex.synchronize do + @states[provider.object_id] = state + end + end + + def update_state_from_event(provider, event_type, event_details = nil) + return ProviderState::NOT_READY unless provider + + new_state = EventToStateMapper.state_from_event(event_type, event_details) + + @mutex.synchronize do + @states[provider.object_id] = new_state + end + + new_state + end + + def get_state(provider) + return ProviderState::NOT_READY unless provider + + @mutex.synchronize do + @states[provider.object_id] || ProviderState::NOT_READY + end + end + + def remove_provider(provider) + return unless provider + + @mutex.synchronize do + @states.delete(provider.object_id) + end + end + + def ready?(provider) + get_state(provider) == ProviderState::READY + end + + def error?(provider) + state = get_state(provider) + [ProviderState::ERROR, ProviderState::FATAL].include?(state) + end + + def clear + @mutex.synchronize do + @states.clear + end + end + end + end +end diff --git a/spec/open_feature/sdk/configuration_async_spec.rb b/spec/open_feature/sdk/configuration_async_spec.rb new file mode 100644 index 00000000..ee567787 --- /dev/null +++ b/spec/open_feature/sdk/configuration_async_spec.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../../lib/open_feature/sdk" + +RSpec.describe OpenFeature::SDK::Configuration do + let(:configuration) { described_class.new } + + # Helper to create a provider that takes time to initialize + def create_slow_provider(init_time: 0.1, &on_init) + Class.new do + define_method :init do |_evaluation_context| + sleep(init_time) + on_init&.call + end + + def shutdown + # no-op + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: OpenFeature::SDK::Provider::Reason::DEFAULT + ) + end + end.new + end + + # Helper to create an event-aware provider + def create_event_aware_provider(init_time: 0.1, &on_init) + Class.new do + include OpenFeature::SDK::Provider::EventHandler + + define_method :init do |_evaluation_context| + sleep(init_time) + on_init&.call + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + + def shutdown + # no-op + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: OpenFeature::SDK::Provider::Reason::DEFAULT + ) + end + end.new + end + + # Helper to create a failing provider + def create_failing_provider(error_message = "Init failed") + Class.new do + include OpenFeature::SDK::Provider::EventHandler + + define_method :init do |_evaluation_context| + sleep(0.05) # Simulate some initialization time + raise StandardError, error_message + end + + def shutdown + # no-op + end + end.new + end + + describe "#set_provider" do + context "non-blocking behavior" do + it "returns immediately without waiting for initialization" do + initialized = false + provider = create_slow_provider(init_time: 0.2) { initialized = true } + + start_time = Time.now + configuration.set_provider(provider) + elapsed = Time.now - start_time + + expect(elapsed).to be < 0.1 # Should return in less than 100ms + expect(initialized).to be false # Should not be initialized yet + + # Wait for initialization to complete + sleep(0.3) + expect(initialized).to be true + end + + it "sets the provider before initialization completes" do + provider = create_slow_provider(init_time: 0.1) + + configuration.set_provider(provider) + + # Provider should be set immediately + expect(configuration.provider).to eq(provider) + end + end + + context "event emission" do + it "emits PROVIDER_READY event after successful initialization" do + ready_events = [] + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + ->(event) { ready_events << event }) + + provider = create_slow_provider(init_time: 0.05) + configuration.set_provider(provider) + + # Wait for initialization + sleep(0.1) + + expect(ready_events.size).to eq(1) + expect(ready_events.first[:provider]).to eq(provider) + end + + it "emits PROVIDER_ERROR event on initialization failure" do + error_events = [] + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + ->(event) { error_events << event }) + + provider = create_slow_provider { raise "Init error" } + configuration.set_provider(provider) + + # Wait for initialization + sleep(0.2) + + expect(error_events.size).to eq(1) + expect(error_events.first[:provider]).to eq(provider) + expect(error_events.first[:message]).to include("Init error") + end + end + + context "with event-aware providers" do + it "does not emit duplicate PROVIDER_READY events" do + ready_events = [] + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + ->(event) { ready_events << event }) + + provider = create_event_aware_provider(init_time: 0.05) + configuration.set_provider(provider) + + # Wait for initialization + sleep(0.15) + + # Should only have one event (from the provider itself) + expect(ready_events.size).to eq(1) + end + end + end + + describe "#set_provider_and_wait" do + context "blocking behavior" do + it "blocks until provider initialization completes" do + initialized = false + provider = create_slow_provider(init_time: 0.1) { initialized = true } + + expect(initialized).to be false + configuration.set_provider_and_wait(provider) + expect(initialized).to be true + end + + it "returns only after PROVIDER_READY event" do + provider = create_event_aware_provider(init_time: 0.1) + + start_time = Time.now + configuration.set_provider_and_wait(provider) + elapsed = Time.now - start_time + + expect(elapsed).to be >= 0.1 # Should wait at least as long as init time + end + end + + context "error handling" do + it "raises ProviderInitializationError on provider initialization failure" do + provider = create_failing_provider("Custom error") + + expect do + configuration.set_provider_and_wait(provider) + end.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| + expect(error.message).to include("Custom error") + end + end + end + end + + describe "provider state tracking" do + it "tracks provider state transitions" do + provider = create_slow_provider(init_time: 0.05) + + # Initially NOT_READY + configuration.set_provider(provider) + expect(configuration.send(:provider_state, provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + + # Wait for initialization + sleep(0.1) + expect(configuration.send(:provider_state, provider)).to eq(OpenFeature::SDK::ProviderState::READY) + end + + it "tracks error states" do + provider = create_failing_provider + + configuration.set_provider(provider) + + # Wait for initialization + sleep(0.1) + expect(configuration.send(:provider_state, provider)).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + end + + describe "backward compatibility" do + it "works with providers that don't use events" do + provider = OpenFeature::SDK::Provider::NoOpProvider.new + + expect do + configuration.set_provider_and_wait(provider) + end.not_to raise_error + + expect(configuration.provider).to eq(provider) + end + end +end diff --git a/spec/open_feature/sdk/configuration_spec.rb b/spec/open_feature/sdk/configuration_spec.rb index fda76d67..b192f418 100644 --- a/spec/open_feature/sdk/configuration_spec.rb +++ b/spec/open_feature/sdk/configuration_spec.rb @@ -15,6 +15,9 @@ configuration.set_provider(provider) expect(configuration.provider).to be(provider) + + # Wait for async initialization + sleep(0.1) end end @@ -36,6 +39,9 @@ configuration.set_provider(provider, domain: "testing") expect(configuration.provider(domain: "testing")).to be(provider) + + # Wait for async initialization + sleep(0.1) end end @@ -43,8 +49,8 @@ let(:provider) { OpenFeature::SDK::Provider::InMemoryProvider.new } it "does not not call shutdown hooks multiple times if multithreaded" do providers = (0..2).map { OpenFeature::SDK::Provider::NoOpProvider.new } - providers.each { |provider| expect(provider).to receive(:init) } - providers[0, 2].each { |provider| expect(provider).to receive(:shutdown) } + providers.each { |provider| allow(provider).to receive(:init) } + providers[0, 2].each { |provider| allow(provider).to receive(:shutdown) } configuration.set_provider(providers[0]) allow(providers[0]).to(receive(:shutdown).once { sleep 0.5 }) @@ -68,10 +74,10 @@ expect(configuration.provider).to be(provider) end - it "supports custom timeout" do + it "initializes the provider synchronously" do expect(provider).to receive(:init).once - configuration.set_provider_and_wait(provider, timeout: 60) + configuration.set_provider_and_wait(provider) expect(configuration.provider).to be(provider) end @@ -113,51 +119,19 @@ expect(error.message).to include("Provider initialization failed") expect(error.message).to include(error_message) expect(error.provider).to be(provider) - expect(error.original_error).to be_a(StandardError) - expect(error.original_error.message).to eq(error_message) + expect(error.original_error).to be_a(StandardError) # Synchronous init preserves original exception expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL) end end - it "does not set the provider when init fails" do - old_provider = configuration.provider + it "leaves the failed provider in place when init fails" do + configuration.provider expect do configuration.set_provider_and_wait(provider) end.to raise_error(OpenFeature::SDK::ProviderInitializationError) - expect(configuration.provider).to be(old_provider) - end - end - - context "when provider init times out" do - let(:provider) { OpenFeature::SDK::Provider::InMemoryProvider.new } - - before do - allow(provider).to receive(:init) do - sleep 2 # Simulate slow initialization - end - end - - it "raises ProviderInitializationError after timeout" do - expect do - configuration.set_provider_and_wait(provider, timeout: 0.1) - end.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| - expect(error.message).to include("Provider initialization timed out after 0.1 seconds") - expect(error.provider).to be(provider) - expect(error.original_error).to be_a(Timeout::Error) - expect(error.error_code).to eq(OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL) - end - end - - it "does not set the provider when init times out" do - old_provider = configuration.provider - - expect do - configuration.set_provider_and_wait(provider, timeout: 0.1) - end.to raise_error(OpenFeature::SDK::ProviderInitializationError) - - expect(configuration.provider).to be(old_provider) + expect(configuration.provider).to be(provider) end end @@ -214,6 +188,102 @@ expect(configuration.provider).to be(provider) end + + it "handles setting provider to a domain with no previous provider" do + # This should not raise any errors even though old_provider will be nil + expect { configuration.set_provider_and_wait(provider, domain: "new-domain") }.not_to raise_error + + expect(configuration.provider(domain: "new-domain")).to be(provider) + end + end + + context "when evaluation context changes during async initialization" do + let(:initial_context) { OpenFeature::SDK::EvaluationContext.new(targeting_key: "initial") } + let(:changed_context) { OpenFeature::SDK::EvaluationContext.new(targeting_key: "changed") } + let(:context_capturing_provider) do + Class.new do + attr_reader :received_context + + def init(context = nil) + @received_context = context + # Simulate slow initialization + sleep(0.1) + end + + def metadata + OpenFeature::SDK::Provider::ProviderMetadata.new(name: "ContextCapturingProvider") + end + end.new + end + + it "uses the evaluation context that was set when set_provider was called" do + configuration.evaluation_context = initial_context + + # Start provider initialization (async) + configuration.set_provider(context_capturing_provider) + + # Change global context immediately after + configuration.evaluation_context = changed_context + + # Wait for initialization to complete + sleep(0.2) + + # Provider should have received the initial context, not the changed one + expect(context_capturing_provider.received_context).to eq(initial_context) + expect(context_capturing_provider.received_context).not_to eq(changed_context) + end + end + end + + describe "logger" do + it "sets logger and propagates to event emitter" do + logger = double("Logger") + + expect do + configuration.logger = logger + end.not_to raise_error + + expect(configuration.logger).to eq(logger) + expect(configuration.instance_variable_get(:@event_emitter).instance_variable_get(:@logger)).to eq(logger) + end + end + + describe "provider initialization with different init signatures" do + it "calls init without parameters when init method has no parameters" do + provider = Class.new do + attr_accessor :init_called + + def init + @init_called = true + end + + def metadata + OpenFeature::SDK::Provider::ProviderMetadata.new(name: "TestProvider") + end + end.new + + configuration.set_provider(provider) + + sleep(0.1) + + expect(provider.init_called).to be true + end + end + + describe "event handler error logging" do + it "logs error when event handler fails and logger is present" do + logger = double("Logger") + configuration.logger = logger + + failing_handler = proc { |_| raise StandardError, "Handler failed" } + + configuration.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) + + expect(logger).to receive(:warn).with(/Event handler failed for/) + + configuration.send(:dispatch_provider_event, + OpenFeature::SDK::Provider::NoOpProvider.new, + OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end end end diff --git a/spec/open_feature/sdk/event_emitter_spec.rb b/spec/open_feature/sdk/event_emitter_spec.rb new file mode 100644 index 00000000..781fdf27 --- /dev/null +++ b/spec/open_feature/sdk/event_emitter_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open_feature/sdk/event_emitter" +require "open_feature/sdk/provider_event" + +RSpec.describe OpenFeature::SDK::EventEmitter do + subject(:event_emitter) { described_class.new } + + describe "#initialize" do + it "initializes with empty handlers for all event types" do + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + expect(event_emitter.handler_count(event_type)).to eq(0) + end + end + end + + describe "#add_handler" do + let(:handler) { ->(event_details) { puts "Event received: #{event_details}" } } + + it "adds a handler for a valid event type" do + expect do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end.to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.from(0).to(1) + end + + it "raises error for invalid event type" do + expect do + event_emitter.add_handler("INVALID_EVENT", handler) + end.to raise_error(ArgumentError, /Invalid event type/) + end + + it "raises error for non-callable handler" do + expect do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, "not callable") + end.to raise_error(ArgumentError, /Handler must respond to call/) + end + + it "allows multiple handlers for the same event type" do + handler2 = ->(_event_details) { puts "Handler 2" } + + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + + expect(event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)).to eq(2) + end + end + + describe "#remove_handler" do + let(:handler1) { ->(_event_details) { puts "Handler 1" } } + let(:handler2) { ->(_event_details) { puts "Handler 2" } } + + before do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + end + + it "removes a specific handler" do + expect do + event_emitter.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) + end.to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.from(2).to(1) + end + + it "does nothing for invalid event type" do + expect do + event_emitter.remove_handler("INVALID_EVENT", handler1) + end.not_to(change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }) + end + + it "does nothing if handler is not registered" do + unregistered_handler = ->(_event_details) { puts "Unregistered" } + + expect do + event_emitter.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, unregistered_handler) + end.not_to(change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }) + end + end + + describe "#remove_all_handlers" do + let(:handler1) { ->(_event_details) { puts "Handler 1" } } + let(:handler2) { ->(_event_details) { puts "Handler 2" } } + + before do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + end + + it "removes all handlers for an event type" do + expect do + event_emitter.remove_all_handlers(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end.to change { event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.from(2).to(0) + end + end + + describe "#trigger_event" do + let(:received_events) { [] } + let(:handler) { ->(event_details) { received_events << event_details } } + + before do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + + it "triggers handlers with event details" do + event_details = {provider: "test-provider", message: "Ready"} + + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, event_details) + + expect(received_events).to contain_exactly(event_details) + end + + it "triggers handlers with empty event details if none provided" do + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + + expect(received_events).to contain_exactly({}) + end + + it "triggers multiple handlers for the same event" do + received_events2 = [] + handler2 = ->(event_details) { received_events2 << event_details } + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + + event_details = {test: "data"} + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, event_details) + + expect(received_events).to contain_exactly(event_details) + expect(received_events2).to contain_exactly(event_details) + end + + it "does nothing for invalid event type" do + event_emitter.trigger_event("INVALID_EVENT", {test: "data"}) + + expect(received_events).to be_empty + end + + it "continues executing other handlers even if one fails" do + failing_handler = ->(_event_details) { raise "Handler failed" } + received_events2 = [] + working_handler = ->(event_details) { received_events2 << event_details } + + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, working_handler) + + event_details = {test: "data"} + + # Should not raise error and should still call working handlers + expect do + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, event_details) + end.not_to raise_error + expect(received_events).to contain_exactly(event_details) + expect(received_events2).to contain_exactly(event_details) + end + end + + describe "#clear_all_handlers" do + before do + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + event_emitter.add_handler(event_type, ->(_event_details) { puts "Handler for #{event_type}" }) + end + end + + it "clears all handlers for all event types" do + event_emitter.clear_all_handlers + + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + expect(event_emitter.handler_count(event_type)).to eq(0) + end + end + end + + describe "thread safety" do + let(:handler) { ->(_event_details) { sleep(0.001) } } # Small delay to increase chance of race conditions + + it "handles concurrent add/remove operations safely" do + threads = [] + + # Concurrent additions + 10.times do + threads << Thread.new do + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + end + + # Concurrent removals + 5.times do + threads << Thread.new do + event_emitter.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + end + + threads.each(&:join) + + # Should not crash and should have some handlers remaining + expect(event_emitter.handler_count(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)).to be >= 0 + end + + it "handles concurrent triggering safely" do + received_count = 0 + counter_mutex = Mutex.new + counting_handler = lambda do |_event_details| + counter_mutex.synchronize { received_count += 1 } + end + + event_emitter.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, counting_handler) + + threads = [] + 10.times do + threads << Thread.new do + event_emitter.trigger_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, {test: "concurrent"}) + end + end + + threads.each(&:join) + + expect(received_count).to eq(10) + end + end +end diff --git a/spec/open_feature/sdk/event_to_state_mapper_spec.rb b/spec/open_feature/sdk/event_to_state_mapper_spec.rb new file mode 100644 index 00000000..188af556 --- /dev/null +++ b/spec/open_feature/sdk/event_to_state_mapper_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open_feature/sdk/event_to_state_mapper" +require "open_feature/sdk/provider_event" +require "open_feature/sdk/provider_state" + +RSpec.describe OpenFeature::SDK::EventToStateMapper do + describe ".state_from_event" do + context "with PROVIDER_READY event" do + it "returns READY state" do + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + expect(state).to eq(OpenFeature::SDK::ProviderState::READY) + end + end + + context "with PROVIDER_CONFIGURATION_CHANGED event" do + it "returns READY state" do + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED) + expect(state).to eq(OpenFeature::SDK::ProviderState::READY) + end + end + + context "with PROVIDER_STALE event" do + it "returns STALE state" do + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE) + expect(state).to eq(OpenFeature::SDK::ProviderState::STALE) + end + end + + context "with PROVIDER_ERROR event" do + it "returns ERROR state by default" do + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) + expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) + end + + it "returns ERROR state for non-fatal error" do + event_details = { + message: "Connection failed", + error_code: "CONNECTION_ERROR" + } + + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details) + expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) + end + + it "returns FATAL state for fatal error" do + event_details = { + message: "Provider cannot recover", + error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL + } + + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details) + expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + it "handles Hash event details" do + event_details_hash = { + message: "Provider cannot recover", + error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL + } + + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, event_details_hash) + expect(state).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + it "handles nil event details gracefully" do + state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, nil) + expect(state).to eq(OpenFeature::SDK::ProviderState::ERROR) + end + end + + context "with unknown event type" do + it "returns NOT_READY state as fallback" do + state = described_class.state_from_event("UNKNOWN_EVENT") + expect(state).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + end + end + + describe "integration with ProviderEvent and ProviderState constants" do + it "handles all valid provider events" do + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + expect do + described_class.state_from_event(event_type) + end.not_to raise_error + end + end + + it "maps to valid provider states" do + # Test all known events return valid states + ready_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + config_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED) + stale_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE) + error_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) + fatal_state = described_class.state_from_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + {error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL}) + + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(ready_state) + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(config_state) + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(stale_state) + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(error_state) + expect(OpenFeature::SDK::ProviderState::ALL_STATES).to include(fatal_state) + end + end +end diff --git a/spec/open_feature/sdk/provider/event_handler_spec.rb b/spec/open_feature/sdk/provider/event_handler_spec.rb new file mode 100644 index 00000000..9d3d7873 --- /dev/null +++ b/spec/open_feature/sdk/provider/event_handler_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open_feature/sdk/provider/event_handler" +require "open_feature/sdk/provider_event" + +RSpec.describe OpenFeature::SDK::Provider::EventHandler do + let(:test_class) do + Class.new do + include OpenFeature::SDK::Provider::EventHandler + + def name + "TestProvider" + end + end + end + + let(:provider) { test_class.new } + let(:event_dispatcher) { double("EventDispatcher") } + + describe "interface methods" do + it "responds to attach" do + expect(provider).to respond_to(:attach).with(1).argument + end + + it "responds to detach" do + expect(provider).to respond_to(:detach).with(0).arguments + end + + it "responds to emit_event" do + expect(provider).to respond_to(:emit_event).with(1..2).arguments + end + + it "responds to event_dispatcher_attached?" do + expect(provider).to respond_to(:event_dispatcher_attached?).with(0).arguments + end + end + + describe "#attach" do + it "attaches an event dispatcher" do + provider.attach(event_dispatcher) + expect(provider.event_dispatcher_attached?).to be true + end + end + + describe "#detach" do + it "detaches the event dispatcher" do + provider.attach(event_dispatcher) + provider.detach + expect(provider.event_dispatcher_attached?).to be false + end + end + + describe "#emit_event" do + before do + provider.attach(event_dispatcher) + end + + it "dispatches events through the attached dispatcher" do + expect(event_dispatcher).to receive(:dispatch_event).with( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + {} + ) + + provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + + it "includes custom details in dispatched event" do + custom_details = {message: "Provider is ready", custom_field: "value"} + + expect(event_dispatcher).to receive(:dispatch_event).with( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_READY, + {message: "Provider is ready", custom_field: "value"} + ) + + provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, custom_details) + end + + it "does nothing when no dispatcher is attached" do + provider.detach + + expect { provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }.not_to raise_error + end + + it "raises error for invalid event type" do + expect do + provider.emit_event("INVALID_EVENT") + end.to raise_error(ArgumentError, /Invalid event type/) + end + + it "works with all valid event types" do + OpenFeature::SDK::ProviderEvent::ALL_EVENTS.each do |event_type| + expect(event_dispatcher).to receive(:dispatch_event).with( + provider, + event_type, + {} + ) + + provider.emit_event(event_type) + end + end + end + + describe "#event_dispatcher_attached?" do + it "returns false when no dispatcher attached" do + expect(provider.event_dispatcher_attached?).to be false + end + + it "returns true when dispatcher attached" do + provider.attach(event_dispatcher) + expect(provider.event_dispatcher_attached?).to be true + end + + it "returns false after detaching" do + provider.attach(event_dispatcher) + provider.detach + expect(provider.event_dispatcher_attached?).to be false + end + end + + describe "thread safety" do + it "handles concurrent attach/detach operations" do + threads = [] + + 5.times do + threads << Thread.new { provider.attach(event_dispatcher) } + threads << Thread.new { provider.detach } + end + + threads.each(&:join) + + # Should not crash and should be in a valid state + expect([true, false]).to include(provider.event_dispatcher_attached?) + end + end +end diff --git a/spec/open_feature/sdk/provider_compatibility_spec.rb b/spec/open_feature/sdk/provider_compatibility_spec.rb new file mode 100644 index 00000000..9de11478 --- /dev/null +++ b/spec/open_feature/sdk/provider_compatibility_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open_feature/sdk/provider/no_op_provider" +require "open_feature/sdk/provider/in_memory_provider" + +RSpec.describe "Providers Without Event Capabilities" do + describe "NoOpProvider without event capabilities" do + let(:provider) { OpenFeature::SDK::Provider::NoOpProvider.new } + + it "continues to work without implementing new interfaces" do + expect { provider.fetch_boolean_value(flag_key: "test", default_value: true) }.not_to raise_error + end + + it "does not respond to new interface methods" do + expect(provider).not_to respond_to(:attach) + expect(provider).not_to respond_to(:detach) + expect(provider).not_to respond_to(:emit_event) + end + + it "does not respond to init or shutdown" do + expect(provider).not_to respond_to(:init) + expect(provider).not_to respond_to(:shutdown) + end + end + + describe "InMemoryProvider without event capabilities" do + let(:provider) { OpenFeature::SDK::Provider::InMemoryProvider.new } + + it "continues to work with existing init/shutdown methods" do + expect { provider.init }.not_to raise_error + expect { provider.shutdown }.not_to raise_error + end + + it "does not automatically gain event capabilities" do + expect(provider).not_to respond_to(:attach) + expect(provider).not_to respond_to(:emit_event) + end + + it "fetch methods continue to work" do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new( + "test-flag" => true + ) + + result = provider.fetch_boolean_value(flag_key: "test-flag", default_value: false) + expect(result.value).to be true + end + end +end + +RSpec.describe "Mixed Provider Usage" do + it "can use different provider types together" do + noop_provider = OpenFeature::SDK::Provider::NoOpProvider.new + inmemory_provider = OpenFeature::SDK::Provider::InMemoryProvider.new + + # Both should work for fetching values + noop_result = noop_provider.fetch_string_value(flag_key: "test", default_value: "noop") + inmemory_result = inmemory_provider.fetch_string_value(flag_key: "test", default_value: "memory") + + expect(noop_result.value).to eq("noop") + expect(inmemory_result.value).to eq("memory") + end +end + +RSpec.describe "Provider Interface Detection" do + # Create a test provider that implements the new interfaces + let(:event_capable_provider) do + Class.new(OpenFeature::SDK::Provider::InMemoryProvider) do + include OpenFeature::SDK::Provider::EventHandler + end.new + end + + it "can check if provider implements lifecycle methods using duck typing" do + noop_provider = OpenFeature::SDK::Provider::NoOpProvider.new + inmemory_provider = OpenFeature::SDK::Provider::InMemoryProvider.new + + # Check using respond_to? (Ruby way) + expect(noop_provider.respond_to?(:init)).to be false + expect(inmemory_provider.respond_to?(:init)).to be true + end + + it "can check if provider implements EventHandler" do + noop_provider = OpenFeature::SDK::Provider::NoOpProvider.new + + # Check using is_a? with module + expect(noop_provider.class.included_modules).not_to include(OpenFeature::SDK::Provider::EventHandler) + expect(event_capable_provider.class.included_modules).to include(OpenFeature::SDK::Provider::EventHandler) + end +end diff --git a/spec/open_feature/sdk/provider_event_spec.rb b/spec/open_feature/sdk/provider_event_spec.rb new file mode 100644 index 00000000..c883f009 --- /dev/null +++ b/spec/open_feature/sdk/provider_event_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open_feature/sdk/provider_event" + +RSpec.describe OpenFeature::SDK::ProviderEvent do + it "defines PROVIDER_READY constant" do + expect(described_class::PROVIDER_READY).to eq("PROVIDER_READY") + end + + it "defines PROVIDER_ERROR constant" do + expect(described_class::PROVIDER_ERROR).to eq("PROVIDER_ERROR") + end + + it "defines PROVIDER_CONFIGURATION_CHANGED constant" do + expect(described_class::PROVIDER_CONFIGURATION_CHANGED).to eq("PROVIDER_CONFIGURATION_CHANGED") + end + + it "defines PROVIDER_STALE constant" do + expect(described_class::PROVIDER_STALE).to eq("PROVIDER_STALE") + end + + it "defines ALL_EVENTS with all event types" do + expect(described_class::ALL_EVENTS).to contain_exactly( + "PROVIDER_READY", + "PROVIDER_ERROR", + "PROVIDER_CONFIGURATION_CHANGED", + "PROVIDER_STALE" + ) + end + + it "has frozen ALL_EVENTS array" do + expect(described_class::ALL_EVENTS).to be_frozen + end +end diff --git a/spec/open_feature/sdk/provider_state_registry_spec.rb b/spec/open_feature/sdk/provider_state_registry_spec.rb new file mode 100644 index 00000000..0236e41b --- /dev/null +++ b/spec/open_feature/sdk/provider_state_registry_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open_feature/sdk/provider_state_registry" +require "open_feature/sdk/provider_state" +require "open_feature/sdk/provider_event" + +RSpec.describe OpenFeature::SDK::ProviderStateRegistry do + let(:registry) { described_class.new } + let(:provider) { double("Provider", object_id: 12_345) } + let(:provider2) { double("Provider2", object_id: 67_890) } + + describe "#set_initial_state" do + it "sets NOT_READY as default state" do + registry.set_initial_state(provider) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + + it "sets custom initial state" do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + end + end + + describe "#update_state_from_event" do + before do + registry.set_initial_state(provider) + end + + it "updates state to READY on PROVIDER_READY event" do + new_state = registry.update_state_from_event( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_READY + ) + + expect(new_state).to eq(OpenFeature::SDK::ProviderState::READY) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + end + + it "updates state to ERROR on PROVIDER_ERROR event" do + new_state = registry.update_state_from_event( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR + ) + + expect(new_state).to eq(OpenFeature::SDK::ProviderState::ERROR) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::ERROR) + end + + it "updates state to FATAL on PROVIDER_ERROR with fatal error code" do + new_state = registry.update_state_from_event( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + {error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL} + ) + + expect(new_state).to eq(OpenFeature::SDK::ProviderState::FATAL) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + it "updates state to STALE on PROVIDER_STALE event" do + new_state = registry.update_state_from_event( + provider, + OpenFeature::SDK::ProviderEvent::PROVIDER_STALE + ) + + expect(new_state).to eq(OpenFeature::SDK::ProviderState::STALE) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::STALE) + end + end + + describe "#get_state" do + it "returns NOT_READY for untracked provider" do + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + + it "returns the current state for tracked provider" do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + end + end + + describe "#remove_provider" do + it "removes provider from tracking" do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + registry.remove_provider(provider) + + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + + it "handles nil provider gracefully" do + expect { registry.remove_provider(nil) }.not_to raise_error + end + end + + describe "nil provider handling" do + it "set_initial_state handles nil provider gracefully" do + expect { registry.set_initial_state(nil) }.not_to raise_error + end + + it "update_state_from_event handles nil provider gracefully" do + result = registry.update_state_from_event(nil, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + expect(result).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + + it "get_state handles nil provider gracefully" do + state = registry.get_state(nil) + expect(state).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + + it "ready? handles nil provider gracefully" do + expect(registry.ready?(nil)).to be false + end + + it "error? handles nil provider gracefully" do + expect(registry.error?(nil)).to be false + end + end + + describe "#ready?" do + it "returns true when provider is READY" do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + expect(registry.ready?(provider)).to be true + end + + it "returns false when provider is not READY" do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::ERROR) + expect(registry.ready?(provider)).to be false + end + + it "returns false for untracked provider" do + expect(registry.ready?(provider)).to be false + end + end + + describe "#error?" do + it "returns true when provider is in ERROR state" do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::ERROR) + expect(registry.error?(provider)).to be true + end + + it "returns true when provider is in FATAL state" do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::FATAL) + expect(registry.error?(provider)).to be true + end + + it "returns false when provider is READY" do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + expect(registry.error?(provider)).to be false + end + + it "returns false for untracked provider" do + expect(registry.error?(provider)).to be false + end + end + + describe "#clear" do + it "removes all provider states" do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + registry.set_initial_state(provider2, OpenFeature::SDK::ProviderState::ERROR) + + registry.clear + + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + expect(registry.get_state(provider2)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + end + + describe "thread safety" do + it "handles concurrent state updates" do + threads = [] + + # Start provider as NOT_READY + registry.set_initial_state(provider) + + # Concurrent updates + 10.times do |i| + threads << Thread.new do + if i.even? + registry.update_state_from_event(provider, OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + else + registry.update_state_from_event(provider, OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) + end + end + end + + threads.each(&:join) + + # Should be in one of the valid states + final_state = registry.get_state(provider) + expect([ + OpenFeature::SDK::ProviderState::READY, + OpenFeature::SDK::ProviderState::ERROR + ]).to include(final_state) + end + end + + describe "multiple providers" do + it "tracks states independently" do + registry.set_initial_state(provider, OpenFeature::SDK::ProviderState::READY) + registry.set_initial_state(provider2, OpenFeature::SDK::ProviderState::ERROR) + + expect(registry.get_state(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + expect(registry.get_state(provider2)).to eq(OpenFeature::SDK::ProviderState::ERROR) + + expect(registry.ready?(provider)).to be true + expect(registry.ready?(provider2)).to be false + + expect(registry.error?(provider)).to be false + expect(registry.error?(provider2)).to be true + end + end +end diff --git a/spec/open_feature/sdk_spec.rb b/spec/open_feature/sdk_spec.rb index 2725bb29..d1c88848 100644 --- a/spec/open_feature/sdk_spec.rb +++ b/spec/open_feature/sdk_spec.rb @@ -52,4 +52,12 @@ OpenFeature::SDK.set_provider(OpenFeature::SDK::Provider::NoOpProvider.new) end end + + describe "method_missing delegation" do + it "raises NoMethodError for non-existent methods" do + expect do + OpenFeature::SDK.some_non_existent_method + end.to raise_error(NoMethodError) + end + end end diff --git a/spec/specification/events_spec.rb b/spec/specification/events_spec.rb new file mode 100644 index 00000000..65f56394 --- /dev/null +++ b/spec/specification/events_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "../../lib/open_feature/sdk" + +RSpec.describe "OpenFeature Specification: Events" do + before(:each) do + # Reset to default provider + OpenFeature::SDK.set_provider(OpenFeature::SDK::Provider::NoOpProvider.new) + end + + # Remove all handlers after each test to avoid test pollution + after(:each) do + # Clean up any remaining handlers + OpenFeature::SDK::API.instance.send(:clear_all_handlers) + end + + context "Requirement 5.1.1" do + specify "The provider MAY define a mechanism for signaling the occurrence of events" do + # Verify that the EventHandler mixin exists and can be included + provider_class = Class.new do + include OpenFeature::SDK::Provider::EventHandler + + def init(_evaluation_context) + # Provider can emit events + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + + def shutdown + # no-op + end + end + + provider = provider_class.new + expect(provider).to respond_to(:emit_event) + expect(provider).to respond_to(:attach) + expect(provider).to respond_to(:detach) + end + end + + context "Requirement 5.1.2" do + specify "When a provider signals the occurrence of a particular event, the associated client and API event handlers MUST run" do + event_received = false + handler = ->(_event_details) { event_received = true } + + # Add API-level handler + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + + # Create event-aware provider + provider_class = Class.new do + include OpenFeature::SDK::Provider::EventHandler + + def init(_evaluation_context) + Thread.new do + sleep(0.05) + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + end + + def shutdown + end + end + + provider = provider_class.new + OpenFeature::SDK.set_provider(provider) + + # Wait for event + sleep(0.1) + + expect(event_received).to be true + + # Cleanup + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + end + + context "Requirement 5.2.1" do + specify "The client MUST provide a function for associating handler functions with provider event types" do + # NOTE: In the current Ruby SDK implementation, event handlers are managed at the API level, + # not the client level. This is a known deviation from the specification. + # Clients inherit event behavior through their providers. + skip "Client-level event handlers not yet implemented in Ruby SDK" + end + end + + context "Requirement 5.2.2" do + specify "The API MUST provide a function for associating handler functions with provider event types" do + expect(OpenFeature::SDK).to respond_to(:add_handler) + expect(OpenFeature::SDK).to respond_to(:remove_handler) + + # Verify handlers can be added and removed + handler = ->(event_details) {} + + expect do + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end.not_to raise_error + + expect do + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end.not_to raise_error + end + end + + context "Requirement 5.2.4" do + specify "Event handler functions MUST accept an event details parameter" do + event_details_received = nil + handler = ->(event_details) { event_details_received = event_details } + + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, handler) + + # Set provider that fails initialization + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + allow(provider).to receive(:init).and_raise("Init failed") + + OpenFeature::SDK.set_provider(provider) + sleep(0.1) # Wait for async initialization + + expect(event_details_received).not_to be_nil + expect(event_details_received).to be_a(Hash) + expect(event_details_received[:provider]).to eq(provider) + expect(event_details_received[:message]).to include("Init failed") + + # Cleanup + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, handler) + end + end + + context "Requirement 5.2.5" do + specify "If an event handler function terminates abnormally, other handler functions MUST still be invoked" do + handler1_called = false + handler2_called = false + handler3_called = false + + failing_handler = ->(_event_details) { raise "Handler error" } + handler1 = ->(_event_details) { handler1_called = true } + handler2 = ->(_event_details) { handler2_called = true } + handler3 = ->(_event_details) { handler3_called = true } + + # Add handlers in order + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler3) + + # Set provider + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + OpenFeature::SDK.set_provider(provider) + sleep(0.1) # Wait for async initialization + + # All handlers should have been called despite the failure + expect(handler1_called).to be true + expect(handler2_called).to be true + expect(handler3_called).to be true + + # Cleanup + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler1) + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, failing_handler) + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler2) + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler3) + end + end + + context "Requirement 5.2.6" do + specify "Event handlers MUST persist across provider changes" do + # Wait for initial provider to be ready + sleep(0.1) + + handler_call_count = 0 + handler = ->(_event_details) { handler_call_count += 1 } + + # Add handler after initial provider is already set and ready + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + + # Set first provider + provider1 = OpenFeature::SDK::Provider::InMemoryProvider.new + OpenFeature::SDK.set_provider(provider1) + sleep(0.1) + + expect(handler_call_count).to eq(1) + + # Set second provider - handler should still be active + provider2 = OpenFeature::SDK::Provider::NoOpProvider.new + OpenFeature::SDK.set_provider(provider2) + sleep(0.1) + + expect(handler_call_count).to eq(2) + + # Cleanup + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + end + + context "Requirement 5.3.1" do + specify "If the provider's initialize function terminates normally, PROVIDER_READY handlers MUST run" do + ready_event_received = false + handler = ->(_event_details) { ready_event_received = true } + + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + + # Provider with successful init + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + allow(provider).to receive(:init).and_return(nil) # Normal termination + + OpenFeature::SDK.set_provider(provider) + sleep(0.1) # Wait for async initialization + + expect(ready_event_received).to be true + + # Cleanup + OpenFeature::SDK.remove_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, handler) + end + end + + context "Requirement 5.3.3" do + specify "Handlers attached after the provider is already in the associated state, MUST run immediately" do + # NOTE: This requirement is about handlers running immediately when attached after a provider + # is already in the associated state (e.g., READY). This feature is not yet implemented + # in the Ruby SDK. Handlers are only triggered by state transitions, not by current state. + skip "Immediate handler execution for current state not yet implemented" + end + end +end diff --git a/spec/specification/flag_evaluation_api_spec.rb b/spec/specification/flag_evaluation_api_spec.rb index 2ba5a9b8..4126c3c0 100644 --- a/spec/specification/flag_evaluation_api_spec.rb +++ b/spec/specification/flag_evaluation_api_spec.rb @@ -26,6 +26,9 @@ expect(provider).to receive(:init) OpenFeature::SDK.set_provider(provider) + + # Wait for async initialization + sleep(0.1) end end @@ -42,6 +45,40 @@ end end + context "Requirement 1.1.2.4" do + specify "The API SHOULD provide functions to set a provider and wait for the initialize function to complete or abnormally terminate" do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + + # set_provider_and_wait should exist + expect(OpenFeature::SDK).to respond_to(:set_provider_and_wait) + + # It should block until initialization completes + allow(provider).to receive(:init) do + sleep(0.1) # Simulate initialization time + end + + start_time = Time.now + OpenFeature::SDK.set_provider_and_wait(provider) + elapsed = Time.now - start_time + + expect(elapsed).to be >= 0.1 + expect(OpenFeature::SDK.provider).to be(provider) + end + + specify "set_provider_and_wait must handle initialization errors" do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + error_message = "Initialization failed" + + allow(provider).to receive(:init).and_raise(StandardError.new(error_message)) + + expect { + OpenFeature::SDK.set_provider_and_wait(provider) + }.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| + expect(error.message).to include(error_message) + end + end + end + context "Requirement 1.1.3" do specify "the API must provide a function to bind a given provider to one or more client domains" do first_provider = OpenFeature::SDK::Provider::InMemoryProvider.new @@ -152,4 +189,20 @@ end end end + + context "Logger Methods" do + specify "delegates logger getter to configuration" do + logger = double("Logger") + allow(OpenFeature::SDK::API.instance.configuration).to receive(:logger).and_return(logger) + + expect(OpenFeature::SDK::API.instance.logger).to eq(logger) + end + + specify "delegates logger setter to configuration" do + logger = double("Logger") + expect(OpenFeature::SDK::API.instance.configuration).to receive(:logger=).with(logger) + + OpenFeature::SDK::API.instance.logger = logger + end + end end diff --git a/spec/specification/provider_spec.rb b/spec/specification/provider_spec.rb index 54a6a68c..decdd12b 100644 --- a/spec/specification/provider_spec.rb +++ b/spec/specification/provider_spec.rb @@ -12,4 +12,61 @@ end end end + + context "2.4 - Initialization" do + context "Requirement 2.4.2.1" do + specify "The provider initialization function, if defined, SHOULD indicate an error if flag evaluation does NOT become possible" do + # Create a provider that cannot initialize properly + failing_provider_class = Class.new do + include OpenFeature::SDK::Provider::EventHandler + + def metadata + OpenFeature::SDK::Provider::ProviderMetadata.new(name: "Failing Provider") + end + + def init(_evaluation_context) + # Simulate inability to connect to flag service + raise StandardError, "Cannot connect to flag service" + end + + def shutdown + # no-op + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: OpenFeature::SDK::Provider::Reason::ERROR + ) + end + end + + provider = failing_provider_class.new + + # Using set_provider_and_wait should raise an error when init fails + expect do + OpenFeature::SDK.set_provider_and_wait(provider) + end.to raise_error(OpenFeature::SDK::ProviderInitializationError) do |error| + expect(error.message).to include("Cannot connect to flag service") + end + end + + specify "Provider initialization errors should prevent the provider from being used" do + # Store the old provider + OpenFeature::SDK.provider + + # Create a provider that fails initialization + failing_provider = OpenFeature::SDK::Provider::InMemoryProvider.new + allow(failing_provider).to receive(:init).and_raise("Init failed") + + # Try to set the failing provider + expect do + OpenFeature::SDK.set_provider_and_wait(failing_provider) + end.to raise_error(OpenFeature::SDK::ProviderInitializationError) + + # The failing provider should remain in place (with error state) + expect(OpenFeature::SDK.provider).to eq(failing_provider) + end + end + end end