openfeature-sdk 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93f85fdb9eb64b634a3267d0987415f3e4b9033a9d864e80478dfde83074fa7d
4
- data.tar.gz: 14e94971fdae29a56d31db9d8ee44716b805b6783e0d381ac04e6348168ccf67
3
+ metadata.gz: 1b8b6e8a5fd01205278fa44c809eb3c1ef578b56e5542aa94793a090e605458d
4
+ data.tar.gz: f85c7d0e8b804fb651e02cc3c59d14c21458f9fdb419d58df6d8fbb714a35b9b
5
5
  SHA512:
6
- metadata.gz: c1e726bae4617d91ed82f676270bb51b7355e8694fac68a76af5f8ea046ed60c33d4c31b0a716827ad61edffe2849c3eb444c614975ac8a09707dcf620286c03
7
- data.tar.gz: 47d740d86cac2c493058c0f35aa6c2489d89f0502e8df3eb9ba850585777934478fe2dcf500b7eac9b71c96cfb2c857bc6026e9d1214e7876bc385788fcf6f18
6
+ metadata.gz: a7fef199498fb02863ed74cf76a6fbff8acf65df23e2e5d4baad90b6ed3da589b6ca98da1540443a7367456a527819eb2380979aa4f1a1ef8780c33ccf425d32
7
+ data.tar.gz: 71db2db0d85592861ebd5f117d7599e9d54374deb9a6eae5060716044abddb27a4bd10db7ca07314540a36adeb7c366afa1a8c21fb4f19824e05e15fd59a1a27
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.6.7"
2
+ ".": "0.7.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/open-feature/ruby-sdk/compare/v0.6.7...v0.7.0) (2026-10-23)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * resolve a client's provider by domain on every call ([#352](https://github.com/open-feature/ruby-sdk/issues/352))
9
+
10
+ ### Features
11
+
12
+ * let providers own their lifecycle events ([#353](https://github.com/open-feature/ruby-sdk/issues/353)) ([15faf98](https://github.com/open-feature/ruby-sdk/commit/15faf98d1c900118da362f6b8d76ccdec1b3b99e))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * catch NotImplementedError in track and the hook lifecycle ([#349](https://github.com/open-feature/ruby-sdk/issues/349)) ([62759f4](https://github.com/open-feature/ruby-sdk/commit/62759f443fa8cb2f7a1b67720397240063623da1))
18
+ * deliver provider events in order, outside handler locks ([#351](https://github.com/open-feature/ruby-sdk/issues/351)) ([16bf89c](https://github.com/open-feature/ruby-sdk/commit/16bf89cf72541c22de9565f0dcdbfcf9dfe097ad))
19
+ * release init waiters on replacement and same-thread re-entry ([#348](https://github.com/open-feature/ruby-sdk/issues/348)) ([9e82b15](https://github.com/open-feature/ruby-sdk/commit/9e82b15e9730f0c53f2186aeb39cc53275d5f929))
20
+ * resolve a client's provider by domain on every call ([#352](https://github.com/open-feature/ruby-sdk/issues/352)) ([fa0076f](https://github.com/open-feature/ruby-sdk/commit/fa0076fb8a159a180af408ee0a335e9f8d43afc9))
21
+
3
22
  ## [0.6.7](https://github.com/open-feature/ruby-sdk/compare/v0.6.6...v0.6.7) (2026-10-23)
4
23
 
5
24
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- openfeature-sdk (0.6.7)
4
+ openfeature-sdk (0.7.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -17,8 +17,8 @@
17
17
  </a>
18
18
  <!-- x-release-please-start-version -->
19
19
 
20
- <a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.6.7">
21
- <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.6.7&color=blue&style=for-the-badge" />
20
+ <a href="https://github.com/open-feature/ruby-sdk/releases/tag/v0.7.0">
21
+ <img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.7.0&color=blue&style=for-the-badge" />
22
22
  </a>
23
23
 
24
24
  <!-- x-release-please-end -->
@@ -287,6 +287,7 @@ Log output uses a structured key=value format:
287
287
 
288
288
  Clients can be assigned to a domain. A domain is a logical identifier which can be used to associate clients with a particular provider.
289
289
  If a domain has no associated provider, the default provider is used.
290
+ Clients look up their domain's provider on every call, so a client created before `set_provider` follows the new binding.
290
291
 
291
292
  ```ruby
292
293
  OpenFeature::SDK.configure do |config|
@@ -300,7 +301,11 @@ legacy_flag_client = OpenFeature::SDK.build_client(domain: "legacy_flags")
300
301
  ### Eventing
301
302
 
302
303
  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.
303
- Initialization events (`PROVIDER_READY` on success, `PROVIDER_ERROR` on failure) are dispatched for every provider.
304
+ Providers emit initialization events (`PROVIDER_READY` on success, `PROVIDER_ERROR` on failure) from `init`.
305
+ A provider without `init` is `READY` as soon as it's registered, and its `PROVIDER_READY` handlers run before `set_provider` returns.
306
+ 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.
307
+ That path is deprecated (spec Appendix E), and the SDK logs a deprecation warning once per provider class to its configured logger.
308
+ 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.
304
309
  Some providers emit additional events: `PROVIDER_CONFIGURATION_CHANGED`, `PROVIDER_STALE`, `PROVIDER_RECONCILING` and `PROVIDER_CONTEXT_CHANGED`.
305
310
  Handlers receive a hash with the `provider_name` and any details the provider sent, such as `message` or `error_code`.
306
311
 
@@ -314,15 +319,17 @@ end
314
319
 
315
320
  OpenFeature::SDK.add_handler(OpenFeature::SDK::ProviderEvent::PROVIDER_READY, ready_handler)
316
321
 
317
- # The SDK automatically emits lifecycle events. Providers can emit additional spontaneous events
318
- # using the EventEmitter mixin to signal internal state changes like configuration updates.
322
+ # Providers emit events using the EventEmitter mixin: lifecycle events from init, and spontaneous
323
+ # events to signal internal state changes like configuration updates.
319
324
  class MyEventAwareProvider
320
325
  include OpenFeature::SDK::Provider::EventEmitter
321
326
 
327
+ def emits_lifecycle_events? = true
328
+
322
329
  def init(evaluation_context)
323
330
  # Start background process to monitor for configuration changes
324
- # Note: SDK automatically emits PROVIDER_READY when init completes successfully
325
331
  start_background_process
332
+ emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)
326
333
  end
327
334
 
328
335
  def start_background_process
@@ -446,13 +453,23 @@ You’ll then need to write the provider by implementing the `Provider` duck.
446
453
 
447
454
  ```ruby
448
455
  class MyProvider
456
+ include OpenFeature::SDK::Provider::EventEmitter
457
+
458
+ # Tells the SDK that init emits PROVIDER_READY/PROVIDER_ERROR itself.
459
+ def emits_lifecycle_events? = true
460
+
461
+ # Optional: a provider without init is READY as soon as it's registered.
449
462
  # Both arguments are optional; declare only the ones you need.
450
463
  # `domain` is the domain the provider was bound to, or nil for the default provider.
451
464
  def init(evaluation_context = nil, domain: nil)
452
- # Perform any initialization steps with flag management system here
465
+ # Perform any initialization steps with flag management system here, then emit PROVIDER_READY.
453
466
  # Return value is ignored
454
467
  # **Note** The OpenFeature spec defines a lifecycle method called `initialize` to be called when a new provider is set.
455
468
  # To avoid conflicting with the Ruby `initialize` method, this method should be named `init` when creating a provider.
469
+ emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)
470
+ rescue => e
471
+ emit_event(OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, message: e.message)
472
+ raise
456
473
  end
457
474
 
458
475
  # Optional: return true if this provider keeps state for a single domain.
@@ -48,11 +48,7 @@ module OpenFeature
48
48
  end
49
49
 
50
50
  def build_client(domain: nil, evaluation_context: nil)
51
- active_provider = provider(domain:).nil? ? Provider::NoOpProvider.new : provider(domain:)
52
-
53
- Client.new(provider: active_provider, domain:, evaluation_context:)
54
- rescue
55
- Client.new(provider: Provider::NoOpProvider.new, evaluation_context:)
51
+ Client.new(domain:, evaluation_context:)
56
52
  end
57
53
 
58
54
  def provider_metadata(domain: nil)
@@ -17,11 +17,11 @@ module OpenFeature
17
17
  SUFFIXES = %i[value details].freeze
18
18
  EMPTY_HINTS = Hooks::Hints.new.freeze
19
19
 
20
- attr_reader :metadata, :evaluation_context
20
+ attr_reader :metadata
21
21
 
22
- attr_accessor :hooks
22
+ attr_accessor :hooks, :evaluation_context
23
23
 
24
- def initialize(provider:, domain: nil, evaluation_context: nil)
24
+ def initialize(provider: nil, domain: nil, evaluation_context: nil)
25
25
  @provider = provider
26
26
  @metadata = ClientMetadata.new(domain:)
27
27
  @evaluation_context = evaluation_context
@@ -33,7 +33,7 @@ module OpenFeature
33
33
  end
34
34
 
35
35
  def provider_status
36
- OpenFeature::SDK.configuration.provider_state(@provider)
36
+ OpenFeature::SDK.configuration.provider_state(resolve_provider)
37
37
  end
38
38
 
39
39
  def add_handler(event_type, handler = nil, &block)
@@ -51,12 +51,13 @@ module OpenFeature
51
51
  # Records a tracking event. If the provider does not implement
52
52
  # tracking, this is a no-op (spec 6.1.4).
53
53
  def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
54
- return unless @provider.respond_to?(:track)
54
+ provider = resolve_provider
55
+ return unless provider.respond_to?(:track)
55
56
 
56
57
  built_context = build_evaluation_context(evaluation_context)
57
58
 
58
- @provider.track(tracking_event_name, evaluation_context: built_context, tracking_event_details: tracking_event_details)
59
- rescue
59
+ provider.track(tracking_event_name, evaluation_context: built_context, tracking_event_details: tracking_event_details)
60
+ rescue StandardError, NotImplementedError
60
61
  nil
61
62
  end
62
63
 
@@ -75,16 +76,18 @@ module OpenFeature
75
76
  private
76
77
 
77
78
  def fetch_details(type:, flag_key:, default_value:, evaluation_context: nil, invocation_hooks: [], hook_hints: nil)
79
+ provider = resolve_provider
78
80
  built_context = build_evaluation_context(evaluation_context)
79
81
 
80
82
  # Assemble ordered hooks: API → Client → Invocation → Provider (spec 4.4.2)
81
- provider_hooks = @provider.respond_to?(:hooks) ? Array(@provider.hooks) : []
83
+ provider_hooks = provider.respond_to?(:hooks) ? Array(provider.hooks) : []
82
84
  ordered_hooks = [*OpenFeature::SDK.hooks, *@hooks, *invocation_hooks, *provider_hooks]
83
85
 
84
- # Short-circuit NOT_READY/FATAL providers (required by evaluation_v2.feature)
86
+ # Short-circuit NOT_READY/FATAL providers (required by evaluation_v2.feature). A provider resolved from the
87
+ # configuration is tracked while bound, so one that isn't was replaced mid-call and reads NOT_READY.
85
88
  short_circuit_code = nil
86
- if OpenFeature::SDK.configuration.provider_tracked?(@provider)
87
- short_circuit_code = short_circuit_error_code(provider_status)
89
+ if @provider ? OpenFeature::SDK.configuration.provider_tracked?(provider) : !provider.equal?(@fallback_provider)
90
+ short_circuit_code = short_circuit_error_code(OpenFeature::SDK.configuration.provider_state(provider))
88
91
  end
89
92
 
90
93
  # Fast path: skip hook ceremony when no hooks are registered
@@ -98,7 +101,7 @@ module OpenFeature
98
101
  return EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
99
102
  end
100
103
 
101
- return evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
104
+ return evaluate_flag(provider: provider, type: type, flag_key: flag_key, default_value: default_value, evaluation_context: built_context)
102
105
  end
103
106
 
104
107
  hook_context = Hooks::HookContext.new(
@@ -107,7 +110,7 @@ module OpenFeature
107
110
  default_value: default_value,
108
111
  evaluation_context: built_context,
109
112
  client_metadata: @metadata,
110
- provider_metadata: @provider.respond_to?(:metadata) ? @provider.metadata : nil
113
+ provider_metadata: provider.respond_to?(:metadata) ? provider.metadata : nil
111
114
  )
112
115
 
113
116
  hints = if hook_hints.is_a?(Hooks::Hints)
@@ -131,7 +134,7 @@ module OpenFeature
131
134
  evaluation_details
132
135
  else
133
136
  executor.execute(ordered_hooks: ordered_hooks, hook_context: hook_context, hints: hints) do |hctx|
134
- evaluate_flag(type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
137
+ evaluate_flag(provider: provider, type: type, flag_key: flag_key, default_value: default_value, evaluation_context: hctx.evaluation_context)
135
138
  end
136
139
  end
137
140
  rescue StandardError, NotImplementedError => e
@@ -145,8 +148,8 @@ module OpenFeature
145
148
  EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution)
146
149
  end
147
150
 
148
- def evaluate_flag(type:, flag_key:, default_value:, evaluation_context:)
149
- resolution_details = @provider.send(
151
+ def evaluate_flag(provider:, type:, flag_key:, default_value:, evaluation_context:)
152
+ resolution_details = provider.send(
150
153
  :"fetch_#{type}_value",
151
154
  flag_key: flag_key,
152
155
  default_value: default_value,
@@ -163,6 +166,12 @@ module OpenFeature
163
166
  EvaluationDetails.new(flag_key: flag_key, resolution_details: resolution_details)
164
167
  end
165
168
 
169
+ # Callers resolve once per operation, so a rebinding mid-evaluation can't mix two providers.
170
+ # The fallback NoOp is never registered: untracked, so not short-circuited, and its status reads NOT_READY.
171
+ def resolve_provider
172
+ @provider || OpenFeature::SDK.configuration.provider(domain: @metadata.domain) || (@fallback_provider ||= Provider::NoOpProvider.new)
173
+ end
174
+
166
175
  def short_circuit_error_code(state)
167
176
  case state
168
177
  when ProviderState::NOT_READY then Provider::ErrorCode::PROVIDER_NOT_READY
@@ -21,19 +21,21 @@ module OpenFeature
21
21
  attr_reader :logger
22
22
 
23
23
  # One per registration; +done+ is closed once init finishes, so waiters can pop on it
24
- InitAttempt = Struct.new(:state, :error, :done)
24
+ InitAttempt = Struct.new(:state, :error, :done, :thread)
25
25
  private_constant :InitAttempt
26
26
 
27
27
  def initialize
28
28
  @hooks = []
29
29
  @providers = {}
30
30
  @init_attempts = {}.compare_by_identity
31
+ @legacy_warned = Set.new.compare_by_identity
31
32
  @provider_mutex = Mutex.new
32
33
  @logger = nil
33
34
  @event_dispatcher = EventDispatcher.new(@logger)
34
35
  @provider_state_registry = ProviderStateRegistry.new
35
36
  @client_handlers = {}
36
37
  @client_handlers_mutex = Mutex.new
38
+ @dispatch_key = :"openfeature_dispatch_#{object_id}"
37
39
  end
38
40
 
39
41
  def add_hooks(*new_hooks)
@@ -60,6 +62,8 @@ module OpenFeature
60
62
 
61
63
  # @api private
62
64
  def add_client_handler(client, event_type, handler)
65
+ @event_dispatcher.validate_handler!(event_type, handler)
66
+
63
67
  @client_handlers_mutex.synchronize do
64
68
  @client_handlers[client] ||= Hash.new { |h, k| h[k] = [] }
65
69
  @client_handlers[client][event_type] << handler
@@ -123,9 +127,9 @@ module OpenFeature
123
127
  @provider_state_registry.clear
124
128
  @provider_mutex.synchronize do
125
129
  @providers.clear
126
- # Release anyone waiting on an init that shutdown cut short
127
- @init_attempts.each_value { |attempt| attempt.done.close }
130
+ @init_attempts.each_value { |attempt| abandon_init_attempt(attempt) }
128
131
  @init_attempts.clear
132
+ @legacy_warned.clear
129
133
  end
130
134
  @hooks.clear
131
135
  @evaluation_context = nil
@@ -144,16 +148,15 @@ module OpenFeature
144
148
 
145
149
  # Provider code runs before taking the lock: it may call back into the SDK
146
150
  scoped = domain_scoped?(provider)
151
+ has_init = provider.respond_to?(:init)
152
+ legacy = has_init && !emits_lifecycle_events?(provider)
153
+ warn_legacy_provider(provider) if legacy
147
154
 
148
155
  @provider_mutex.synchronize do
149
156
  validate_domain_scoped_binding!(provider, domain, scoped)
150
157
 
151
158
  old_provider = @providers[domain]
152
159
 
153
- new_providers = @providers.dup
154
- new_providers[domain] = provider
155
- @providers = new_providers
156
-
157
160
  # Spec 1.1.2.2: initialize once per registration; a failed init is retried on the next set
158
161
  attempt = @init_attempts[provider]
159
162
  needs_init = attempt.nil? || attempt.state == :failed
@@ -164,11 +167,16 @@ module OpenFeature
164
167
  provider.send(:attach, self) if provider.is_a?(Provider::EventEmitter)
165
168
  end
166
169
 
170
+ # Publish last: clients read @providers without the lock, and must not see an untracked provider
171
+ new_providers = @providers.dup
172
+ new_providers[domain] = provider
173
+ @providers = new_providers
174
+
167
175
  # Spec 1.1.2.3: Only shutdown old provider if it's no longer bound to any domain
168
176
  if old_provider && !old_provider.equal?(provider)
169
177
  needs_shutdown = !provider_bound?(old_provider)
170
178
  if needs_shutdown
171
- @init_attempts.delete(old_provider)
179
+ abandon_init_attempt(@init_attempts.delete(old_provider))
172
180
  @provider_state_registry.remove_provider(old_provider)
173
181
  old_provider.send(:detach) if old_provider.is_a?(Provider::EventEmitter)
174
182
  end
@@ -186,25 +194,28 @@ module OpenFeature
186
194
 
187
195
  # Initialize provider outside the mutex to avoid blocking other operations
188
196
  if needs_init
189
- if wait_for_init
190
- init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: true)
197
+ # Spec 2.8.5.1: a provider without init is READY, and its handlers have run, when set_provider returns
198
+ if wait_for_init || !has_init
199
+ init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: wait_for_init)
200
+ # Replaced or shut down while init ran: fail like the waiters on this attempt did
201
+ raise incomplete_init_error(provider, "initialization did not complete") if wait_for_init && attempt.state != :succeeded
191
202
  else
192
203
  init_thread = Thread.new do
193
204
  init_provider(provider, context_for_init, domain:, attempt:, raise_on_error: false)
194
205
  end
195
206
  end
207
+ elsif !has_init
208
+ # Spec 2.8.5.1: each new binding of a provider without init runs its domain's READY handlers
209
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) unless old_provider.equal?(provider)
196
210
  elsif wait_for_init
197
- # Already registered: wait for an init still in flight, then report its outcome
211
+ # Already registered: wait for an init still in flight, then report its outcome.
212
+ # From inside that init (e.g. a handler of an event it emits) the wait could never end.
213
+ raise incomplete_init_error(provider, "is still initializing on this thread") if running_on_this_thread?(attempt)
214
+
198
215
  attempt.done.pop
199
- unless attempt.state == :succeeded
200
- raise attempt.error || ProviderInitializationError.new(
201
- "Provider #{provider.class.name} initialization did not complete",
202
- provider:,
203
- error_code: Provider::ErrorCode::GENERAL
204
- )
205
- end
216
+ raise attempt.error || incomplete_init_error(provider, "initialization did not complete") unless attempt.state == :succeeded
206
217
 
207
- dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
218
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) if legacy
208
219
  end
209
220
  ensure
210
221
  # Fail an attempt no init took over (e.g. a Timeout hit first), or it would stay running forever
@@ -212,11 +223,14 @@ module OpenFeature
212
223
  end
213
224
 
214
225
  def init_provider(provider, context, domain:, attempt:, raise_on_error: false)
226
+ emits_lifecycle_events = emits_lifecycle_events?(provider)
227
+ attempt.thread = Thread.current
215
228
  call_init(provider, context, domain)
216
229
 
217
- # Settle the attempt before handlers run, so a handler can register this provider again
218
- finish_init_attempt(attempt, :succeeded)
219
- dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY)
230
+ # Settle the attempt before handlers run, so a handler can register this provider again.
231
+ # An abandoned attempt (provider replaced or shut down meanwhile) reports no READY.
232
+ # Spec 2.8.2/2.8.3: a provider that emits its own lifecycle events gets no synthetic READY or ERROR
233
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_READY, token: attempt) if finish_init_attempt(attempt, :succeeded) && !emits_lifecycle_events
220
234
  rescue => e
221
235
  # Spec 1.7.5: PROVIDER_FATAL from init makes the provider FATAL
222
236
  error_code = EvaluationError.code_for(e)
@@ -228,9 +242,10 @@ module OpenFeature
228
242
  )
229
243
  finish_init_attempt(attempt, :failed, error)
230
244
 
231
- dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR,
232
- error_code: error_code,
233
- message: e.message)
245
+ unless emits_lifecycle_events
246
+ dispatch_provider_event(provider, ProviderEvent::PROVIDER_ERROR,
247
+ {error_code: error_code, message: e.message}, token: attempt)
248
+ end
234
249
 
