featureflip 2.7.0 → 2.9.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: 3d0798d6f9dde45580a5345467cfcbc108b01a0bda1ed9c0224a6e58305ec9bd
4
- data.tar.gz: d7a7ceca0d72477e0987ee83b0af3bdcee069f47add6ecc89a42bb7c9cb635e4
3
+ metadata.gz: 4dfdc2e9de3795a36a917d2a748184853bbd1c30362dd313ac70e386a4d0758c
4
+ data.tar.gz: e878ef6a553b2948435fc004b905f84015433b42625721d41311c5cb55e87654
5
5
  SHA512:
6
- metadata.gz: 2546ac239e8b8c0b120caa1d8286ad9a58e4fe23900a58cb63343ad469e9e324548ebac72ea194c102c023e5de2ef2e88fd344c80d15beb49dfc18803a3bd711
7
- data.tar.gz: 6393ec68c36a42813a537fa699e9801132606d273264c142b8d75082c0fc7d260160b27aac53306d49a14bd2d895de2da57e8284690c8affb031076a76500a47
6
+ metadata.gz: 871ae6a42bfcc7663a805a70935e2ac6b5a746a879df937343239f5f833c00be0990890f0ec47684a1beca0507160439274e26a606ef957510a3aa400d442929
7
+ data.tar.gz: d793281fb6b00d5cfeb5226c89c035fa17d6fad5d7af1876eac1c7d8c4c1290452c2067b6b9c8106795e4511451e43ef4debc399e660b245b3f7685507c494af
@@ -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, on_give_up: 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
- @on_give_up = on_give_up
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
 
@@ -112,9 +117,14 @@ module Featureflip
112
117
  # purposes — otherwise an accept-then-close server never accumulates
113
118
  # toward max_stream_retries and never degrades to polling.
114
119
  @retry_count += 1
115
- if @retry_count > @config.max_stream_retries
116
- @on_give_up&.call
117
- break
120
+ # The fallback is ADDITIVE, never terminal (#3071). Polling covers the
121
+ # outage; this loop keeps retrying the stream underneath at the capped
122
+ # backoff, and the next delivered frame retires the poller. Breaking out
123
+ # here left the process polling — and blind to real-time updates — until
124
+ # it restarted, after only ~31s of unreachability.
125
+ if @retry_count > @config.max_stream_retries && !@fallback_active
126
+ @fallback_active = true
127
+ @on_fallback_to_polling&.call
118
128
  end
119
129
  end
120
130
 
@@ -189,6 +199,14 @@ module Featureflip
189
199
  @current_data = @current_data.nil? ? fragment : "#{@current_data}\n#{fragment}"
190
200
  elsif line.empty? && @current_event_type && @current_data
191
201
  @delivered_frame = true
202
+ # Retire the fallback poller HERE rather than when run() next comes round:
203
+ # connect() blocks in read_body for the whole lifetime of a healthy stream,
204
+ # so a reap on return would leave the poller alive that entire time, and its
205
+ # periodic whole-store replaces would revert deltas applied by this stream.
206
+ if @fallback_active
207
+ @fallback_active = false
208
+ @on_recovered&.call
209
+ end
192
210
  handle_event(@current_event_type, @current_data)
193
211
  @current_event_type = nil
194
212
  @current_data = nil
@@ -14,14 +14,19 @@ module Featureflip
14
14
 
15
15
  return condition.negate if attr_value.nil?
16
16
 
17
+ # Resolve the operator ONCE (#2374). Every subsequent decision that keys
18
+ # off it -- numeric coercion, dispatch -- must read the same normalised
19
+ # label, or a mis-cased operator takes some paths and not others.
20
+ operator = normalize_operator(condition.operator)
21
+
17
22
  # Issue #1458: when the attribute is a native numeric (Integer/Float —
18
23
  # Ruby's `true`/`false` are NOT Numeric, so booleans are naturally
19
24
  # excluded), the equality-family operators coerce the condition values to
20
25
  # numbers and compare numerically, so 1.0 matches "1". This mirrors the
21
26
  # engine's type-aware path and runs BEFORE stringification — a String
22
27
  # attribute (even "1.0") stays on the string path below.
23
- if attr_value.is_a?(Numeric) && NUMERIC_EQUALITY_OPERATORS.include?(condition.operator)
24
- return evaluate_numeric_equality(condition, attr_value)
28
+ if attr_value.is_a?(Numeric) && NUMERIC_EQUALITY_OPERATORS.include?(operator)
29
+ return evaluate_numeric_equality(condition, operator, attr_value)
25
30
  end
26
31
 
27
32
  # Pass the raw (case-preserved) strings to the operator dispatcher.
@@ -31,7 +36,7 @@ module Featureflip
31
36
  str_value = attr_value.to_s
32
37
  targets = condition.values.map(&:to_s)
33
38
 
34
- result = evaluate_operator(condition.operator, str_value, targets)
39
+ result = evaluate_operator(operator, str_value, targets)
35
40
 
36
41
  # Issue #2262: an unrecognised operator fails CLOSED. `!nil` is `true`
37
42
  # in Ruby, so without this guard a negated unknown operator would match
@@ -66,7 +71,7 @@ module Featureflip
66
71
  # The equality-family operators that get type-aware numeric coercion when
67
72
  # the attribute is a native Numeric (Issue #1458). Relational/string ops
68
73
  # are deliberately excluded — only these four coerce.
69
- NUMERIC_EQUALITY_OPERATORS = %w[Equals NotEquals In NotIn].freeze
74
+ NUMERIC_EQUALITY_OPERATORS = %w[equals notequals in notin].freeze
70
75
  private_constant :NUMERIC_EQUALITY_OPERATORS
71
76
 
72
77
  # A parsed semantic version: the release core as dot-separated numeric
@@ -105,29 +110,47 @@ module Featureflip
105
110
  DAYS_IN_MONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
106
111
  private_constant :DAYS_IN_MONTH
107
112
 
113
+ # The single definition of "recognised operator" shared by the four
114
+ # string-typed SDKs (js, go, ruby, php) -- see #2374. Strips underscores
115
+ # and folds case, so the canonical PascalCase the API emits ("NotEquals"),
116
+ # the concatenated form go accepted ("notequals", "NOTEQUALS") and the
117
+ # snake_case form php accepted ("not_equals") all resolve to one label.
118
+ #
119
+ # This SDK previously matched the PascalCase labels EXACTLY, so every
120
+ # mis-cased spelling was simply unknown and failed closed. Normalising by
121
+ # removal makes the shared rule a superset of what each SDK accepted
122
+ # before, so no SDK gets stricter and no configuration that evaluated
123
+ # before stops doing so. The concatenated labels stay unambiguous under
124
+ # this mapping -- no two operator names collide once underscores go.
125
+ def normalize_operator(operator)
126
+ operator.to_s.delete("_").downcase
127
+ end
128
+
129
+ # `operator` arrives already normalised, so the labels below are the
130
+ # concatenated lowercase form rather than the wire PascalCase.
108
131
  def evaluate_operator(operator, value, targets)
109
132
  # Case-insensitive views for the string/relational/date operators.
110
133
  ci_value = value.downcase
111
134
  ci_targets = targets.map(&:downcase)
112
135
 
113
136
  case operator
114
- when "Equals"
137
+ when "equals"
115
138
  ci_targets.any? { |t| ci_value == t }
116
- when "NotEquals"
139
+ when "notequals"
117
140
  ci_targets.all? { |t| ci_value != t }
118
- when "Contains"
141
+ when "contains"
119
142
  ci_targets.any? { |t| ci_value.include?(t) }
120
- when "NotContains"
143
+ when "notcontains"
121
144
  ci_targets.all? { |t| !ci_value.include?(t) }
122
- when "StartsWith"
145
+ when "startswith"
123
146
  ci_targets.any? { |t| ci_value.start_with?(t) }
124
- when "EndsWith"
147
+ when "endswith"
125
148
  ci_targets.any? { |t| ci_value.end_with?(t) }
126
- when "In"
149
+ when "in"
127
150
  ci_targets.include?(ci_value)
128
- when "NotIn"
151
+ when "notin"
129
152
  !ci_targets.include?(ci_value)
130
- when "MatchesRegex"
153
+ when "matchesregex"
131
154
  # Case-sensitive matching on the original-case value and pattern,
132
155
  # mirroring the engine (RegexOptions.None). Case-insensitivity is
133
156
  # opt-in via the (?i) inline flag in the pattern.
@@ -145,13 +168,13 @@ module Featureflip
145
168
  # against ANY condition value (mirroring the server engine), not just
146
169
  # values[0]. `.any?` over an empty array is false, so empty values
147
170
  # returns false without error.
148
- when "GreaterThan"
171
+ when "greaterthan"
149
172
  ci_targets.any? { |t| compare_numeric(ci_value, t, :>) }
150
- when "GreaterThanOrEqual"
173
+ when "greaterthanorequal"
151
174
  ci_targets.any? { |t| compare_numeric(ci_value, t, :>=) }
152
- when "LessThan"
175
+ when "lessthan"
153
176
  ci_targets.any? { |t| compare_numeric(ci_value, t, :<) }
154
- when "LessThanOrEqual"
177
+ when "lessthanorequal"
155
178
  ci_targets.any? { |t| compare_numeric(ci_value, t, :<=) }
156
179
  # Date operators compare against the RAW value/targets, not the
157
180
  # lowercased copies: downcasing breaks ISO-8601 parsing (the "Z" UTC
@@ -159,23 +182,23 @@ module Featureflip
159
182
  # to UTC instants — offsets are honored, no-offset strings are assumed
160
183
  # UTC, and a bare integer is treated as Unix seconds — so an unparseable
161
184
  # operand matches nothing instead of falling back to a string compare.
162
- when "Before"
185
+ when "before"
163
186
  targets.any? { |t| compare_datetime(value, t, :<) }
164
- when "After"
187
+ when "after"
165
188
  targets.any? { |t| compare_datetime(value, t, :>) }
