featureflip 2.9.0 → 2.11.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/lib/featureflip/client.rb +50 -1
- data/lib/featureflip/config.rb +21 -1
- data/lib/featureflip/data_source/polling.rb +7 -2
- data/lib/featureflip/data_source/streaming.rb +17 -1
- data/lib/featureflip/http/client.rb +1 -1
- data/lib/featureflip/sdk_identity.rb +27 -0
- data/lib/featureflip/shared_core.rb +57 -5
- data/lib/featureflip/store/flag_store.rb +35 -6
- data/lib/featureflip/store/update_diff.rb +105 -0
- data/lib/featureflip/version.rb +1 -1
- data/lib/featureflip.rb +7 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7dbbc4e1eefae9adf8a818fc31905e674ee68369be22f6d558f1838604a34fba
|
|
4
|
+
data.tar.gz: 91ddda08b1eb8015d297c4a6206d8df48704cde620908634bdc7c5415b6e3cb4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c77f14b1245ebd4453d878c41fbadbea5051cee8b86eb5a9aed6fb210714b71275792b19cf571e62ccacce985babf303db5eeb81875fd6a18009bb169ef5506
|
|
7
|
+
data.tar.gz: 0ea6aba2d012d330c976e2fee16be73aee7d3ec1392a7bc0677147b2e1d7bac1a33b7048b59afaad82fd72339e984421ddb358339bedb220bacb66b8fa572e55
|
data/lib/featureflip/client.rb
CHANGED
|
@@ -2,6 +2,9 @@ module Featureflip
|
|
|
2
2
|
class Client
|
|
3
3
|
private_class_method :new
|
|
4
4
|
|
|
5
|
+
NOOP_UNSUBSCRIBE = -> {}.freeze
|
|
6
|
+
private_constant :NOOP_UNSUBSCRIBE
|
|
7
|
+
|
|
5
8
|
def self.get(sdk_key = nil, config: nil)
|
|
6
9
|
sdk_key ||= ENV["FEATUREFLIP_SDK_KEY"]
|
|
7
10
|
raise ConfigurationError, "SDK key is required. Pass sdk_key parameter or set FEATUREFLIP_SDK_KEY env var." unless sdk_key
|
|
@@ -68,10 +71,15 @@ module Featureflip
|
|
|
68
71
|
end
|
|
69
72
|
|
|
70
73
|
def close
|
|
71
|
-
@close_mutex.synchronize do
|
|
74
|
+
subscriptions = @close_mutex.synchronize do
|
|
72
75
|
return if @closed
|
|
76
|
+
|
|
73
77
|
@closed = true
|
|
78
|
+
@subscriptions.dup.tap { @subscriptions.clear }
|
|
74
79
|
end
|
|
80
|
+
# This handle's listeners only: the core is shared and other handles may
|
|
81
|
+
# still be subscribed.
|
|
82
|
+
subscriptions.each { |s| @core._remove_update_listener(s) }
|
|
75
83
|
@core._release
|
|
76
84
|
end
|
|
77
85
|
|
|
@@ -80,12 +88,53 @@ module Featureflip
|
|
|
80
88
|
@core.restart
|
|
81
89
|
end
|
|
82
90
|
|
|
91
|
+
# Subscribe to flag-configuration changes. The listener (a block or any
|
|
92
|
+
# #call-able) receives the sorted keys whose configuration changed, batched
|
|
93
|
+
# into one call per update. It runs on the SDK's streaming or polling
|
|
94
|
+
# thread, so it must not block.
|
|
95
|
+
#
|
|
96
|
+
# The initial flag load does NOT fire. Reported keys include flags added,
|
|
97
|
+
# removed or modified, flags referencing an edited segment, and flags
|
|
98
|
+
# depending on a changed flag through a prerequisite. A raising listener is
|
|
99
|
+
# logged and isolated.
|
|
100
|
+
#
|
|
101
|
+
# Returns a callable that unsubscribes (calling it twice is harmless). One
|
|
102
|
+
# dispatch already in flight may still reach the listener after the
|
|
103
|
+
# unsubscribe returns. While the fallback poller and the stream overlap
|
|
104
|
+
# (#3071) a listener can be called from two threads at once, so it must be
|
|
105
|
+
# thread-safe.
|
|
106
|
+
# Closing this handle drops every subscription it made.
|
|
107
|
+
def on_update(listener = nil, &block)
|
|
108
|
+
raise ArgumentError, "pass a listener or a block, not both" if listener && block
|
|
109
|
+
|
|
110
|
+
callable = listener || block
|
|
111
|
+
raise ArgumentError, "on_update requires a listener or a block" if callable.nil?
|
|
112
|
+
raise ArgumentError, "listener must respond to #call" unless callable.respond_to?(:call)
|
|
113
|
+
|
|
114
|
+
# A distinct wrapper per subscription, so registering the same callable
|
|
115
|
+
# twice yields two independently removable subscriptions.
|
|
116
|
+
subscription = ->(keys) { callable.call(keys) }
|
|
117
|
+
@close_mutex.synchronize do
|
|
118
|
+
return NOOP_UNSUBSCRIBE if @closed
|
|
119
|
+
|
|
120
|
+
@subscriptions << subscription
|
|
121
|
+
@core._add_update_listener(subscription)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
lambda do
|
|
125
|
+
@close_mutex.synchronize { @subscriptions.delete_if { |s| s.equal?(subscription) } }
|
|
126
|
+
@core._remove_update_listener(subscription)
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
83
131
|
private
|
|
84
132
|
|
|
85
133
|
def initialize(core)
|
|
86
134
|
@core = core
|
|
87
135
|
@closed = false
|
|
88
136
|
@close_mutex = Mutex.new
|
|
137
|
+
@subscriptions = []
|
|
89
138
|
end
|
|
90
139
|
end
|
|
91
140
|
end
|
data/lib/featureflip/config.rb
CHANGED
|
@@ -4,6 +4,15 @@ module Featureflip
|
|
|
4
4
|
:flush_batch_size, :init_timeout, :connect_timeout, :read_timeout,
|
|
5
5
|
:max_stream_retries, :send_events, :logger
|
|
6
6
|
|
|
7
|
+
# Identifies a library that wraps this SDK, such as the OpenFeature provider. Its
|
|
8
|
+
# token is sent after the SDK's own in the User-Agent
|
|
9
|
+
# (`featureflip-ruby/<version> my-wrapper/1.0.0`) so usage through the wrapper can be
|
|
10
|
+
# counted. Both parts must be valid HTTP tokens; an invalid wrapper is ignored with a
|
|
11
|
+
# logged warning. wrapper_version may be nil. Leave unset when calling the SDK
|
|
12
|
+
# directly. Excluded from SharedCore._configs_equal: it identifies the caller rather
|
|
13
|
+
# than changing behavior.
|
|
14
|
+
attr_accessor :wrapper_name, :wrapper_version
|
|
15
|
+
|
|
7
16
|
# Evaluation inspectors -- callables invoked once per variation call with a
|
|
8
17
|
# Models::EvaluationEvent. Always an Array; non-callable entries are dropped
|
|
9
18
|
# on assignment rather than blowing up on the evaluation hot path.
|
|
@@ -22,7 +31,9 @@ module Featureflip
|
|
|
22
31
|
max_stream_retries: 5,
|
|
23
32
|
send_events: true,
|
|
24
33
|
logger: nil,
|
|
25
|
-
inspectors: nil
|
|
34
|
+
inspectors: nil,
|
|
35
|
+
wrapper_name: nil,
|
|
36
|
+
wrapper_version: nil
|
|
26
37
|
)
|
|
27
38
|
@sdk_key = sdk_key
|
|
28
39
|
@base_url = base_url
|
|
@@ -37,6 +48,8 @@ module Featureflip
|
|
|
37
48
|
@send_events = send_events
|
|
38
49
|
@logger = logger || default_logger
|
|
39
50
|
self.inspectors = inspectors
|
|
51
|
+
@wrapper_name = wrapper_name
|
|
52
|
+
@wrapper_version = wrapper_version
|
|
40
53
|
|
|
41
54
|
validate!
|
|
42
55
|
end
|
|
@@ -53,6 +66,13 @@ module Featureflip
|
|
|
53
66
|
@inspectors = list.select { |i| i.respond_to?(:call) }.freeze
|
|
54
67
|
end
|
|
55
68
|
|
|
69
|
+
# The User-Agent for every request: the SDK token, then the wrapper's (#4154). Built
|
|
70
|
+
# on first use and memoized, so the HTTP client and the stream share one value and an
|
|
71
|
+
# invalid wrapper is warned about once.
|
|
72
|
+
def user_agent
|
|
73
|
+
@user_agent ||= SdkIdentity.user_agent(self)
|
|
74
|
+
end
|
|
75
|
+
|
|
56
76
|
def validate!
|
|
57
77
|
finalize!
|
|
58
78
|
validate_positive_fields!
|
|
@@ -17,9 +17,14 @@ module Featureflip
|
|
|
17
17
|
|
|
18
18
|
def stop
|
|
19
19
|
@stop_flag = true
|
|
20
|
-
|
|
21
|
-
@thread&.join(5)
|
|
20
|
+
thread = @thread
|
|
22
21
|
@thread = nil
|
|
22
|
+
# Called from a listener running on the poll thread: it can't join itself,
|
|
23
|
+
# and the stop flag ends its loop once the listener returns.
|
|
24
|
+
return if thread.nil? || thread.equal?(Thread.current)
|
|
25
|
+
|
|
26
|
+
thread.wakeup rescue nil
|
|
27
|
+
thread.join(5)
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
private
|
|
@@ -77,6 +77,13 @@ module Featureflip
|
|
|
77
77
|
@thread = nil
|
|
78
78
|
return unless thread
|
|
79
79
|
|
|
80
|
+
# Called from a listener running ON the stream thread (close/restart from
|
|
81
|
+
# inside an on_update callback): raising into ourselves would fire
|
|
82
|
+
# StreamStopped synchronously mid-shutdown and joining ourselves would
|
|
83
|
+
# deadlock. The flag is set, so the read loop abandons the connection as soon
|
|
84
|
+
# as the listener returns.
|
|
85
|
+
return if thread.equal?(Thread.current)
|
|
86
|
+
|
|
80
87
|
# Interrupt a thread blocked in read_body. Guard the raise: the thread may
|
|
81
88
|
# finish between the alive? check and the raise (ThreadError on a dead one).
|
|
82
89
|
begin
|
|
@@ -153,7 +160,9 @@ module Featureflip
|
|
|
153
160
|
req = Net::HTTP::Get.new(uri.request_uri)
|
|
154
161
|
req["Authorization"] = @sdk_key
|
|
155
162
|
req["Accept"] = "text/event-stream"
|
|
156
|
-
|
|
163
|
+
# Same memoized value as the HTTP client, so an invalid wrapper is warned about
|
|
164
|
+
# once rather than on every reconnect.
|
|
165
|
+
req["User-Agent"] = @config.user_agent
|
|
157
166
|
|
|
158
167
|
http.request(req) do |response|
|
|
159
168
|
unless response.is_a?(Net::HTTPSuccess)
|
|
@@ -163,7 +172,12 @@ module Featureflip
|
|
|
163
172
|
reset_stream_parser
|
|
164
173
|
response.read_body do |chunk|
|
|
165
174
|
break if @stop_flag
|
|
175
|
+
|
|
166
176
|
feed_chunk(chunk)
|
|
177
|
+
# A listener on this thread may have stopped the handler while
|
|
178
|
+
# feed_chunk dispatched. Leave read_body now, which abandons the SSE
|
|
179
|
+
# connection, instead of blocking on the socket for the next chunk.
|
|
180
|
+
break if @stop_flag
|
|
167
181
|
end
|
|
168
182
|
end
|
|
169
183
|
|
|
@@ -183,6 +197,8 @@ module Featureflip
|
|
|
183
197
|
def feed_chunk(chunk)
|
|
184
198
|
@line_buffer << chunk
|
|
185
199
|
while (newline_index = @line_buffer.index("\n"))
|
|
200
|
+
break if @stop_flag
|
|
201
|
+
|
|
186
202
|
line = @line_buffer.slice!(0, newline_index + 1)
|
|
187
203
|
process_sse_line(line.chomp.force_encoding(Encoding::UTF_8))
|
|
188
204
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Featureflip
|
|
2
|
+
# The User-Agent every request carries so the Evaluation API can count SDK usage by
|
|
3
|
+
# name and version (#4154, contract in #4138): `featureflip-ruby/<version>`, then the
|
|
4
|
+
# configured wrapper's token. VERSION is the constant the publish workflow rewrites,
|
|
5
|
+
# never a second literal (#2141).
|
|
6
|
+
module SdkIdentity
|
|
7
|
+
# RFC 9110 product token: tchars.
|
|
8
|
+
TOKEN = /\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/
|
|
9
|
+
|
|
10
|
+
def self.user_agent(config)
|
|
11
|
+
sdk = "featureflip-ruby/#{Featureflip::VERSION}"
|
|
12
|
+
name = config.wrapper_name
|
|
13
|
+
return sdk if name.nil? || name.to_s.empty?
|
|
14
|
+
|
|
15
|
+
version = config.wrapper_version
|
|
16
|
+
unless TOKEN.match?(name.to_s) && (version.nil? || TOKEN.match?(version.to_s))
|
|
17
|
+
# Dropped rather than raised: identity metadata must never stop the client starting.
|
|
18
|
+
config.logger.warn(
|
|
19
|
+
"Featureflip: ignoring wrapper '#{name}/#{version}': not a valid User-Agent product token"
|
|
20
|
+
)
|
|
21
|
+
return sdk
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
version.nil? ? "#{sdk} #{name}" : "#{sdk} #{name}/#{version}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -97,6 +97,8 @@ module Featureflip
|
|
|
97
97
|
@ref_count = 1
|
|
98
98
|
@ref_mutex = Mutex.new
|
|
99
99
|
@shut_down = false
|
|
100
|
+
@update_listeners = []
|
|
101
|
+
@update_listeners_mutex = Mutex.new
|
|
100
102
|
|
|
101
103
|
bootstrap!
|
|
102
104
|
end
|
|
@@ -126,6 +128,17 @@ module Featureflip
|
|
|
126
128
|
@config
|
|
127
129
|
end
|
|
128
130
|
|
|
131
|
+
# Internal: Client#on_update's registry. Listeners are core-wide (one data
|
|
132
|
+
# source feeds every handle); Client tracks which ones each handle added so
|
|
133
|
+
# closing one handle never silences another's.
|
|
134
|
+
def _add_update_listener(listener)
|
|
135
|
+
@update_listeners_mutex.synchronize { @update_listeners << listener }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def _remove_update_listener(listener)
|
|
139
|
+
@update_listeners_mutex.synchronize { @update_listeners.delete_if { |l| l.equal?(listener) } }
|
|
140
|
+
end
|
|
141
|
+
|
|
129
142
|
def _ref_count
|
|
130
143
|
@ref_mutex.synchronize { @ref_count }
|
|
131
144
|
end
|
|
@@ -361,10 +374,10 @@ module Featureflip
|
|
|
361
374
|
sdk_key: @sdk_key,
|
|
362
375
|
config: @config,
|
|
363
376
|
http_client: @http_client,
|
|
364
|
-
on_flag_updated: ->(flag) {
|
|
365
|
-
on_flag_deleted: ->(key) {
|
|
366
|
-
on_segment_updated: ->(flags, segments) {
|
|
367
|
-
on_sync: ->(flags, segments) {
|
|
377
|
+
on_flag_updated: ->(flag) { apply_flag_update(flag) },
|
|
378
|
+
on_flag_deleted: ->(key) { apply_flag_delete(key) },
|
|
379
|
+
on_segment_updated: ->(flags, segments) { apply_snapshot(flags, segments) },
|
|
380
|
+
on_sync: ->(flags, segments) { apply_snapshot(flags, segments) },
|
|
368
381
|
on_error: ->(_err) { },
|
|
369
382
|
on_fallback_to_polling: -> { fallback_to_polling },
|
|
370
383
|
on_recovered: -> { stop_fallback_polling }
|
|
@@ -398,7 +411,7 @@ module Featureflip
|
|
|
398
411
|
@polling_handler = DataSource::PollingHandler.new(
|
|
399
412
|
http_client: @http_client,
|
|
400
413
|
config: @config,
|
|
401
|
-
on_update: ->(flags, segments) {
|
|
414
|
+
on_update: ->(flags, segments) { apply_snapshot(flags, segments) },
|
|
402
415
|
on_error: ->(_err) { }
|
|
403
416
|
)
|
|
404
417
|
@polling_handler.start
|
|
@@ -441,6 +454,41 @@ module Featureflip
|
|
|
441
454
|
@store.get_segment(key)
|
|
442
455
|
end
|
|
443
456
|
|
|
457
|
+
# Every data-source write funnels through one of these three so the change
|
|
458
|
+
# set is computed by the store and reported in one place.
|
|
459
|
+
def apply_snapshot(flags, segments)
|
|
460
|
+
notify_update_listeners(@store.init(flags, segments))
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def apply_flag_update(flag)
|
|
464
|
+
notify_update_listeners(@store.upsert(flag))
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def apply_flag_delete(key)
|
|
468
|
+
notify_update_listeners(@store.remove_flag(key))
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Runs on the streaming/polling thread, OUTSIDE the store lock: listeners are
|
|
472
|
+
# user code and may re-enter the client. Isolation mirrors
|
|
473
|
+
# notify_inspectors: everything is contained except the process-level errors
|
|
474
|
+
# in INSPECTOR_UNISOLATED_ERRORS, which no callback has the right to swallow.
|
|
475
|
+
def notify_update_listeners(keys)
|
|
476
|
+
return if keys.nil? || keys.empty?
|
|
477
|
+
|
|
478
|
+
listeners = @update_listeners_mutex.synchronize { @update_listeners.dup }
|
|
479
|
+
listeners.each do |listener|
|
|
480
|
+
begin
|
|
481
|
+
listener.call(keys.dup)
|
|
482
|
+
rescue *INSPECTOR_UNISOLATED_ERRORS, DataSource::StreamingHandler::StreamStopped
|
|
483
|
+
# StreamStopped is the stream's own shutdown signal (Thread#raise from
|
|
484
|
+
# StreamingHandler#stop), never a listener failure.
|
|
485
|
+
raise
|
|
486
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
487
|
+
@config.logger&.warn("Featureflip: flag update listener raised #{e.class}: #{e.message}")
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
444
492
|
def normalize_context(context)
|
|
445
493
|
return {} if context.nil?
|
|
446
494
|
context.transform_keys(&:to_s)
|
|
@@ -543,8 +591,12 @@ module Featureflip
|
|
|
543
591
|
@ref_count = 1
|
|
544
592
|
@ref_mutex = Mutex.new
|
|
545
593
|
@shut_down = false
|
|
594
|
+
@update_listeners = []
|
|
595
|
+
@update_listeners_mutex = Mutex.new
|
|
546
596
|
end
|
|
547
597
|
|
|
598
|
+
# wrapper_name/wrapper_version are deliberately excluded: they identify the caller
|
|
599
|
+
# rather than change behavior (see Config).
|
|
548
600
|
def self._configs_equal(a, b)
|
|
549
601
|
a.base_url == b.base_url &&
|
|
550
602
|
a.streaming == b.streaming &&
|
|
@@ -5,14 +5,22 @@ module Featureflip
|
|
|
5
5
|
@flags = {}
|
|
6
6
|
@segments = {}
|
|
7
7
|
@mutex = Mutex.new
|
|
8
|
+
# The first snapshot establishes the baseline and reports nothing: a cold
|
|
9
|
+
# start is not a change (the cross-SDK on_update contract).
|
|
10
|
+
@has_snapshot = false
|
|
8
11
|
end
|
|
9
12
|
|
|
13
|
+
# Replace the store with a full snapshot (initial fetch, poll tick, SSE
|
|
14
|
+
# `sync`, SSE segment refetch). Returns the changed flag keys, computed
|
|
15
|
+
# under the same lock as the replace so a concurrent writer can never make
|
|
16
|
+
# the reported change and the stored state disagree.
|
|
10
17
|
def init(flags, segments)
|
|
11
18
|
@mutex.synchronize do
|
|
12
|
-
@flags
|
|
13
|
-
@
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
changed = @has_snapshot ? UpdateDiff.diff_snapshot(@flags, @segments, flags, segments) : []
|
|
20
|
+
@flags = flags.to_h { |f| [f.key, f] }
|
|
21
|
+
@segments = segments.to_h { |s| [s.key, s] }
|
|
22
|
+
@has_snapshot = true
|
|
23
|
+
changed
|
|
16
24
|
end
|
|
17
25
|
end
|
|
18
26
|
|
|
@@ -42,16 +50,37 @@ module Featureflip
|
|
|
42
50
|
# pre-edit config until an SSE `sync` or reconnect.
|
|
43
51
|
#
|
|
44
52
|
# Re-applying an identical config is harmless; dropping a real one is not.
|
|
53
|
+
#
|
|
54
|
+
# Returns the changed keys: none when the delta is rejected or is an
|
|
55
|
+
# identical redelivery (reconnects and duplicate events re-send the same
|
|
56
|
+
# config, and reporting those would make on_update noisy).
|
|
45
57
|
def upsert(flag)
|
|
46
58
|
@mutex.synchronize do
|
|
47
59
|
existing = @flags[flag.key]
|
|
48
|
-
return if existing && existing.version > flag.version
|
|
60
|
+
return [] if existing && existing.version > flag.version
|
|
61
|
+
|
|
49
62
|
@flags[flag.key] = flag
|
|
63
|
+
return [] if existing == flag
|
|
64
|
+
|
|
65
|
+
with_prerequisite_dependents(flag.key)
|
|
50
66
|
end
|
|
51
67
|
end
|
|
52
68
|
|
|
69
|
+
# Returns the deleted key plus its dependents, or [] if it was absent.
|
|
53
70
|
def remove_flag(key)
|
|
54
|
-
@mutex.synchronize
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
return [] if @flags.delete(key).nil?
|
|
73
|
+
|
|
74
|
+
with_prerequisite_dependents(key)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Caller holds @mutex. Reads the post-mutation store, so a deleted flag's
|
|
81
|
+
# dependents, which still carry the dangling prerequisite row, are reached.
|
|
82
|
+
def with_prerequisite_dependents(key)
|
|
83
|
+
UpdateDiff.add_prerequisite_dependents(@flags.values, Set[key]).to_a.sort
|
|
55
84
|
end
|
|
56
85
|
end
|
|
57
86
|
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module Featureflip
|
|
4
|
+
module Store
|
|
5
|
+
# Change detection for Client#on_update.
|
|
6
|
+
#
|
|
7
|
+
# The store receives a *full* snapshot on every poll tick and every SSE
|
|
8
|
+
# `sync`, so notifying on each one would report "everything changed" once
|
|
9
|
+
# per poll interval. These functions reduce a snapshot to the flag keys whose
|
|
10
|
+
# evaluated value could actually have moved.
|
|
11
|
+
#
|
|
12
|
+
# A port of the python SDK's `_updates.py` (itself mirroring js-sdk
|
|
13
|
+
# `core/store.ts`). The semantics are cross-SDK contract, not an
|
|
14
|
+
# implementation detail.
|
|
15
|
+
module UpdateDiff
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Keys whose evaluated value may differ between two snapshots: added,
|
|
19
|
+
# removed and modified flags, then the flags a changed segment or a
|
|
20
|
+
# changed prerequisite drags along with them.
|
|
21
|
+
#
|
|
22
|
+
# Every model is a Struct, so `!=` is an exact config comparison.
|
|
23
|
+
# FlagConfiguration's memoised @variations_by_key is an ivar, not a
|
|
24
|
+
# member, so it does not take part.
|
|
25
|
+
def diff_snapshot(previous_flags, previous_segments, next_flags, next_segments)
|
|
26
|
+
changed = Set.new
|
|
27
|
+
|
|
28
|
+
next_flag_keys = Set.new
|
|
29
|
+
next_flags.each do |flag|
|
|
30
|
+
next_flag_keys << flag.key
|
|
31
|
+
previous = previous_flags[flag.key]
|
|
32
|
+
changed << flag.key if previous.nil? || previous != flag
|
|
33
|
+
end
|
|
34
|
+
# Removed server-side: still a change for anyone holding the old value.
|
|
35
|
+
previous_flags.each_key { |key| changed << key unless next_flag_keys.include?(key) }
|
|
36
|
+
|
|
37
|
+
changed_segments = Set.new
|
|
38
|
+
next_segment_keys = Set.new
|
|
39
|
+
next_segments.each do |segment|
|
|
40
|
+
next_segment_keys << segment.key
|
|
41
|
+
previous = previous_segments[segment.key]
|
|
42
|
+
changed_segments << segment.key if previous.nil? || previous != segment
|
|
43
|
+
end
|
|
44
|
+
previous_segments.each_key { |key| changed_segments << key unless next_segment_keys.include?(key) }
|
|
45
|
+
|
|
46
|
+
unless changed_segments.empty?
|
|
47
|
+
# A segment edit alters evaluation without bumping any flag's version,
|
|
48
|
+
# so the flags referencing it are pulled in by hand. Only incoming
|
|
49
|
+
# flags are scanned: a flag that dropped its reference to a changed
|
|
50
|
+
# segment had its own rules edited and is already reported above.
|
|
51
|
+
next_flags.each do |flag|
|
|
52
|
+
references = (flag.rules || []).any? do |rule|
|
|
53
|
+
rule.segment_key && changed_segments.include?(rule.segment_key)
|
|
54
|
+
end
|
|
55
|
+
changed << flag.key if references
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Must run last: it walks out from the fully-resolved set, so a flag only
|
|
60
|
+
# the segment scan pulled in still reaches its dependents.
|
|
61
|
+
add_prerequisite_dependents(next_flags, changed)
|
|
62
|
+
changed.to_a.sort
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Expands +changed+ (a Set, mutated and returned) with every transitive
|
|
66
|
+
# prerequisite dependent.
|
|
67
|
+
#
|
|
68
|
+
# A flag's version covers its own prerequisite rows, not the flags they
|
|
69
|
+
# point at, so toggling a prerequisite bumps only the prerequisite. The
|
|
70
|
+
# dependent's value still flips (to its off variation, PrerequisiteFailed),
|
|
71
|
+
# so it has to be reported by hand. Over-reporting is harmless; this
|
|
72
|
+
# prevents under-reporting.
|
|
73
|
+
#
|
|
74
|
+
# Pass the POST-change flags: a deleted flag's dependents are still
|
|
75
|
+
# present and still carry the now-dangling row, so they stay reachable.
|
|
76
|
+
def add_prerequisite_dependents(flags, changed)
|
|
77
|
+
return changed if changed.empty?
|
|
78
|
+
|
|
79
|
+
dependents = {}
|
|
80
|
+
flags.each do |flag|
|
|
81
|
+
(flag.prerequisites || []).each do |prerequisite|
|
|
82
|
+
key = prerequisite.prerequisite_flag_key
|
|
83
|
+
next if key.nil? || key.empty?
|
|
84
|
+
|
|
85
|
+
(dependents[key] ||= []) << flag.key
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
return changed if dependents.empty?
|
|
89
|
+
|
|
90
|
+
# `changed` doubles as the visited set, so a prerequisite cycle
|
|
91
|
+
# (rejected by the server, reachable via a malformed payload) terminates.
|
|
92
|
+
queue = changed.to_a
|
|
93
|
+
until queue.empty?
|
|
94
|
+
(dependents[queue.pop] || []).each do |dependent|
|
|
95
|
+
next if changed.include?(dependent)
|
|
96
|
+
|
|
97
|
+
changed << dependent
|
|
98
|
+
queue << dependent
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
changed
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/featureflip/version.rb
CHANGED
data/lib/featureflip.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require_relative "featureflip/version"
|
|
2
2
|
require_relative "featureflip/errors"
|
|
3
3
|
require_relative "featureflip/config"
|
|
4
|
+
require_relative "featureflip/sdk_identity"
|
|
4
5
|
require_relative "featureflip/models/flag"
|
|
5
6
|
require_relative "featureflip/models/segment"
|
|
6
7
|
require_relative "featureflip/models/evaluation_detail"
|
|
@@ -8,6 +9,7 @@ require_relative "featureflip/models/evaluation_event"
|
|
|
8
9
|
require_relative "featureflip/evaluation/bucketing"
|
|
9
10
|
require_relative "featureflip/evaluation/condition_evaluator"
|
|
10
11
|
require_relative "featureflip/evaluation/evaluator"
|
|
12
|
+
require_relative "featureflip/store/update_diff"
|
|
11
13
|
require_relative "featureflip/store/flag_store"
|
|
12
14
|
require_relative "featureflip/http/client"
|
|
13
15
|
require_relative "featureflip/events/event"
|
|
@@ -85,6 +87,11 @@ module Featureflip
|
|
|
85
87
|
@default_client.restart
|
|
86
88
|
end
|
|
87
89
|
|
|
90
|
+
def on_update(listener = nil, &block)
|
|
91
|
+
ensure_configured!
|
|
92
|
+
@default_client.on_update(listener, &block)
|
|
93
|
+
end
|
|
94
|
+
|
|
88
95
|
private
|
|
89
96
|
|
|
90
97
|
def ensure_configured!
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: featureflip
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Featureflip
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|
|
@@ -88,8 +88,10 @@ files:
|
|
|
88
88
|
- lib/featureflip/models/evaluation_event.rb
|
|
89
89
|
- lib/featureflip/models/flag.rb
|
|
90
90
|
- lib/featureflip/models/segment.rb
|
|
91
|
+
- lib/featureflip/sdk_identity.rb
|
|
91
92
|
- lib/featureflip/shared_core.rb
|
|
92
93
|
- lib/featureflip/store/flag_store.rb
|
|
94
|
+
- lib/featureflip/store/update_diff.rb
|
|
93
95
|
- lib/featureflip/version.rb
|
|
94
96
|
homepage: https://featureflip.io
|
|
95
97
|
licenses:
|