featureflip 2.8.0 → 2.10.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/data_source/polling.rb +7 -2
- data/lib/featureflip/data_source/streaming.rb +37 -5
- data/lib/featureflip/shared_core.rb +80 -8
- 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 +6 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 33b15e24100b214989443fd146e3b5a4ef29c92ee28f457419c4ef0816e01efa
|
|
4
|
+
data.tar.gz: f638de9e949664b629c35771bce31f2dff71eb018133eeecc3a1d952ba037992
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c3da771ddf9cb78f4a73f890d03d178047f78dfdf0a1347a52ca82dd1e3721f79b3249856ea1413068aa924b1f5d9acf9a8c2449b79731134508b9f4701714f
|
|
7
|
+
data.tar.gz: f73db84108ad650e6812868b217fc3b981ba0cdbc2985de312ef94d2fc88d8fdb2d24cc638a5c24837b3598271f7a49005ed5ba1b9f428778099909042b5457b
|
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
|
|
@@ -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
|
|
@@ -36,7 +36,7 @@ module Featureflip
|
|
|
36
36
|
RECONNECT_BASE_DELAY_SECONDS = 1
|
|
37
37
|
MAX_BACKOFF_SECONDS = 30
|
|
38
38
|
|
|
39
|
-
def initialize(sdk_key:, config:, http_client:, on_flag_updated:, on_flag_deleted:, on_segment_updated:, on_error:, on_sync: nil,
|
|
39
|
+
def initialize(sdk_key:, config:, http_client:, on_flag_updated:, on_flag_deleted:, on_segment_updated:, on_error:, on_sync: nil, on_fallback_to_polling: nil, on_recovered: nil)
|
|
40
40
|
@sdk_key = sdk_key
|
|
41
41
|
@config = config
|
|
42
42
|
@http_client = http_client
|
|
@@ -45,10 +45,14 @@ module Featureflip
|
|
|
45
45
|
@on_segment_updated = on_segment_updated
|
|
46
46
|
@on_error = on_error
|
|
47
47
|
@on_sync = on_sync
|
|
48
|
-
@
|
|
48
|
+
@on_fallback_to_polling = on_fallback_to_polling
|
|
49
|
+
@on_recovered = on_recovered
|
|
49
50
|
@stop_flag = false
|
|
50
51
|
@thread = nil
|
|
51
52
|
@retry_count = 0
|
|
53
|
+
# True between arming the polling fallback and the next delivered frame.
|
|
54
|
+
# Only ever touched from the streaming thread.
|
|
55
|
+
@fallback_active = false
|
|
52
56
|
@current_event_type = nil
|
|
53
57
|
@current_data = nil
|
|
54
58
|
@line_buffer = String.new # ASCII-8BIT: raw read_body bytes concatenate safely
|
|
@@ -60,6 +64,7 @@ module Featureflip
|
|
|
60
64
|
def start
|
|
61
65
|
@stop_flag = false
|
|
62
66
|
@retry_count = 0
|
|
67
|
+
@fallback_active = false
|
|
63
68
|
@thread = Thread.new { run }
|
|
64
69
|
end
|
|
65
70
|
|
|
@@ -72,6 +77,13 @@ module Featureflip
|
|
|
72
77
|
@thread = nil
|
|
73
78
|
return unless thread
|
|
74
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
|
+
|
|
75
87
|
# Interrupt a thread blocked in read_body. Guard the raise: the thread may
|
|
76
88
|
# finish between the alive? check and the raise (ThreadError on a dead one).
|
|
77
89
|
begin
|
|
@@ -112,9 +124,14 @@ module Featureflip
|
|
|
112
124
|
# purposes — otherwise an accept-then-close server never accumulates
|
|
113
125
|
# toward max_stream_retries and never degrades to polling.
|
|
114
126
|
@retry_count += 1
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
# The fallback is ADDITIVE, never terminal (#3071). Polling covers the
|
|
128
|
+
# outage; this loop keeps retrying the stream underneath at the capped
|
|
129
|
+
# backoff, and the next delivered frame retires the poller. Breaking out
|
|
130
|
+
# here left the process polling — and blind to real-time updates — until
|
|
131
|
+
# it restarted, after only ~31s of unreachability.
|
|
132
|
+
if @retry_count > @config.max_stream_retries && !@fallback_active
|
|
133
|
+
@fallback_active = true
|
|
134
|
+
@on_fallback_to_polling&.call
|
|
118
135
|
end
|
|
119
136
|
end
|
|
120
137
|
|
|
@@ -153,7 +170,12 @@ module Featureflip
|
|
|
153
170
|
reset_stream_parser
|
|
154
171
|
response.read_body do |chunk|
|
|
155
172
|
break if @stop_flag
|
|
173
|
+
|
|
156
174
|
feed_chunk(chunk)
|
|
175
|
+
# A listener on this thread may have stopped the handler while
|
|
176
|
+
# feed_chunk dispatched. Leave read_body now, which abandons the SSE
|
|
177
|
+
# connection, instead of blocking on the socket for the next chunk.
|
|
178
|
+
break if @stop_flag
|
|
157
179
|
end
|
|
158
180
|
end
|
|
159
181
|
|
|
@@ -173,6 +195,8 @@ module Featureflip
|
|
|
173
195
|
def feed_chunk(chunk)
|
|
174
196
|
@line_buffer << chunk
|
|
175
197
|
while (newline_index = @line_buffer.index("\n"))
|
|
198
|
+
break if @stop_flag
|
|
199
|
+
|
|
176
200
|
line = @line_buffer.slice!(0, newline_index + 1)
|
|
177
201
|
process_sse_line(line.chomp.force_encoding(Encoding::UTF_8))
|
|
178
202
|
end
|
|
@@ -189,6 +213,14 @@ module Featureflip
|
|
|
189
213
|
@current_data = @current_data.nil? ? fragment : "#{@current_data}\n#{fragment}"
|
|
190
214
|
elsif line.empty? && @current_event_type && @current_data
|
|
191
215
|
@delivered_frame = true
|
|
216
|
+
# Retire the fallback poller HERE rather than when run() next comes round:
|
|
217
|
+
# connect() blocks in read_body for the whole lifetime of a healthy stream,
|
|
218
|
+
# so a reap on return would leave the poller alive that entire time, and its
|
|
219
|
+
# periodic whole-store replaces would revert deltas applied by this stream.
|
|
220
|
+
if @fallback_active
|
|
221
|
+
@fallback_active = false
|
|
222
|
+
@on_recovered&.call
|
|
223
|
+
end
|
|
192
224
|
handle_event(@current_event_type, @current_data)
|
|
193
225
|
@current_event_type = nil
|
|
194
226
|
@current_data = nil
|
|
@@ -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
|
|
@@ -276,6 +289,11 @@ module Featureflip
|
|
|
276
289
|
@streaming_handler&.stop
|
|
277
290
|
@polling_handler&.stop
|
|
278
291
|
@event_processor&.stop
|
|
292
|
+
# Clear both before restarting: start_polling refuses to start a second
|
|
293
|
+
# poller while one is referenced, so a stopped-but-referenced handler here
|
|
294
|
+
# would leave a restarted non-streaming core with no data source at all.
|
|
295
|
+
@streaming_handler = nil
|
|
296
|
+
@polling_handler = nil
|
|
279
297
|
|
|
280
298
|
if @config.streaming
|
|
281
299
|
start_streaming
|
|
@@ -356,27 +374,44 @@ module Featureflip
|
|
|
356
374
|
sdk_key: @sdk_key,
|
|
357
375
|
config: @config,
|
|
358
376
|
http_client: @http_client,
|
|
359
|
-
on_flag_updated: ->(flag) {
|
|
360
|
-
on_flag_deleted: ->(key) {
|
|
361
|
-
on_segment_updated: ->(flags, segments) {
|
|
362
|
-
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) },
|
|
363
381
|
on_error: ->(_err) { },
|
|
364
|
-
|
|
382
|
+
on_fallback_to_polling: -> { fallback_to_polling },
|
|
383
|
+
on_recovered: -> { stop_fallback_polling }
|
|
365
384
|
)
|
|
366
385
|
@streaming_handler.start
|
|
367
386
|
end
|
|
368
387
|
|
|
369
388
|
def fallback_to_polling
|
|
370
|
-
@config.logger&.warn(
|
|
371
|
-
|
|
389
|
+
@config.logger&.warn(
|
|
390
|
+
"Featureflip: streaming retries exhausted, falling back to polling while the stream keeps retrying"
|
|
391
|
+
)
|
|
392
|
+
# The handler is NOT cleared: it is still running and still trying to reopen
|
|
393
|
+
# the stream (#3071). Clearing it here is what used to make the fallback
|
|
394
|
+
# permanent — nothing would have restarted streaming.
|
|
372
395
|
start_polling
|
|
373
396
|
end
|
|
374
397
|
|
|
398
|
+
def stop_fallback_polling
|
|
399
|
+
return if @polling_handler.nil?
|
|
400
|
+
|
|
401
|
+
@config.logger&.info("Featureflip: stream recovered, stopping polling fallback")
|
|
402
|
+
@polling_handler.stop
|
|
403
|
+
@polling_handler = nil
|
|
404
|
+
end
|
|
405
|
+
|
|
375
406
|
def start_polling
|
|
407
|
+
# Reachable from both start_data_source and the streaming fallback; a second
|
|
408
|
+
# call must not leak a second polling thread.
|
|
409
|
+
return if @polling_handler
|
|
410
|
+
|
|
376
411
|
@polling_handler = DataSource::PollingHandler.new(
|
|
377
412
|
http_client: @http_client,
|
|
378
413
|
config: @config,
|
|
379
|
-
on_update: ->(flags, segments) {
|
|
414
|
+
on_update: ->(flags, segments) { apply_snapshot(flags, segments) },
|
|
380
415
|
on_error: ->(_err) { }
|
|
381
416
|
)
|
|
382
417
|
@polling_handler.start
|
|
@@ -419,6 +454,41 @@ module Featureflip
|
|
|
419
454
|
@store.get_segment(key)
|
|
420
455
|
end
|
|
421
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
|
+
|
|
422
492
|
def normalize_context(context)
|
|
423
493
|
return {} if context.nil?
|
|
424
494
|
context.transform_keys(&:to_s)
|
|
@@ -521,6 +591,8 @@ module Featureflip
|
|
|
521
591
|
@ref_count = 1
|
|
522
592
|
@ref_mutex = Mutex.new
|
|
523
593
|
@shut_down = false
|
|
594
|
+
@update_listeners = []
|
|
595
|
+
@update_listeners_mutex = Mutex.new
|
|
524
596
|
end
|
|
525
597
|
|
|
526
598
|
def self._configs_equal(a, b)
|
|
@@ -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
|
@@ -8,6 +8,7 @@ require_relative "featureflip/models/evaluation_event"
|
|
|
8
8
|
require_relative "featureflip/evaluation/bucketing"
|
|
9
9
|
require_relative "featureflip/evaluation/condition_evaluator"
|
|
10
10
|
require_relative "featureflip/evaluation/evaluator"
|
|
11
|
+
require_relative "featureflip/store/update_diff"
|
|
11
12
|
require_relative "featureflip/store/flag_store"
|
|
12
13
|
require_relative "featureflip/http/client"
|
|
13
14
|
require_relative "featureflip/events/event"
|
|
@@ -85,6 +86,11 @@ module Featureflip
|
|
|
85
86
|
@default_client.restart
|
|
86
87
|
end
|
|
87
88
|
|
|
89
|
+
def on_update(listener = nil, &block)
|
|
90
|
+
ensure_configured!
|
|
91
|
+
@default_client.on_update(listener, &block)
|
|
92
|
+
end
|
|
93
|
+
|
|
88
94
|
private
|
|
89
95
|
|
|
90
96
|
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.10.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-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|
|
@@ -90,6 +90,7 @@ files:
|
|
|
90
90
|
- lib/featureflip/models/segment.rb
|
|
91
91
|
- lib/featureflip/shared_core.rb
|
|
92
92
|
- lib/featureflip/store/flag_store.rb
|
|
93
|
+
- lib/featureflip/store/update_diff.rb
|
|
93
94
|
- lib/featureflip/version.rb
|
|
94
95
|
homepage: https://featureflip.io
|
|
95
96
|
licenses:
|