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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4dfdc2e9de3795a36a917d2a748184853bbd1c30362dd313ac70e386a4d0758c
|
|
4
|
+
data.tar.gz: e878ef6a553b2948435fc004b905f84015433b42625721d41311c5cb55e87654
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
|
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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?(
|
|
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(
|
|
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[
|
|
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 "
|
|
137
|
+
when "equals"
|
|
115
138
|
ci_targets.any? { |t| ci_value == t }
|
|
116
|
-
when "
|
|
139
|
+
when "notequals"
|
|
117
140
|
ci_targets.all? { |t| ci_value != t }
|
|
118
|
-
when "
|
|
141
|
+
when "contains"
|
|
119
142
|
ci_targets.any? { |t| ci_value.include?(t) }
|
|
120
|
-
when "
|
|
143
|
+
when "notcontains"
|
|
121
144
|
ci_targets.all? { |t| !ci_value.include?(t) }
|
|
122
|
-
when "
|
|
145
|
+
when "startswith"
|
|
123
146
|
ci_targets.any? { |t| ci_value.start_with?(t) }
|
|
124
|
-
when "
|
|
147
|
+
when "endswith"
|
|
125
148
|
ci_targets.any? { |t| ci_value.end_with?(t) }
|
|
126
|
-
when "
|
|
149
|
+
when "in"
|
|
127
150
|
ci_targets.include?(ci_value)
|
|
128
|
-
when "
|
|
151
|
+
when "notin"
|
|
129
152
|
!ci_targets.include?(ci_value)
|
|
130
|
-
when "
|
|
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 "
|
|
171
|
+
when "greaterthan"
|
|
149
172
|
ci_targets.any? { |t| compare_numeric(ci_value, t, :>) }
|
|
150
|
-
when "
|
|
173
|
+
when "greaterthanorequal"
|
|
151
174
|
ci_targets.any? { |t| compare_numeric(ci_value, t, :>=) }
|
|
152
|
-
when "
|
|
175
|
+
when "lessthan"
|
|
153
176
|
ci_targets.any? { |t| compare_numeric(ci_value, t, :<) }
|
|
154
|
-
when "
|
|
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 "
|
|
185
|
+
when "before"
|
|
163
186
|
targets.any? { |t| compare_datetime(value, t, :<) }
|
|
164
|
-
when "
|
|
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 "
|
|
193
|
+
when "semverequals"
|
|
171
194
|
targets.any? { |t| compare_semver(value, t, :==) }
|
|
172
|
-
when "
|
|
195
|
+
when "semvergreaterthan"
|
|
173
196
|
targets.any? { |t| compare_semver(value, t, :>) }
|
|
174
|
-
when "
|
|
197
|
+
when "semvergreaterthanorequal"
|
|
175
198
|
targets.any? { |t| compare_semver(value, t, :>=) }
|
|
176
|
-
when "
|
|
199
|
+
when "semverlessthan"
|
|
177
200
|
targets.any? { |t| compare_semver(value, t, :<) }
|
|
178
|
-
when "
|
|
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?(
|
|
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[
|
|
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
|
-
|
|
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(
|
|
371
|
-
|
|
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,
|
data/lib/featureflip/version.rb
CHANGED
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.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-
|
|
11
|
+
date: 2026-09-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|