Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 2 additions & 22 deletions features/step_definitions/evaluation_steps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 18 additions & 6 deletions features/support/test_flags_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,6 @@ def initialize
@last_context = nil
end

def init(evaluation_context = nil)
# no-op
end

def shutdown
# no-op
end
Expand All @@ -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(
Expand Down
54 changes: 44 additions & 10 deletions lib/open_feature/sdk/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -188,37 +193,43 @@ 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)
Comment on lines +206 to +208
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
finish_init_attempt(attempt, :failed) if needs_init && !init_thread
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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +282 to 283

queues = (Thread.current[@dispatch_key] ||= {}.compare_by_identity)
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 0 additions & 4 deletions lib/open_feature/sdk/provider/in_memory_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions sig/open_feature/sdk/configuration.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion sig/open_feature/sdk/provider/in_memory_provider.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spec/open_feature/sdk/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 3 additions & 4 deletions spec/open_feature/sdk/provider_compatibility_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion spec/specification/domain_scoped_provider_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading