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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +19 -0
- data/Gemfile.lock +1 -1
- data/README.md +24 -7
- data/lib/open_feature/sdk/api.rb +1 -5
- data/lib/open_feature/sdk/client.rb +25 -16
- data/lib/open_feature/sdk/configuration.rb +128 -50
- data/lib/open_feature/sdk/event_dispatcher.rb +6 -2
- data/lib/open_feature/sdk/hooks/hook_executor.rb +1 -1
- data/lib/open_feature/sdk/provider/in_memory_provider.rb +0 -4
- data/lib/open_feature/sdk/version.rb +1 -1
- data/sig/open_feature/sdk/client.rbs +4 -3
- data/sig/open_feature/sdk/configuration.rbs +10 -2
- data/sig/open_feature/sdk/event_dispatcher.rbs +1 -0
- data/sig/open_feature/sdk/provider/in_memory_provider.rbs +0 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b8b6e8a5fd01205278fa44c809eb3c1ef578b56e5542aa94793a090e605458d
|
|
4
|
+
data.tar.gz: f85c7d0e8b804fb651e02cc3c59d14c21458f9fdb419d58df6d8fbb714a35b9b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a7fef199498fb02863ed74cf76a6fbff8acf65df23e2e5d4baad90b6ed3da589b6ca98da1540443a7367456a527819eb2380979aa4f1a1ef8780c33ccf425d32
|
|
7
|
+
data.tar.gz: 71db2db0d85592861ebd5f117d7599e9d54374deb9a6eae5060716044abddb27a4bd10db7ca07314540a36adeb7c366afa1a8c21fb4f19824e05e15fd59a1a27
|
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
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.
|
|
21
|
-
<img alt="Release" src="https://img.shields.io/static/v1?label=release&message=v0.
|
|
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
|
-
|
|
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
|
-
#
|
|
318
|
-
#
|
|
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.
|
data/lib/open_feature/sdk/api.rb
CHANGED
|
@@ -48,11 +48,7 @@ module OpenFeature
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def build_client(domain: nil, evaluation_context: nil)
|
|
51
|
-
|
|
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
|
|
20
|
+
attr_reader :metadata
|
|
21
21
|
|
|
22
|
-
attr_accessor :hooks
|
|
22
|
+
attr_accessor :hooks, :evaluation_context
|
|
23
23
|
|
|
24
|
-
def initialize(provider
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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?(
|
|
87
|
-
short_circuit_code = short_circuit_error_code(
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
344
|
-
@providers.each do |
|
|
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
|
-
|
|
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(
|
|
@@ -7,10 +7,10 @@ module OpenFeature
|
|
|
7
7
|
EMPTY_HINTS: Hooks::Hints
|
|
8
8
|
|
|
9
9
|
attr_reader metadata: ClientMetadata
|
|
10
|
-
|
|
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) ->
|
|
37
|
-
def
|
|
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
|