166
189
  # Semantic-version operators compare against the RAW value/targets:
167
190
  # prerelease precedence is case-sensitive (semver §11), so the casing
168
191
  # preserved by `evaluate_condition` must not be folded here. An
169
192
  # unparseable version matches nothing, like the numeric/date operators.
170
- when "SemverEquals"
193
+ when "semverequals"
171
194
  targets.any? { |t| compare_semver(value, t, :==) }
172
- when "SemverGreaterThan"
195
+ when "semvergreaterthan"
173
196
  targets.any? { |t| compare_semver(value, t, :>) }
174
- when "SemverGreaterThanOrEqual"
197
+ when "semvergreaterthanorequal"
175
198
  targets.any? { |t| compare_semver(value, t, :>=) }
176
- when "SemverLessThan"
199
+ when "semverlessthan"
177
200
  targets.any? { |t| compare_semver(value, t, :<) }
178
- when "SemverLessThanOrEqual"
201
+ when "semverlessthanorequal"
179
202
  targets.any? { |t| compare_semver(value, t, :<=) }
180
203
  else
181
204
  # Unrecognised operator. `nil` — NOT `false` — so the caller can tell
@@ -190,21 +213,21 @@ module Featureflip
190
213
  # numerically against the attribute. Equals/In match if ANY value is equal;
191
214
  # NotEquals/NotIn are their negation. The `negate` flag is then applied,
192
215
  # mirroring `evaluate_condition`'s tail.
193
- def evaluate_numeric_equality(condition, attr_value)
216
+ def evaluate_numeric_equality(condition, operator, attr_value)
194
217
  target = attr_value.to_f
195
218
  any_equal = condition.values.any? do |v|
196
219
  n = parse_numeric(v)
197
220
  n && n == target
198
221
  end
199
222
 
200
- positive = NUMERIC_EQUALITY_POSITIVE_OPERATORS.include?(condition.operator)
223
+ positive = NUMERIC_EQUALITY_POSITIVE_OPERATORS.include?(operator)
201
224
  result = positive ? any_equal : !any_equal
202
225
  condition.negate ? !result : result
203
226
  end
204
227
 
205
228
  # Equals/In are the "positive" members of the equality family (match on
206
229
  # equality); NotEquals/NotIn negate the same any-equal test.
207
- NUMERIC_EQUALITY_POSITIVE_OPERATORS = %w[Equals In].freeze
230
+ NUMERIC_EQUALITY_POSITIVE_OPERATORS = %w[equals in].freeze
208
231
  private_constant :NUMERIC_EQUALITY_POSITIVE_OPERATORS
209
232
 
210
233
  # Strict literal parse of a condition value to a Float, reusing the same
@@ -276,6 +276,11 @@ module Featureflip
276
276
  @streaming_handler&.stop
277
277
  @polling_handler&.stop
278
278
  @event_processor&.stop
279
+ # Clear both before restarting: start_polling refuses to start a second
280
+ # poller while one is referenced, so a stopped-but-referenced handler here
281
+ # would leave a restarted non-streaming core with no data source at all.
282
+ @streaming_handler = nil
283
+ @polling_handler = nil
279
284
 
280
285
  if @config.streaming
281
286
  start_streaming
@@ -361,18 +366,35 @@ module Featureflip
361
366
  on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
362
367
  on_sync: ->(flags, segments) { @store.init(flags, segments) },
363
368
  on_error: ->(_err) { },
364
- on_give_up: -> { fallback_to_polling }
369
+ on_fallback_to_polling: -> { fallback_to_polling },
370
+ on_recovered: -> { stop_fallback_polling }
365
371
  )
366
372
  @streaming_handler.start
367
373
  end
368
374
 
369
375
  def fallback_to_polling
370
- @config.logger&.warn("Featureflip: streaming retries exhausted, falling back to polling")
371
- @streaming_handler = nil
376
+ @config.logger&.warn(
377
+ "Featureflip: streaming retries exhausted, falling back to polling while the stream keeps retrying"
378
+ )
379
+ # The handler is NOT cleared: it is still running and still trying to reopen
380
+ # the stream (#3071). Clearing it here is what used to make the fallback
381
+ # permanent — nothing would have restarted streaming.
372
382
  start_polling
373
383
  end
374
384
 
385
+ def stop_fallback_polling
386
+ return if @polling_handler.nil?
387
+
388
+ @config.logger&.info("Featureflip: stream recovered, stopping polling fallback")
389
+ @polling_handler.stop
390
+ @polling_handler = nil
391
+ end
392
+
375
393
  def start_polling
394
+ # Reachable from both start_data_source and the streaming fallback; a second
395
+ # call must not leak a second polling thread.
396
+ return if @polling_handler
397
+
376
398
  @polling_handler = DataSource::PollingHandler.new(
377
399
  http_client: @http_client,
378
400
  config: @config,
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.7.0"
2
+ VERSION = "2.9.0"
3
3
  end
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.7.0
4
+ version: 2.9.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-08-26 00:00:00.000000000 Z
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger