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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,13 +442,21 @@ You’ll then need to write the provider by implementing the `Provider` duck.

```ruby
class MyProvider
def init
# 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
# 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.
end

# Optional: return true if this provider keeps state for a single domain.
# The SDK then raises ArgumentError if the same instance is bound to a second domain.
def domain_scoped?
false
end

def shutdown
# Perform any shutdown/reclamation steps with flag management system here
# Return value is ignored
Expand Down
118 changes: 94 additions & 24 deletions lib/open_feature/sdk/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ class Configuration
attr_accessor :evaluation_context, :hooks, :transaction_context_propagator
attr_reader :logger

# One per registration; +done+ is closed once init finishes, so waiters can pop on it
InitAttempt = Struct.new(:state, :error, :done)
private_constant :InitAttempt

def initialize
@hooks = []
@providers = {}
@init_attempts = {}.compare_by_identity
@provider_mutex = Mutex.new
@logger = nil
@event_dispatcher = EventDispatcher.new(@logger)
Expand Down Expand Up @@ -118,6 +123,9 @@ def reset
@provider_state_registry.clear
@provider_mutex.synchronize do
@providers.clear
# Release anyone waiting on an init that shutdown cut short
@init_attempts.each_value { |attempt| attempt.done.close }
@init_attempts.clear
end
@hooks.clear
@evaluation_context = nil
Expand All @@ -129,22 +137,29 @@ def set_provider_internal(provider, domain:, wait_for_init:)
context_for_init = @evaluation_context

old_provider = nil
attempt = nil
needs_init = false
init_thread = nil
needs_shutdown = false

# Provider code runs before taking the lock: it may call back into the SDK
scoped = domain_scoped?(provider)

@provider_mutex.synchronize do
validate_domain_scoped_binding!(provider, domain, scoped)

old_provider = @providers[domain]

new_providers = @providers.dup
new_providers[domain] = provider
@providers = new_providers

# Spec 1.1.2.2: Only initialize if the provider is not already active
# (i.e., not already bound to another domain)
already_active = @providers.any? { |d, p| d != domain && p.equal?(provider) && @provider_state_registry.tracked?(p) }
needs_init = !already_active
# Spec 1.1.2.2: initialize once per registration; a failed init is retried on the next set
attempt = @init_attempts[provider]
needs_init = attempt.nil? || attempt.state == :failed

if needs_init
attempt = @init_attempts[provider] = InitAttempt.new(:running, nil, Thread::Queue.new)
@provider_state_registry.set_initial_state(provider)
provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
end
Expand All @@ -153,6 +168,7 @@ def set_provider_internal(provider, domain:, wait_for_init:)
if old_provider && !old_provider.equal?(provider)
needs_shutdown = !provider_bound?(old_provider)
if needs_shutdown
@init_attempts.delete(old_provider)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 95f9d98. When the last binding of a provider is removed, its attempt is now abandoned under the lock: a running attempt is marked failed and its queue closed. reset does the same. A waiter then raises ProviderInitializationError ("initialization did not complete") instead of staying blocked. New test: "replacing a provider releases a waiter on its unfinished init".

@provider_state_registry.remove_provider(old_provider)
old_provider.send(:detach) if old_provider.is_a?(Provider::EventEmitter)
end
Expand All @@ -171,45 +187,65 @@ 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, raise_on_error: true)
init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: true)
else
Thread.new do
init_provider(provider, context_for_init, raise_on_error: false)
init_thread = Thread.new do
init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: false)
end
end
elsif wait_for_init
# Provider already active; no init needed but still dispatch READY
# Already registered: wait for an init still in flight, then report its outcome
attempt.done.pop

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 95f9d98. Each attempt now records the thread running its init. If set_provider_and_wait finds the attempt still running on the current thread, it raises ProviderInitializationError ("is still initializing on this thread") instead of waiting on itself. So it doesn't deadlock, and it never reports an unfinished init as successful. New test: "set_provider_and_wait from inside the provider's own init raises instead of deadlocking", which uses a provider that emits an event from init.

unless attempt.state == :succeeded
raise attempt.error || ProviderInitializationError.new(
"Provider #{provider.class.name} initialization did not complete",
provider:,
error_code: Provider::ErrorCode::GENERAL
)
end

dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
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, 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
def init_provider(provider, context, domain:, attempt:, raise_on_error: false)
call_init(provider, context, domain)

# Settle the attempt before handlers run, so a handler can register this provider again
finish_init_attempt(attempt, :succeeded)
dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
rescue => e
# Spec 1.7.5: PROVIDER_FATAL from init makes the provider FATAL
error_code = EvaluationError.code_for(e)
error = ProviderInitializationError.new(
"Provider #{provider.class.name} initialization failed: #{e.message}",
provider:,
error_code: error_code,
original_error: e
)
finish_init_attempt(attempt, :failed, error)

dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR,
error_code: error_code,
message: e.message)

if raise_on_error
raise ProviderInitializationError.new(
"Provider #{provider.class.name} initialization failed: #{e.message}",
provider:,
error_code: error_code,
original_error: e
)
raise error if raise_on_error
ensure
# Reached unsettled only when init raised something other than a StandardError
finish_init_attempt(attempt, :failed)
end

def finish_init_attempt(attempt, state, error = nil)
@provider_mutex.synchronize do
return unless attempt.state == :running

attempt.state = state
attempt.error = error
end
attempt.done.close
end

def dispatch_provider_event(provider, event_type, details = {})
Expand Down Expand Up @@ -239,6 +275,40 @@ def extract_provider_name(provider)
provider.respond_to?(:metadata) ? provider.metadata.name : provider.class.name
end

def domain_scoped?(provider)
provider.respond_to?(:domain_scoped?) && provider.domain_scoped?
end

def validate_domain_scoped_binding!(provider, domain, scoped)
return unless scoped && provider_bound?(provider) && !@providers[domain].equal?(provider)

raise ArgumentError, "Cannot bind domain-scoped provider to more than one domain"
end

def call_init(provider, context, domain)
return unless provider.respond_to?(:init)

parameters = provider.method(:init).parameters
takes_domain = init_accepts_domain?(parameters)
if !takes_domain && domain_scoped?(provider)
@logger&.warn("Provider #{extract_provider_name(provider)} is domain-scoped but its init doesn't accept domain:")
end

args = init_accepts_evaluation_context?(parameters) ? [context] : []
provider.init(*args, **(takes_domain ? {domain:} : {}))
end

# Anonymous `**` (from `...`) and delegators' bare `*` can't be trusted to accept `domain:`
def init_accepts_domain?(parameters)
parameters.any? do |kind, name|
((kind == :key || kind == :keyreq) && name == :domain) || (kind == :keyrest && name && name != :**)
end
end

def init_accepts_evaluation_context?(parameters)
parameters.any? { |kind, _| kind == :req || kind == :opt || kind == :rest }
end

def run_handlers_for_provider(provider, event_type, event_details)
# Run global handlers (API-level, no domain filtering)
@event_dispatcher.trigger_event(event_type, event_details)
Expand Down
8 changes: 7 additions & 1 deletion sig/open_feature/sdk/configuration.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,15 @@ module OpenFeature

def reset: () -> void
def set_provider_internal: (untyped provider, domain: String?, wait_for_init: bool) -> void
def init_provider: (untyped provider, EvaluationContext? context, ?raise_on_error: bool) -> void
def init_provider: (untyped provider, EvaluationContext? context, domain: String?, attempt: untyped, ?raise_on_error: bool) -> void
def finish_init_attempt: (untyped attempt, Symbol state, ?ProviderInitializationError? error) -> void
def dispatch_provider_event: (untyped provider, String event_type, ?Hash[Symbol, untyped] details) -> void
def provider_bound?: (untyped provider) -> bool
def domain_scoped?: (untyped provider) -> 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
def init_accepts_evaluation_context?: (Array[[Symbol, Symbol?]] parameters) -> bool
def extract_provider_name: (untyped provider) -> String
def run_handlers_for_provider: (untyped provider, String event_type, Hash[Symbol, untyped] event_details) -> void
def run_immediate_handler: (String event_type, _ToProc handler, Client? client) -> void
Expand Down
6 changes: 5 additions & 1 deletion sig/open_feature/sdk/provider.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ module OpenFeature
end

interface _LifecycleProvider
def init: (?EvaluationContext? evaluation_context) -> void
def init: (?EvaluationContext? evaluation_context, ?domain: String?) -> void
def shutdown: () -> void
def metadata: () -> ProviderMetadata
end

interface _DomainScopedProvider
def domain_scoped?: () -> bool
end

interface _TrackableProvider
def track: (String tracking_event_name, ?evaluation_context: EvaluationContext?, ?tracking_event_details: TrackingEventDetails?) -> void
end
Expand Down
128 changes: 128 additions & 0 deletions spec/open_feature/sdk/configuration_spec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "spec_helper"
require "delegate"

RSpec.describe OpenFeature::SDK::Configuration do
subject(:configuration) { described_class.new }
Expand Down Expand Up @@ -191,6 +192,7 @@
it "handles provider that responds_to init but init is nil" do
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(:init).and_return(nil)

configuration.set_provider_and_wait(provider)
Expand Down Expand Up @@ -278,6 +280,132 @@ def metadata

expect(provider.init_called).to be true
end

it "passes domain to init when init accepts evaluation context and domain" do
provider = Class.new do
attr_reader :init_domain, :init_context

def init(evaluation_context = nil, domain: nil)
@init_context = evaluation_context
@init_domain = domain
end

def metadata
OpenFeature::SDK::Provider::ProviderMetadata.new(name: "DomainAwareProvider")
end
end.new

context = OpenFeature::SDK::EvaluationContext.new(targeting_key: "user")
configuration.evaluation_context = context
configuration.set_provider_and_wait(provider, domain: "billing")

expect(provider.init_context).to eq(context)
expect(provider.init_domain).to eq("billing")
end

it "passes domain to init when init only accepts domain" do
provider = Class.new do
attr_reader :init_domain

def init(domain: nil)
@init_domain = domain
end

def metadata
OpenFeature::SDK::Provider::ProviderMetadata.new(name: "DomainOnlyProvider")
end
end.new

configuration.set_provider_and_wait(provider, domain: "billing")

expect(provider.init_domain).to eq("billing")
end

it "passes domain to init when init accepts keyword rest" do
provider = Class.new do
attr_reader :init_kwargs

def init(**kwargs)
@init_kwargs = kwargs
end

def metadata
OpenFeature::SDK::Provider::ProviderMetadata.new(name: "KwargsProvider")
end
end.new

configuration.set_provider_and_wait(provider, domain: "billing")

expect(provider.init_kwargs[:domain]).to eq("billing")
end

context "when the init signature doesn't name the domain" do
let(:context) { OpenFeature::SDK::EvaluationContext.new(targeting_key: "user") }

before { configuration.evaluation_context = context }

def build_provider(&block)
Class.new do
attr_reader :received

def metadata = OpenFeature::SDK::Provider::ProviderMetadata.new(name: "SignatureProvider")

class_eval(&block)
end.new
end

it "passes only the context to init(...) that forwards to a legacy init" do
provider = build_provider do
def init(...) = legacy_init(...)

def legacy_init(evaluation_context) = @received = evaluation_context
end

expect { configuration.set_provider_and_wait(provider, domain: "billing") }.not_to raise_error
expect(provider.received).to eq(context)
end

it "passes only the context to init(*args)" do
provider = build_provider do
def init(*args) = @received = args
end

configuration.set_provider_and_wait(provider, domain: "billing")

expect(provider.received).to eq([context])
end

it "passes the context to init(evaluation_context) defined in an included module" do
legacy_init = Module.new do
def init(evaluation_context) = @received = evaluation_context
end
provider = build_provider { include legacy_init }

configuration.set_provider_and_wait(provider, domain: "billing")

expect(provider.received).to eq(context)
end

it "passes the context through a SimpleDelegator wrapper" do
inner = build_provider do
def init(evaluation_context) = @received = evaluation_context
end

configuration.set_provider_and_wait(SimpleDelegator.new(inner), domain: "billing")

expect(inner.received).to eq(context)
end

it "passes the domain to init(evaluation_context = nil, **opts)" do
provider = build_provider do
def init(evaluation_context = nil, **opts) = @received = [evaluation_context, opts]
end

configuration.set_provider_and_wait(provider, domain: "billing")

expect(provider.received).to eq([context, {domain: "billing"}])
end
end
end

describe "event handler error logging" do
Expand Down
Loading
Loading