235
250
  raise error if raise_on_error
236
251
  ensure
@@ -238,35 +253,80 @@ module OpenFeature
238
253
  finish_init_attempt(attempt, :failed)
239
254
  end
240
255
 
256
+ # Caller holds @provider_mutex: the provider was unbound or the API reset before its init settled
257
+ def abandon_init_attempt(attempt)
258
+ return unless attempt
259
+
260
+ attempt.state = :failed if attempt.state == :running
261
+ attempt.done.close
262
+ end
263
+
264
+ def incomplete_init_error(provider, reason)
265
+ ProviderInitializationError.new("Provider #{provider.class.name} #{reason}", provider:, error_code: Provider::ErrorCode::GENERAL)
266
+ end
267
+
241
268
  def finish_init_attempt(attempt, state, error = nil)
242
269
  @provider_mutex.synchronize do
243
- return unless attempt.state == :running
270
+ return false unless attempt.state == :running
244
271
 
245
272
  attempt.state = state
246
273
  attempt.error = error
247
274
  end
248
275
  attempt.done.close
276
+ true
249
277
  end
250
278
 
251
- def dispatch_provider_event(provider, event_type, details = {})
252
- # Under the replacement lock, so a late event can't re-track a replaced or shut-down provider
253
- @provider_mutex.synchronize do
254
- return unless provider_bound?(provider)
255
-
256
- @provider_state_registry.update_state_from_event(provider, event_type, details)
279
+ # An event for a provider already dispatching on this call stack is queued behind the current one
280
+ def dispatch_provider_event(provider, event_type, details = {}, token: nil)
281
+ # Lifecycle events (the SDK's own, or the provider's from its init thread) record status when sent, so
282
+ # 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)
283
+ token ||= running_init_attempt_on_this_thread(provider)
284
+ return if token && !accept_provider_event(provider, event_type, details, token)
285
+
286
+ queues = (Thread.current[@dispatch_key] ||= {}.compare_by_identity)
287
+ if (queue = queues[provider])
288
+ queue << [event_type, details.dup, token]
289
+ return
257
290
  end
