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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4dfdc2e9de3795a36a917d2a748184853bbd1c30362dd313ac70e386a4d0758c
4
- data.tar.gz: e878ef6a553b2948435fc004b905f84015433b42625721d41311c5cb55e87654
3
+ metadata.gz: 33b15e24100b214989443fd146e3b5a4ef29c92ee28f457419c4ef0816e01efa
4
+ data.tar.gz: f638de9e949664b629c35771bce31f2dff71eb018133eeecc3a1d952ba037992
5
5
  SHA512:
6
- metadata.gz: 871ae6a42bfcc7663a805a70935e2ac6b5a746a879df937343239f5f833c00be0990890f0ec47684a1beca0507160439274e26a606ef957510a3aa400d442929
7
- data.tar.gz: d793281fb6b00d5cfeb5226c89c035fa17d6fad5d7af1876eac1c7d8c4c1290452c2067b6b9c8106795e4511451e43ef4debc399e660b245b3f7685507c494af
6
+ metadata.gz: 0c3da771ddf9cb78f4a73f890d03d178047f78dfdf0a1347a52ca82dd1e3721f79b3249856ea1413068aa924b1f5d9acf9a8c2449b79731134508b9f4701714f
7
+ data.tar.gz: f73db84108ad650e6812868b217fc3b981ba0cdbc2985de312ef94d2fc88d8fdb2d24cc638a5c24837b3598271f7a49005ed5ba1b9f428778099909042b5457b
@@ -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
- @thread&.wakeup rescue nil
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) { @store.upsert(flag) },
365
- on_flag_deleted: ->(key) { @store.remove_flag(key) },
366
- on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
367
- on_sync: ->(flags, segments) { @store.init(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) { @store.init(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.clear
13
- @segments.clear
14
- flags.each { |f| @flags[f.key] = f }
15
- segments.each { |s| @segments[s.key] = s }
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 { @flags.delete(key) }
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
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.9.0"
2
+ VERSION = "2.10.0"
3
3
  end
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.9.0
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-09-18 00:00:00.000000000 Z
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: