From 0d1d20d416e50f430ab482e4da6878a3ec82d611 Mon Sep 17 00:00:00 2001 From: Jose Colella Date: Fri, 23 Oct 2026 10:21:10 -0700 Subject: [PATCH] feat: let providers own their lifecycle events Providers can opt in to emitting their own PROVIDER_READY/PROVIDER_ERROR by defining emits_lifecycle_events? returning true (spec 2.8, Appendix E). The SDK then sends no synthetic lifecycle events for them and takes their status from their own events. - Providers with init but no marker keep the SDK-emitted events, now deprecated, and log a warning once per class. - Providers without init are READY when set_provider returns, and each new binding runs that domain's READY handlers (2.8.5.1). - InMemoryProvider drops its no-op init. - Events a provider emits from its own init record status when sent, so set_provider_and_wait from its handler returns with it. Signed-off-by: Jose Colella Co-Authored-By: Claude Opus 5.5 --- README.md | 26 ++- features/step_definitions/evaluation_steps.rb | 24 +-- features/support/test_flags_provider.rb | 24 ++- lib/open_feature/sdk/configuration.rb | 54 +++++- .../sdk/provider/in_memory_provider.rb | 4 - sig/open_feature/sdk/configuration.rbs | 4 + .../sdk/provider/in_memory_provider.rbs | 1 - spec/open_feature/sdk/configuration_spec.rb | 1 + .../sdk/provider_compatibility_spec.rb | 7 +- .../domain_scoped_provider_spec.rb | 2 +- spec/specification/provider_spec.rb | 177 ++++++++++++++++++ 11 files changed, 271 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index b7ff73b8..4362ccd2 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,11 @@ legacy_flag_client = OpenFeature::SDK.build_client(domain: "legacy_flags") ### Eventing Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions. -Initialization events (`PROVIDER_READY` on success, `PROVIDER_ERROR` on failure) are dispatched for every provider. +Providers emit initialization events (`PROVIDER_READY` on success, `PROVIDER_ERROR` on failure) from `init`. +A provider without `init` is `READY` as soon as it's registered, and its `PROVIDER_READY` handlers run before `set_provider` returns. +For providers that define `init` but not `emits_lifecycle_events?` returning `true`, the SDK emits `PROVIDER_READY` after `init` returns and `PROVIDER_ERROR` if it raises. +That path is deprecated (spec Appendix E), and the SDK logs a deprecation warning once per provider class to its configured logger. +Handlers for events a provider emits from `init` run while `init` is still in progress, so they can't call `set_provider_and_wait` for that same provider. Some providers emit additional events: `PROVIDER_CONFIGURATION_CHANGED`, `PROVIDER_STALE`, `PROVIDER_RECONCILING` and `PROVIDER_CONTEXT_CHANGED`. Handlers receive a hash with the `provider_name` and any details the provider sent, such as `message` or `error_code`. @@ -314,15 +318,17 @@ end OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ready_handler) -# The SDK automatically emits lifecycle events. Providers can emit additional spontaneous events -# using the EventEmitter mixin to signal internal state changes like configuration updates. +# Providers emit events using the EventEmitter mixin: lifecycle events from init, and spontaneous +# events to signal internal state changes like configuration updates. class MyEventAwareProvider include OpenFeature::SDK::Provider::EventEmitter + def emits_lifecycle_events? = true + 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 + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end def start_background_process @@ -446,13 +452,23 @@ You’ll then need to write the provider by implementing the `Provider` duck. ```ruby class MyProvider + include OpenFeature::SDK::Provider::EventEmitter + + # Tells the SDK that init emits PROVIDER_READY/PROVIDER_ERROR itself. + def emits_lifecycle_events? = true + + # Optional: a provider without init is READY as soon as it's registered. # Both arguments are optional; declare only the ones you need. # `domain` is the domain the provider was bound to, or nil for the default provider. def init(evaluation_context = nil, domain: nil) - # Perform any initialization steps with flag management system here + # Perform any initialization steps with flag management system here, then emit PROVIDER_READY. # Return value is ignored # **Note** The OpenFeature spec defines a lifecycle method called `initialize` to be called when a new provider is set. # To avoid conflicting with the Ruby `initialize` method, this method should be named `init` when creating a provider. + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + rescue => e + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, message: e.message) + raise end # Optional: return true if this provider keeps state for a single domain. diff --git a/features/step_definitions/evaluation_steps.rb b/features/step_definitions/evaluation_steps.rb index 78037f90..28232826 100644 --- a/features/step_definitions/evaluation_steps.rb +++ b/features/step_definitions/evaluation_steps.rb @@ -3,28 +3,8 @@ # -- Provider setup steps -- Given(/^a (stable|not ready|error|fatal|stale) provider$/) do |status| - if status == "stable" - @provider = CachingInMemoryProvider.new(build_test_flags(TEST_FLAGS)) - OpenFeature::SDK.set_provider_and_wait(@provider) - else - provider = StatusProvider.new - OpenFeature::SDK.set_provider_and_wait(provider) - - state = case status - when "not ready" then OpenFeature::SDK::ProviderState::NOT_READY - when "error" then OpenFeature::SDK::ProviderState::ERROR - when "fatal" then OpenFeature::SDK::ProviderState::FATAL - when "stale" then OpenFeature::SDK::ProviderState::STALE - end - - registry = OpenFeature::SDK.configuration.send(:instance_variable_get, :@provider_state_registry) - registry.send(:instance_variable_get, :@mutex).synchronize do - states = registry.send(:instance_variable_get, :@states) - states[provider.object_id] = {state: state, details: {}} - end - - @provider = provider - end + @provider = (status == "stable") ? CachingInMemoryProvider.new(build_test_flags(TEST_FLAGS)) : StatusProvider.new(status) + OpenFeature::SDK.set_provider_and_wait(@provider) @client = OpenFeature::SDK.build_client end diff --git a/features/support/test_flags_provider.rb b/features/support/test_flags_provider.rb index 65860b40..1aceb5c6 100644 --- a/features/support/test_flags_provider.rb +++ b/features/support/test_flags_provider.rb @@ -74,10 +74,6 @@ def initialize @last_context = nil end - def init(evaluation_context = nil) - # no-op - end - def shutdown # no-op end @@ -93,16 +89,32 @@ def shutdown end end -# A provider that stays in a specified state for provider status tests. +# A provider that reaches a specified state for provider status tests, through the event its init emits. class StatusProvider + include OpenFeature::SDK::Provider::EventEmitter + NAME = "Status Provider" + # Shortcut for the fixture: error/fatal emit ERROR and return normally instead of raising (2.8.3) + STATUS_EVENTS = { + "error" => [OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, {}], + "fatal" => [OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, {error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL}], + "stale" => [OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, {}] + }.freeze attr_reader :metadata - def initialize(name: NAME) + def initialize(status, name: NAME) + @status = status @metadata = OpenFeature::SDK::Provider::ProviderMetadata.new(name: name).freeze end + def emits_lifecycle_events? = true + + def init(_evaluation_context = nil) + event_type, details = STATUS_EVENTS[@status] + emit_event(event_type, details) if event_type + end + %w[boolean string number integer float object].each do |type| define_method(:"fetch_#{type}_value") do |flag_key:, default_value:, evaluation_context: nil| OpenFeature::SDK::Provider::ResolutionDetails.new( diff --git a/lib/open_feature/sdk/configuration.rb b/lib/open_feature/sdk/configuration.rb index 67e8010a..22a4acb9 100644 --- a/lib/open_feature/sdk/configuration.rb +++ b/lib/open_feature/sdk/configuration.rb @@ -28,6 +28,7 @@ def initialize @hooks = [] @providers = {} @init_attempts = {}.compare_by_identity + @legacy_warned = Set.new.compare_by_identity @provider_mutex = Mutex.new @logger = nil @event_dispatcher = EventDispatcher.new(@logger) @@ -128,6 +129,7 @@ def reset @providers.clear @init_attempts.each_value { |attempt| abandon_init_attempt(attempt) } @init_attempts.clear + @legacy_warned.clear end @hooks.clear @evaluation_context = nil @@ -146,6 +148,9 @@ def set_provider_internal(provider, domain:, wait_for_init:) # Provider code runs before taking the lock: it may call back into the SDK scoped = domain_scoped?(provider) + has_init = provider.respond_to?(:init) + legacy = has_init && !emits_lifecycle_events?(provider) + warn_legacy_provider(provider) if legacy @provider_mutex.synchronize do validate_domain_scoped_binding!(provider, domain, scoped) @@ -188,24 +193,28 @@ def set_provider_internal(provider, domain:, wait_for_init:) # Initialize provider outside the mutex to avoid blocking other operations if needs_init - if wait_for_init - init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: true) + # Spec 2.8.5.1: a provider without init is READY, and its handlers have run, when set_provider returns + if wait_for_init || !has_init + init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: wait_for_init) # Replaced or shut down while init ran: fail like the waiters on this attempt did - raise incomplete_init_error(provider, "initialization did not complete") unless attempt.state == :succeeded + raise incomplete_init_error(provider, "initialization did not complete") if wait_for_init && attempt.state != :succeeded else init_thread = Thread.new do init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: false) end end + elsif !has_init + # Spec 2.8.5.1: each new binding of a provider without init runs its domain's READY handlers + dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) unless old_provider.equal?(provider) elsif wait_for_init # Already registered: wait for an init still in flight, then report its outcome. # From inside that init (e.g. a handler of an event it emits) the wait could never end. - raise incomplete_init_error(provider, "is still initializing on this thread") if attempt.state == :running && attempt.thread.equal?(Thread.current) + raise incomplete_init_error(provider, "is still initializing on this thread") if running_on_this_thread?(attempt) attempt.done.pop raise attempt.error || incomplete_init_error(provider, "initialization did not complete") unless attempt.state == :succeeded - dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) + dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) if legacy end ensure # Fail an attempt no init took over (e.g. a Timeout hit first), or it would stay running forever @@ -213,12 +222,14 @@ def set_provider_internal(provider, domain:, wait_for_init:) end def init_provider(provider, context, domain:, attempt:, raise_on_error: false) + emits_lifecycle_events = emits_lifecycle_events?(provider) attempt.thread = Thread.current call_init(provider, context, domain) # Settle the attempt before handlers run, so a handler can register this provider again. # An abandoned attempt (provider replaced or shut down meanwhile) reports no READY. - dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) if finish_init_attempt(attempt, :succeeded) + # Spec 2.8.2/2.8.3: a provider that emits its own lifecycle events gets no synthetic READY or ERROR + dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) if finish_init_attempt(attempt, :succeeded) && !emits_lifecycle_events rescue => e # Spec 1.7.5: PROVIDER_FATAL from init makes the provider FATAL error_code = EvaluationError.code_for(e) @@ -230,8 +241,10 @@ def init_provider(provider, context, domain:, attempt:, raise_on_error: false) ) finish_init_attempt(attempt, :failed, error) - dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, - {error_code: error_code, message: e.message}, token: attempt) + unless emits_lifecycle_events + dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR, + {error_code: error_code, message: e.message}, token: attempt) + end raise error if raise_on_error ensure @@ -264,8 +277,9 @@ def finish_init_attempt(attempt, state, error = nil) # An event for a provider already dispatching on this call stack is queued behind the current one def dispatch_provider_event(provider, event_type, details = {}, token: nil) - # The SDK's lifecycle events (with a token) record status when sent, so set_provider_and_wait returns - # with it even from the provider's own handler (1.1.2.4); provider events record it when delivered (5.3.5) + # Lifecycle events (the SDK's own, or the provider's from its init thread) record status when sent, so + # set_provider_and_wait returns with it even from the provider's own handler (1.1.2.4); others record it when delivered (5.3.5) + token ||= running_init_attempt_on_this_thread(provider) return if token && !accept_provider_event(provider, event_type, details, token) queues = (Thread.current[@dispatch_key] ||= {}.compare_by_identity) @@ -324,6 +338,26 @@ def domain_scoped?(provider) provider.respond_to?(:domain_scoped?) && provider.domain_scoped? end + # Spec 2.8.5.1: without init there's nothing to report, so the marker only counts alongside init + def emits_lifecycle_events?(provider) + provider.respond_to?(:init) && provider.respond_to?(:emits_lifecycle_events?) && provider.emits_lifecycle_events? + end + + def running_init_attempt_on_this_thread(provider) + attempt = @provider_mutex.synchronize { @init_attempts[provider] } + attempt if attempt && running_on_this_thread?(attempt) + end + + def running_on_this_thread?(attempt) + attempt.state == :running && attempt.thread.equal?(Thread.current) + end + + def warn_legacy_provider(provider) + return if @logger.nil? || !@provider_mutex.synchronize { @legacy_warned.add?(provider.class) } + @logger.warn("Provider #{extract_provider_name(provider)} relies on SDK-emitted lifecycle events, which are deprecated; " \ + "emit PROVIDER_READY/PROVIDER_ERROR from init and define emits_lifecycle_events? returning true (spec Appendix E)") + end + def validate_domain_scoped_binding!(provider, domain, scoped) return unless scoped && provider_bound?(provider) && !@providers[domain].equal?(provider) diff --git a/lib/open_feature/sdk/provider/in_memory_provider.rb b/lib/open_feature/sdk/provider/in_memory_provider.rb index 21886ef2..b177e370 100644 --- a/lib/open_feature/sdk/provider/in_memory_provider.rb +++ b/lib/open_feature/sdk/provider/in_memory_provider.rb @@ -19,10 +19,6 @@ def initialize(flags = {}) @flags = flags end - def init(evaluation_context = nil) - # Intentional no-op, used for testing - end - def shutdown # Intentional no-op, used for testing end diff --git a/sig/open_feature/sdk/configuration.rbs b/sig/open_feature/sdk/configuration.rbs index 7b15c2b0..67b05f20 100644 --- a/sig/open_feature/sdk/configuration.rbs +++ b/sig/open_feature/sdk/configuration.rbs @@ -41,6 +41,10 @@ module OpenFeature def accept_provider_event: (untyped provider, String event_type, Hash[Symbol, untyped]? details, untyped token, ?record_status: bool) -> bool def provider_bound?: (untyped provider) -> bool def domain_scoped?: (untyped provider) -> bool + def emits_lifecycle_events?: (untyped provider) -> bool + def warn_legacy_provider: (untyped provider) -> void + def running_init_attempt_on_this_thread: (untyped provider) -> untyped + def running_on_this_thread?: (untyped attempt) -> bool def validate_domain_scoped_binding!: (untyped provider, String? domain, bool scoped) -> void def call_init: (untyped provider, EvaluationContext? context, String? domain) -> void def init_accepts_domain?: (Array[[Symbol, Symbol?]] parameters) -> bool diff --git a/sig/open_feature/sdk/provider/in_memory_provider.rbs b/sig/open_feature/sdk/provider/in_memory_provider.rbs index a415c0b4..31481465 100644 --- a/sig/open_feature/sdk/provider/in_memory_provider.rbs +++ b/sig/open_feature/sdk/provider/in_memory_provider.rbs @@ -20,7 +20,6 @@ module OpenFeature def initialize: (?Hash[String, untyped] flags) -> void - def init: (?EvaluationContext? evaluation_context) -> void def shutdown: () -> void def add_flag: (flag_key: String, value: untyped) -> void diff --git a/spec/open_feature/sdk/configuration_spec.rb b/spec/open_feature/sdk/configuration_spec.rb index 35ed2f43..36dc82af 100644 --- a/spec/open_feature/sdk/configuration_spec.rb +++ b/spec/open_feature/sdk/configuration_spec.rb @@ -193,6 +193,7 @@ allow(provider).to receive(:respond_to?).with(:init).and_return(true) allow(provider).to receive(:respond_to?).with(:metadata).and_call_original allow(provider).to receive(:respond_to?).with(:domain_scoped?).and_call_original + allow(provider).to receive(:respond_to?).with(:emits_lifecycle_events?).and_call_original allow(provider).to receive(:init).and_return(nil) configuration.set_provider_and_wait(provider) diff --git a/spec/open_feature/sdk/provider_compatibility_spec.rb b/spec/open_feature/sdk/provider_compatibility_spec.rb index a80a6093..12300f6b 100644 --- a/spec/open_feature/sdk/provider_compatibility_spec.rb +++ b/spec/open_feature/sdk/provider_compatibility_spec.rb @@ -27,8 +27,7 @@ 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 + it "continues to work with its existing shutdown method" do expect { provider.shutdown }.not_to raise_error end @@ -68,8 +67,8 @@ 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 + expect(noop_provider.respond_to?(:shutdown)).to be false + expect(inmemory_provider.respond_to?(:shutdown)).to be true end it "can check if provider implements EventEmitter" do diff --git a/spec/specification/domain_scoped_provider_spec.rb b/spec/specification/domain_scoped_provider_spec.rb index 0905c84a..7b997a1e 100644 --- a/spec/specification/domain_scoped_provider_spec.rb +++ b/spec/specification/domain_scoped_provider_spec.rb @@ -114,7 +114,7 @@ def init(_evaluation_context = nil, domain: nil) specify "allows a non-domain-scoped provider to back multiple domains" do provider = OpenFeature::SDK::Provider::InMemoryProvider.new - allow(provider).to receive(:init).and_call_original + allow(provider).to receive(:init) OpenFeature::SDK.set_provider_and_wait(provider, domain: "domain-a") OpenFeature::SDK.set_provider_and_wait(provider, domain: "domain-b") diff --git a/spec/specification/provider_spec.rb b/spec/specification/provider_spec.rb index 8fa43b8b..22e178cc 100644 --- a/spec/specification/provider_spec.rb +++ b/spec/specification/provider_spec.rb @@ -183,4 +183,181 @@ def init(_evaluation_context = nil) end end end + + context "2.8 - Provider status" do + before { OpenFeature::SDK.configuration.send(:reset) } + after { OpenFeature::SDK.configuration.send(:reset) } + + def marker_provider(&body) + Class.new(OpenFeature::SDK::Provider::InMemoryProvider) do + define_method(:emits_lifecycle_events?) { true } + define_method(:init) { |_evaluation_context = nil| instance_exec(&body) } + end.new + end + + def calls_for(event_type) + calls = [] + OpenFeature::SDK.add_handler(event_type, ->(details) { calls << details }) + calls + end + + def state_of(provider) = OpenFeature::SDK.configuration.provider_state(provider) + + context "Requirement 2.8.2 / 2.8.3" do + specify "2.8.2: the SDK doesn't synthesize READY for a provider that emits its own" do + ready_calls = calls_for(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + provider = marker_provider { emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) } + + OpenFeature::SDK.set_provider_and_wait(provider) + + expect(ready_calls.size).to eq(1) + expect(state_of(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + end + + specify "2.8.3: the SDK doesn't synthesize ERROR for a provider that emits its own" do + error_calls = calls_for(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR) + provider = marker_provider do + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, error_code: OpenFeature::SDK::Provider::ErrorCode::PROVIDER_FATAL) + raise "init failed" + end + + expect { OpenFeature::SDK.set_provider_and_wait(provider) }.to raise_error(OpenFeature::SDK::ProviderInitializationError) + expect(error_calls.size).to eq(1) + expect(state_of(provider)).to eq(OpenFeature::SDK::ProviderState::FATAL) + end + + specify "Appendix E: a marker provider's status comes only from its events" do + provider = marker_provider {} + + OpenFeature::SDK.set_provider_and_wait(provider) + + expect(state_of(provider)).to eq(OpenFeature::SDK::ProviderState::NOT_READY) + end + end + + context "Condition 2.8.5" do + specify "2.8.5.1: a provider without init is READY when set_provider returns" do + provider = OpenFeature::SDK::Provider::NoOpProvider.new + expect(Thread).not_to receive(:new) + + OpenFeature::SDK.set_provider(provider) + + expect(state_of(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + end + + specify "2.8.5.1: READY handlers run for a provider without init" do + ready_calls = calls_for(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + expect(Thread).not_to receive(:new) + + OpenFeature::SDK.set_provider(OpenFeature::SDK::Provider::InMemoryProvider.new) + + expect(ready_calls.size).to eq(1) + end + + specify "InMemoryProvider has no init" do + expect(OpenFeature::SDK::Provider::InMemoryProvider.new).not_to respond_to(:init) + end + end + + context "Appendix E: legacy, marker and init-less providers" do + let(:legacy_class) { Class.new(OpenFeature::SDK::Provider::InMemoryProvider) { def init(_evaluation_context = nil) = nil } } + + after { OpenFeature::SDK.configuration.logger = nil } + + specify "Appendix E: a legacy provider logs a deprecation warning once per class" do + logger = spy("Logger") + OpenFeature::SDK.configuration.logger = logger + + OpenFeature::SDK.set_provider_and_wait(legacy_class.new, domain: "a") + OpenFeature::SDK.set_provider_and_wait(legacy_class.new, domain: "b") + OpenFeature::SDK.set_provider_and_wait(marker_provider { emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) }, domain: "c") + OpenFeature::SDK.set_provider_and_wait(OpenFeature::SDK::Provider::InMemoryProvider.new, domain: "d") + + expect(logger).to have_received(:warn).with(/deprecated/).once + end + + specify "Appendix E: a legacy provider still gets synthetic READY" do + ready_calls = calls_for(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + + OpenFeature::SDK.set_provider_and_wait(legacy_class.new) + + expect(ready_calls.size).to eq(1) + end + + specify "re-binding a marker provider to another domain sends no READY" do + ready_calls = calls_for(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + provider = marker_provider { emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) } + + OpenFeature::SDK.set_provider_and_wait(provider, domain: "a") + expect(ready_calls.size).to eq(1) + OpenFeature::SDK.set_provider_and_wait(provider, domain: "b") + + expect(ready_calls.size).to eq(1) + end + + specify "2.8.5.1: binding a provider without init to another domain runs that domain's READY handlers" do + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + domain_b_ready = 0 + OpenFeature::SDK.build_client(domain: "b").add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ->(_details) { domain_b_ready += 1 }) + + OpenFeature::SDK.set_provider(provider, domain: "a") + OpenFeature::SDK.set_provider(provider, domain: "b") + + expect(domain_b_ready).to eq(1) + end + + specify "re-binding a provider without init to the same domain sends no duplicate READY" do + ready_calls = calls_for(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + provider = OpenFeature::SDK::Provider::InMemoryProvider.new + + OpenFeature::SDK.set_provider_and_wait(provider) + OpenFeature::SDK.set_provider_and_wait(provider) + + expect(ready_calls.size).to eq(1) + end + + specify "2.8.5.1: a provider without init is READY even if it declares the marker" do + ready_calls = calls_for(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + provider = Class.new(OpenFeature::SDK::Provider::NoOpProvider) { def emits_lifecycle_events? = true }.new + + OpenFeature::SDK.set_provider(provider) + + expect(state_of(provider)).to eq(OpenFeature::SDK::ProviderState::READY) + expect(ready_calls.size).to eq(1) + end + + specify "1.1.2.4: re-initializing a marker provider from one of its own handlers returns with it READY" do + outcomes = Queue.new + provider = marker_provider do + if outcomes.pop == :fail + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, message: "init failed") + raise "init failed" + end + emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + outcomes << :fail + expect { OpenFeature::SDK.set_provider_and_wait(provider) }.to raise_error(OpenFeature::SDK::ProviderInitializationError) + state_after_retry = nil + OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, lambda do |_details| + OpenFeature::SDK.set_provider_and_wait(provider) + state_after_retry = state_of(provider) + end) + outcomes << :ok + + provider.emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE) + + expect(state_after_retry).to eq(OpenFeature::SDK::ProviderState::READY) + end + + specify "re-binding a legacy provider to another domain still sends READY" do + ready_calls = calls_for(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + provider = legacy_class.new + + OpenFeature::SDK.set_provider_and_wait(provider, domain: "a") + OpenFeature::SDK.set_provider_and_wait(provider, domain: "b") + + expect(ready_calls.size).to eq(2) + end + end + end end