258
291
 
259
- provider_name = extract_provider_name(provider)
292
+ queue = queues[provider] = [[event_type, details, token]]
293
+ begin
294
+ deliver_provider_event(provider, *queue.shift)
295
+ until queue.empty?
296
+ begin
297
+ deliver_provider_event(provider, *queue.shift)
298
+ rescue => e
299
+ # Its own emitter already returned, so log rather than fail the event that was dispatching
300
+ @logger&.error("Queued provider event failed: #{e.message}")
301
+ end
302
+ end
303
+ ensure
304
+ queues.delete(provider)
305
+ Thread.current[@dispatch_key] = nil if queues.empty?
306
+ end
307
+ end
260
308
 
261
- event_details = {
262
- provider_name: provider_name
263
- }.merge(details)
309
+ def deliver_provider_event(provider, event_type, details, token)
310
+ return unless accept_provider_event(provider, event_type, details, token, record_status: token.nil?)
264
311
 
312
+ event_details = {provider_name: extract_provider_name(provider)}.merge(details)
265
313
  run_handlers_for_provider(provider, event_type, event_details)
266
314
  end
267
315
 
268
316
  private
269
317
 
318
+ # Under the replacement lock, so a late event can't re-track a replaced or shut-down provider
319
+ def accept_provider_event(provider, event_type, details, token, record_status: true)
320
+ @provider_mutex.synchronize do
321
+ return false unless provider_bound?(provider)
322
+ # An SDK READY/ERROR from an earlier registration of this provider is stale
323
+ return false unless token.nil? || @init_attempts[provider].equal?(token)
324
+
325
+ @provider_state_registry.update_state_from_event(provider, event_type, details) if record_status
326
+ true
327
+ end
328
+ end
329
+
270
330
  def provider_bound?(provider)
