featureflip 2.9.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 +14 -0
- data/lib/featureflip/shared_core.rb +55 -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 +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
|
|
@@ -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
|
|
@@ -163,7 +170,12 @@ module Featureflip
|
|
|
163
170
|
reset_stream_parser
|
|
164
171
|
response.read_body do |chunk|
|
|
165
172
|
break if @stop_flag
|
|
173
|
+
|
|
166
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
|
|
167
179
|
end
|
|
168
180
|
end
|
|
169
181
|
|
|
@@ -183,6 +195,8 @@ module Featureflip
|
|
|
183
195
|
def feed_chunk(chunk)
|
|
184
196
|
@line_buffer << chunk
|
|
185
197
|
while (newline_index = @line_buffer.index("\n"))
|
|
198
|
+
break if @stop_flag
|
|
199
|
+
|
|
186
200
|
line = @line_buffer.slice!(0, newline_index + 1)
|
|
187
201
|
process_sse_line(line.chomp.force_encoding(Encoding::UTF_8))
|
|
188
202
|
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,6 +591,8 @@ 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
|
|
|
548
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:
|