271
331
  @providers.each_value.any? { |p| p.equal?(provider) }
272
332
  end
@@ -279,6 +339,26 @@ module OpenFeature
279
339
  provider.respond_to?(:domain_scoped?) && provider.domain_scoped?
280
340
  end
281
341
 
342
+ # Spec 2.8.5.1: without init there's nothing to report, so the marker only counts alongside init
343
+ def emits_lifecycle_events?(provider)
344
+ provider.respond_to?(:init) && provider.respond_to?(:emits_lifecycle_events?) && provider.emits_lifecycle_events?
345
+ end
346
+
347
+ def running_init_attempt_on_this_thread(provider)
348
+ attempt = @provider_mutex.synchronize { @init_attempts[provider] }
349
+ attempt if attempt && running_on_this_thread?(attempt)
350
+ end
351
+
352
+ def running_on_this_thread?(attempt)
353
+ attempt.state == :running && attempt.thread.equal?(Thread.current)
354
+ end
355
+
356
+ def warn_legacy_provider(provider)
357
+ return if @logger.nil? || !@provider_mutex.synchronize { @legacy_warned.add?(provider.class) }
358
+ @logger.warn("Provider #{extract_provider_name(provider)} relies on SDK-emitted lifecycle events, which are deprecated; " \
359
+ "emit PROVIDER_READY/PROVIDER_ERROR from init and define emits_lifecycle_events? returning true (spec Appendix E)")
360
+ end
361
+
282
362
  def validate_domain_scoped_binding!(provider, domain, scoped)
283
363
  return unless scoped && provider_bound?(provider) && !@providers[domain].equal?(provider)
284
364
 
@@ -313,22 +393,20 @@ module OpenFeature
313
393
  # Run global handlers (API-level, no domain filtering)
314
394
  @event_dispatcher.trigger_event(event_type, event_details)
315
395
 
316
- # Run client handlers (domain-scoped)
317
- @client_handlers_mutex.synchronize do
318
- @client_handlers.each do |client, handlers_by_event|
396
+ # Run client handlers (domain-scoped) outside the lock, so they can add or remove handlers
397
+ handlers = @client_handlers_mutex.synchronize do
398
+ @client_handlers.flat_map do |client, handlers_by_event|
319
399
  # Check if this client should receive events from this provider
320
400
  client_provider = provider(domain: client.metadata.domain)
321
- next unless client_provider&.equal?(provider)
322
-
323
- # Trigger handlers for this client
324
- handlers = handlers_by_event[event_type]
325
- handlers.each do |handler|
326
- handler.call(event_details)
327
- rescue => e
328
- @logger&.error("Client event handler failed: #{e.message}")
329
- end
401
+ client_provider&.equal?(provider) ? handlers_by_event[event_type] : []
330
402
  end
331
403
  end
404
+
405
+ handlers.each do |handler|
406
+ handler.call(event_details)
407
+ rescue => e
408
+ @logger&.error("Client event handler failed: #{e.message}")
409
+ end
332
410
  end
333
411
 
334
412
  def run_immediate_handler(event_type, handler, client)
@@ -340,8 +418,8 @@ module OpenFeature
340
418
  }
341
419
 
342
420
  if client.nil?
343
- # API-level handler: check all providers
344
- @providers.each do |domain, provider|
421
+ # API-level handler: check each bound provider once, however many domains it's bound to
422
+ @providers.values.uniq(&:object_id).each do |provider|
345
423
  next unless provider
346
424
 
347
425
  provider_state = @provider_state_registry.get_state(provider)
@@ -16,14 +16,18 @@ module OpenFeature
16
16
  end
17
17
 
18
18
  def add_handler(event_type, handler)
19
- raise ArgumentError, "Invalid event type: #{event_type}" unless valid_event?(event_type)
20
- raise ArgumentError, "Handler must respond to call" unless handler.respond_to?(:call)
19
+ validate_handler!(event_type, handler)
21
20
 
22
21
  @mutex.synchronize do
23
22
  @handlers[event_type] << handler
24
23
  end
25
24
  end
26
25
 
26
+ def validate_handler!(event_type, handler)
27
+ raise ArgumentError, "Invalid event type: #{event_type}" unless valid_event?(event_type)
28
+ raise ArgumentError, "Handler must respond to call" unless handler.respond_to?(:call)
29
+ end
30
+
27
31
  def remove_handler(event_type, handler)
28
32
  return unless valid_event?(event_type)
29
33
 
@@ -54,7 +54,7 @@ module OpenFeature
54
54
  else
55
55
  run_after_hooks(ordered_hooks, hook_context, evaluation_details, hints)
56
56
  end
57
- rescue => e
57
+ rescue StandardError, NotImplementedError => e
58
58
  run_error_hooks(ordered_hooks, hook_context, e, hints)
59
59
 
60
60
  evaluation_details = EvaluationDetails.new(
@@ -19,10 +19,6 @@ module OpenFeature
19
19
  @flags = flags
20
20
  end
21
21
 
22
- def init(evaluation_context = nil)
23
- # Intentional no-op, used for testing
24
- end
25
-
26
22
  def shutdown
27
23
  # Intentional no-op, used for testing
28
24
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OpenFeature
4
4
  module SDK
5
- VERSION = "0.6.7"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
@@ -7,10 +7,10 @@ module OpenFeature
7
7
  EMPTY_HINTS: Hooks::Hints
8
8
 
9
9
  attr_reader metadata: ClientMetadata
10
- attr_reader evaluation_context: EvaluationContext?
10
+ attr_accessor evaluation_context: EvaluationContext?
11
11
  attr_accessor hooks: Array[untyped]
12
12
 
13
- def initialize: (provider: untyped, ?domain: String?, ?evaluation_context: EvaluationContext?) -> void
13
+ def initialize: (?provider: untyped, ?domain: String?, ?evaluation_context: EvaluationContext?) -> void
14
14
 
15
15
  def add_hooks: (*Array[untyped] new_hooks) -> void
16
16
 
@@ -40,7 +40,8 @@ module OpenFeature
40
40
  private
41
41
 
42
42
  def fetch_details: (type: Symbol, flag_key: String, default_value: untyped, ?evaluation_context: EvaluationContext?, ?invocation_hooks: Array[untyped], ?hook_hints: Hooks::Hints?) -> EvaluationDetails?
43
- def evaluate_flag: (type: Symbol, flag_key: String, default_value: untyped, evaluation_context: EvaluationContext?) -> EvaluationDetails
43
+ def evaluate_flag: (provider: untyped, type: Symbol, flag_key: String, default_value: untyped, evaluation_context: EvaluationContext?) -> EvaluationDetails
44
+ def resolve_provider: () -> untyped
44
45
  def short_circuit_error_code: (String state) -> String?
45
46
  def build_evaluation_context: (EvaluationContext? invocation_context) -> EvaluationContext?
46
47
  def validate_default_value_type: (Symbol type, untyped default_value) -> void
@@ -33,10 +33,18 @@ module OpenFeature
33
33
  def reset: () -> void
34
34
  def set_provider_internal: (untyped provider, domain: String?, wait_for_init: bool) -> void
35
35
  def init_provider: (untyped provider, EvaluationContext? context, domain: String?, attempt: untyped, ?raise_on_error: bool) -> void
36
- def finish_init_attempt: (untyped attempt, Symbol state, ?ProviderInitializationError? error) -> void
37
- def dispatch_provider_event: (untyped provider, String event_type, ?Hash[Symbol, untyped] details) -> void
36
+ def finish_init_attempt: (untyped attempt, Symbol state, ?ProviderInitializationError? error) -> bool
37
+ def abandon_init_attempt: (untyped attempt) -> void
38
+ def incomplete_init_error: (untyped provider, String reason) -> ProviderInitializationError
39
+ def dispatch_provider_event: (untyped provider, String event_type, ?Hash[Symbol, untyped] details, ?token: untyped) -> void
40
+ def deliver_provider_event: (untyped provider, String event_type, Hash[Symbol, untyped]? details, untyped token) -> void
41
+ def accept_provider_event: (untyped provider, String event_type, Hash[Symbol, untyped]? details, untyped token, ?record_status: bool) -> bool
38
42
  def provider_bound?: (untyped provider) -> bool
39
43
  def domain_scoped?: (untyped provider) -> bool
44
+ def emits_lifecycle_events?: (untyped provider) -> bool
45
+ def warn_legacy_provider: (untyped provider) -> void
46
+ def running_init_attempt_on_this_thread: (untyped provider) -> untyped
47
+ def running_on_this_thread?: (untyped attempt) -> bool
40
48
  def validate_domain_scoped_binding!: (untyped provider, String? domain, bool scoped) -> void
41
49
  def call_init: (untyped provider, EvaluationContext? context, String? domain) -> void
42
50
  def init_accepts_domain?: (Array[[Symbol, Symbol?]] parameters) -> bool
@@ -6,6 +6,7 @@ module OpenFeature
6
6
  def initialize: (?untyped? logger) -> void
7
7
 
8
8
  def add_handler: (String event_type, _ToProc handler) -> void
9
+ def validate_handler!: (String event_type, untyped handler) -> void
9
10
  def remove_handler: (String event_type, _ToProc handler) -> void
10
11
  def remove_all_handlers: (String event_type) -> void
11
12
  def trigger_event: (String event_type, ?Hash[Symbol, untyped] event_details) -> void
@@ -20,7 +20,6 @@ module OpenFeature
20
20
 
21
21
  def initialize: (?Hash[String, untyped] flags) -> void
22
22
 
23
- def init: (?EvaluationContext? evaluation_context) -> void
24
23
  def shutdown: () -> void
25
24
 
26
25
  def add_flag: (flag_key: String, value: untyped) -> void
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openfeature-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.7
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OpenFeature Authors