featureflip 2.1.0 → 2.2.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/evaluation/condition_evaluator.rb +286 -19
- data/lib/featureflip/evaluation/evaluator.rb +32 -7
- data/lib/featureflip/version.rb +1 -1
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fdefb980d91435aca01a6f6344804e172dedef867fd826c5ac9a017ca6416ca
|
|
4
|
+
data.tar.gz: d98b0cda83055b8b984b2ea370b9717f6800dd44202ddacf21944fa19b0dc194
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 360a193a8d0557ba71c45ce1c3f341d008f34f34996434328f7aa2c1da3eee9ef7cc531953ffbd21b8f83fa2674e750139c456854e59a322c0099fe04f76cc0f
|
|
7
|
+
data.tar.gz: f4f292e068e49a8688b94c530d5cce50a060f97c57742e5aaee3f4db02aa469b0623c8b3c95065c6293e0452b71a58f3dc57212b649bc78e66044dad025c24a6
|
|
@@ -1,13 +1,35 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
1
3
|
module Featureflip
|
|
2
4
|
module Evaluation
|
|
3
5
|
class ConditionEvaluator
|
|
6
|
+
# Per-regex match timeout (seconds) for MatchesRegex, mirroring the
|
|
7
|
+
# engine's 100ms RegexMatchTimeout guard against catastrophic
|
|
8
|
+
# backtracking / ReDoS (#1460). Requires Ruby >= 3.2 (gemspec floor),
|
|
9
|
+
# which added the Regexp `timeout:` keyword.
|
|
10
|
+
REGEX_TIMEOUT_SECONDS = 0.1
|
|
11
|
+
|
|
4
12
|
def evaluate_condition(condition, context)
|
|
5
13
|
attr_value = context[condition.attribute]
|
|
6
14
|
|
|
7
15
|
return condition.negate if attr_value.nil?
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
# Issue #1458: when the attribute is a native numeric (Integer/Float —
|
|
18
|
+
# Ruby's `true`/`false` are NOT Numeric, so booleans are naturally
|
|
19
|
+
# excluded), the equality-family operators coerce the condition values to
|
|
20
|
+
# numbers and compare numerically, so 1.0 matches "1". This mirrors the
|
|
21
|
+
# engine's type-aware path and runs BEFORE stringification — a String
|
|
22
|
+
# 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)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Pass the raw (case-preserved) strings to the operator dispatcher.
|
|
28
|
+
# Most operators compare case-insensitively and downcase internally, but
|
|
29
|
+
# the semver operators rely on case-sensitive prerelease precedence
|
|
30
|
+
# (semver §11), so the original casing must survive to that point.
|
|
31
|
+
str_value = attr_value.to_s
|
|
32
|
+
targets = condition.values.map(&:to_s)
|
|
11
33
|
|
|
12
34
|
result = evaluate_operator(condition.operator, str_value, targets)
|
|
13
35
|
condition.negate ? !result : result
|
|
@@ -33,49 +55,136 @@ module Featureflip
|
|
|
33
55
|
|
|
34
56
|
private
|
|
35
57
|
|
|
58
|
+
# The equality-family operators that get type-aware numeric coercion when
|
|
59
|
+
# the attribute is a native Numeric (Issue #1458). Relational/string ops
|
|
60
|
+
# are deliberately excluded — only these four coerce.
|
|
61
|
+
NUMERIC_EQUALITY_OPERATORS = %w[Equals NotEquals In NotIn].freeze
|
|
62
|
+
private_constant :NUMERIC_EQUALITY_OPERATORS
|
|
63
|
+
|
|
64
|
+
# A parsed semantic version: the release core as dot-separated numeric
|
|
65
|
+
# segments plus an optional dot-separated prerelease identifier list.
|
|
66
|
+
SemverVersion = Struct.new(:release, :prerelease)
|
|
67
|
+
private_constant :SemverVersion
|
|
68
|
+
|
|
69
|
+
# An ISO-8601 date-time with no timezone offset (no trailing "Z"/±hh:mm),
|
|
70
|
+
# so it must be assumed UTC before parsing.
|
|
71
|
+
ISO_NO_OFFSET = /\A\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?\z/
|
|
72
|
+
private_constant :ISO_NO_OFFSET
|
|
73
|
+
|
|
74
|
+
# A bare ISO-8601 calendar date ("2024-01-01") with no time component. The
|
|
75
|
+
# engine's DateTimeOffset.TryParse accepts these (midnight, assumed UTC),
|
|
76
|
+
# but Time.iso8601 rejects them, so they need their own midnight-UTC path.
|
|
77
|
+
ISO_DATE_ONLY = /\A\d{4}-\d{2}-\d{2}\z/
|
|
78
|
+
private_constant :ISO_DATE_ONLY
|
|
79
|
+
|
|
36
80
|
def evaluate_operator(operator, value, targets)
|
|
81
|
+
# Case-insensitive views for the string/relational/date operators.
|
|
82
|
+
ci_value = value.downcase
|
|
83
|
+
ci_targets = targets.map(&:downcase)
|
|
84
|
+
|
|
37
85
|
case operator
|
|
38
86
|
when "Equals"
|
|
39
|
-
|
|
87
|
+
ci_targets.any? { |t| ci_value == t }
|
|
40
88
|
when "NotEquals"
|
|
41
|
-
|
|
89
|
+
ci_targets.all? { |t| ci_value != t }
|
|
42
90
|
when "Contains"
|
|
43
|
-
|
|
91
|
+
ci_targets.any? { |t| ci_value.include?(t) }
|
|
44
92
|
when "NotContains"
|
|
45
|
-
|
|
93
|
+
ci_targets.all? { |t| !ci_value.include?(t) }
|
|
46
94
|
when "StartsWith"
|
|
47
|
-
|
|
95
|
+
ci_targets.any? { |t| ci_value.start_with?(t) }
|
|
48
96
|
when "EndsWith"
|
|
49
|
-
|
|
97
|
+
ci_targets.any? { |t| ci_value.end_with?(t) }
|
|
50
98
|
when "In"
|
|
51
|
-
|
|
99
|
+
ci_targets.include?(ci_value)
|
|
52
100
|
when "NotIn"
|
|
53
|
-
!
|
|
101
|
+
!ci_targets.include?(ci_value)
|
|
54
102
|
when "MatchesRegex"
|
|
103
|
+
# Case-sensitive matching on the original-case value and pattern,
|
|
104
|
+
# mirroring the engine (RegexOptions.None). Case-insensitivity is
|
|
105
|
+
# opt-in via the (?i) inline flag in the pattern.
|
|
106
|
+
#
|
|
107
|
+
# A per-regex timeout bounds catastrophic backtracking / ReDoS like
|
|
108
|
+
# the engine's 100ms guard (#1460). rescue RegexpError covers BOTH an
|
|
109
|
+
# invalid pattern AND Regexp::TimeoutError (a RegexpError subclass),
|
|
110
|
+
# so either fails safe to no-match.
|
|
55
111
|
targets.any? do |t|
|
|
56
|
-
Regexp.new(t,
|
|
112
|
+
Regexp.new(t, timeout: REGEX_TIMEOUT_SECONDS).match?(value)
|
|
57
113
|
rescue RegexpError
|
|
58
114
|
false
|
|
59
115
|
end
|
|
116
|
+
# Relational operators match if the value satisfies the comparison
|
|
117
|
+
# against ANY condition value (mirroring the server engine), not just
|
|
118
|
+
# values[0]. `.any?` over an empty array is false, so empty values
|
|
119
|
+
# returns false without error.
|
|
60
120
|
when "GreaterThan"
|
|
61
|
-
compare_numeric(
|
|
121
|
+
ci_targets.any? { |t| compare_numeric(ci_value, t, :>) }
|
|
62
122
|
when "GreaterThanOrEqual"
|
|
63
|
-
compare_numeric(
|
|
123
|
+
ci_targets.any? { |t| compare_numeric(ci_value, t, :>=) }
|
|
64
124
|
when "LessThan"
|
|
65
|
-
compare_numeric(
|
|
125
|
+
ci_targets.any? { |t| compare_numeric(ci_value, t, :<) }
|
|
66
126
|
when "LessThanOrEqual"
|
|
67
|
-
compare_numeric(
|
|
127
|
+
ci_targets.any? { |t| compare_numeric(ci_value, t, :<=) }
|
|
128
|
+
# Date operators compare against the RAW value/targets, not the
|
|
129
|
+
# lowercased copies: downcasing breaks ISO-8601 parsing (the "Z" UTC
|
|
130
|
+
# designator becomes "z", which is not valid). Both operands are parsed
|
|
131
|
+
# to UTC instants — offsets are honored, no-offset strings are assumed
|
|
132
|
+
# UTC, and a bare integer is treated as Unix seconds — so an unparseable
|
|
133
|
+
# operand matches nothing instead of falling back to a string compare.
|
|
68
134
|
when "Before"
|
|
69
|
-
|
|
70
|
-
value < targets[0]
|
|
135
|
+
targets.any? { |t| compare_datetime(value, t, :<) }
|
|
71
136
|
when "After"
|
|
72
|
-
|
|
73
|
-
|
|
137
|
+
targets.any? { |t| compare_datetime(value, t, :>) }
|
|
138
|
+
# Semantic-version operators compare against the RAW value/targets:
|
|
139
|
+
# prerelease precedence is case-sensitive (semver §11), so the casing
|
|
140
|
+
# preserved by `evaluate_condition` must not be folded here. An
|
|
141
|
+
# unparseable version matches nothing, like the numeric/date operators.
|
|
142
|
+
when "SemverEquals"
|
|
143
|
+
targets.any? { |t| compare_semver(value, t, :==) }
|
|
144
|
+
when "SemverGreaterThan"
|
|
145
|
+
targets.any? { |t| compare_semver(value, t, :>) }
|
|
146
|
+
when "SemverGreaterThanOrEqual"
|
|
147
|
+
targets.any? { |t| compare_semver(value, t, :>=) }
|
|
148
|
+
when "SemverLessThan"
|
|
149
|
+
targets.any? { |t| compare_semver(value, t, :<) }
|
|
150
|
+
when "SemverLessThanOrEqual"
|
|
151
|
+
targets.any? { |t| compare_semver(value, t, :<=) }
|
|
74
152
|
else
|
|
75
153
|
false
|
|
76
154
|
end
|
|
77
155
|
end
|
|
78
156
|
|
|
157
|
+
# Type-aware numeric equality for a native-Numeric attribute (Issue #1458).
|
|
158
|
+
# Coerces each condition value with a strict literal parse and compares it
|
|
159
|
+
# numerically against the attribute. Equals/In match if ANY value is equal;
|
|
160
|
+
# NotEquals/NotIn are their negation. The `negate` flag is then applied,
|
|
161
|
+
# mirroring `evaluate_condition`'s tail.
|
|
162
|
+
def evaluate_numeric_equality(condition, attr_value)
|
|
163
|
+
target = attr_value.to_f
|
|
164
|
+
any_equal = condition.values.any? do |v|
|
|
165
|
+
n = parse_numeric(v)
|
|
166
|
+
n && n == target
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
positive = NUMERIC_EQUALITY_POSITIVE_OPERATORS.include?(condition.operator)
|
|
170
|
+
result = positive ? any_equal : !any_equal
|
|
171
|
+
condition.negate ? !result : result
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Equals/In are the "positive" members of the equality family (match on
|
|
175
|
+
# equality); NotEquals/NotIn negate the same any-equal test.
|
|
176
|
+
NUMERIC_EQUALITY_POSITIVE_OPERATORS = %w[Equals In].freeze
|
|
177
|
+
private_constant :NUMERIC_EQUALITY_POSITIVE_OPERATORS
|
|
178
|
+
|
|
179
|
+
# Strict literal parse of a condition value to a Float, reusing the same
|
|
180
|
+
# `Float()` approach as `compare_numeric`: a partial number like "1abc"
|
|
181
|
+
# raises and yields nil (no match), matching the engine's strict parse.
|
|
182
|
+
def parse_numeric(value)
|
|
183
|
+
Float(value)
|
|
184
|
+
rescue ArgumentError, TypeError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
79
188
|
def compare_numeric(value, target, op)
|
|
80
189
|
val = Float(value)
|
|
81
190
|
tgt = Float(target)
|
|
@@ -83,6 +192,164 @@ module Featureflip
|
|
|
83
192
|
rescue ArgumentError, TypeError
|
|
84
193
|
false
|
|
85
194
|
end
|
|
195
|
+
|
|
196
|
+
# Compares `value` and `target` as UTC date-time instants and tests the
|
|
197
|
+
# ordering against `op` (:< for Before, :> for After). Returns false when
|
|
198
|
+
# either side is not parseable, mirroring how `compare_numeric` treats
|
|
199
|
+
# non-numeric input (no lexical-string fallback).
|
|
200
|
+
def compare_datetime(value, target, op)
|
|
201
|
+
left = parse_datetime(value)
|
|
202
|
+
right = parse_datetime(target)
|
|
203
|
+
return false if left.nil? || right.nil?
|
|
204
|
+
|
|
205
|
+
left.send(op, right)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Parses a date-time to a UTC `Time`, mirroring the engine's
|
|
209
|
+
# TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
|
|
210
|
+
# without an offset is assumed UTC. A bare integer is treated as Unix time
|
|
211
|
+
# in seconds. Returns nil when the input parses as neither.
|
|
212
|
+
def parse_datetime(value)
|
|
213
|
+
s = value.to_s.strip
|
|
214
|
+
|
|
215
|
+
begin
|
|
216
|
+
# `Time.iso8601` honors offsets/"Z" but raises on a no-offset string;
|
|
217
|
+
# append the missing time/offset to assume midnight UTC for bare dates
|
|
218
|
+
# and UTC for offset-less date-times (mirroring DateTimeOffset.TryParse
|
|
219
|
+
# with AssumeUniversal).
|
|
220
|
+
iso =
|
|
221
|
+
if s.match?(ISO_DATE_ONLY)
|
|
222
|
+
"#{s}T00:00:00Z"
|
|
223
|
+
elsif s.match?(ISO_NO_OFFSET)
|
|
224
|
+
"#{s}Z"
|
|
225
|
+
else
|
|
226
|
+
s
|
|
227
|
+
end
|
|
228
|
+
return Time.iso8601(iso).utc
|
|
229
|
+
rescue ArgumentError
|
|
230
|
+
# Fall through to the Unix-seconds fallback.
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Integer fallback: treat a bare integer as Unix time in seconds.
|
|
234
|
+
if s.match?(/\A-?\d+\z/)
|
|
235
|
+
begin
|
|
236
|
+
return Time.at(Integer(s)).utc
|
|
237
|
+
rescue RangeError, ArgumentError
|
|
238
|
+
return nil
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Compares `value` and `target` as semantic versions and tests the
|
|
246
|
+
# resulting precedence sign (-1/0/1) against `op`. Returns false when
|
|
247
|
+
# either side is not a parseable version, mirroring how `compare_numeric`
|
|
248
|
+
# treats non-numeric input.
|
|
249
|
+
def compare_semver(value, target, op)
|
|
250
|
+
left = parse_semver(value)
|
|
251
|
+
right = parse_semver(target)
|
|
252
|
+
return false if left.nil? || right.nil?
|
|
253
|
+
|
|
254
|
+
compare_semver_parts(left, right).send(op, 0)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Parses a semantic version (https://semver.org). Returns a SemverVersion,
|
|
258
|
+
# or nil when the release core is missing or any release segment is
|
|
259
|
+
# non-numeric. Tolerant of an optional leading "v"/"V", "+build" metadata
|
|
260
|
+
# (ignored for precedence), and an optional "-prerelease" suffix.
|
|
261
|
+
def parse_semver(value)
|
|
262
|
+
s = value.strip
|
|
263
|
+
return nil if s.empty?
|
|
264
|
+
|
|
265
|
+
# Optional leading "v"/"V".
|
|
266
|
+
s = s[1..] if s.start_with?("v", "V")
|
|
267
|
+
|
|
268
|
+
# Build metadata ("+...") does not affect precedence.
|
|
269
|
+
plus = s.index("+")
|
|
270
|
+
s = s[0...plus] if plus
|
|
271
|
+
|
|
272
|
+
# Split the release core from the optional "-prerelease" suffix.
|
|
273
|
+
core = s
|
|
274
|
+
prerelease = []
|
|
275
|
+
dash = s.index("-")
|
|
276
|
+
if dash
|
|
277
|
+
core = s[0...dash]
|
|
278
|
+
pre = s[(dash + 1)..]
|
|
279
|
+
return nil if pre.empty? # trailing "-" with no identifiers is malformed
|
|
280
|
+
|
|
281
|
+
# `split(".", -1)` keeps trailing empty fields so "rc." / "1.0.-x" are
|
|
282
|
+
# rejected; the default `split` would silently drop them.
|
|
283
|
+
prerelease = pre.split(".", -1)
|
|
284
|
+
return nil if prerelease.any?(&:empty?)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
return nil if core.empty?
|
|
288
|
+
|
|
289
|
+
release = core.split(".", -1)
|
|
290
|
+
return nil unless release.all? { |seg| all_digits?(seg) }
|
|
291
|
+
|
|
292
|
+
SemverVersion.new(release, prerelease)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Compares two parsed versions by semantic-version precedence: release
|
|
296
|
+
# segments first (missing trailing segments treated as 0), then
|
|
297
|
+
# prerelease. Returns -1, 0, or 1.
|
|
298
|
+
def compare_semver_parts(a, b)
|
|
299
|
+
[a.release.length, b.release.length].max.times do |i|
|
|
300
|
+
seg_a = a.release[i] || "0"
|
|
301
|
+
seg_b = b.release[i] || "0"
|
|
302
|
+
cmp = compare_numeric_string(seg_a, seg_b)
|
|
303
|
+
return cmp unless cmp.zero?
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
compare_prerelease(a.prerelease, b.prerelease)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Compares two prerelease identifier lists per semver §11. A version with
|
|
310
|
+
# no prerelease ranks above one that has a prerelease.
|
|
311
|
+
def compare_prerelease(a, b)
|
|
312
|
+
return 0 if a.empty? && b.empty?
|
|
313
|
+
return 1 if a.empty?
|
|
314
|
+
return -1 if b.empty?
|
|
315
|
+
|
|
316
|
+
[a.length, b.length].min.times do |i|
|
|
317
|
+
cmp = compare_prerelease_id(a[i], b[i])
|
|
318
|
+
return cmp unless cmp.zero?
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# All shared identifiers equal: the longer prerelease has higher precedence.
|
|
322
|
+
a.length <=> b.length
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Compares two prerelease identifiers: numeric identifiers compare
|
|
326
|
+
# numerically and rank below alphanumeric ones; alphanumeric identifiers
|
|
327
|
+
# compare in ASCII sort order (case-sensitive) per semver §11.
|
|
328
|
+
def compare_prerelease_id(a, b)
|
|
329
|
+
a_num = all_digits?(a)
|
|
330
|
+
b_num = all_digits?(b)
|
|
331
|
+
return compare_numeric_string(a, b) if a_num && b_num
|
|
332
|
+
return -1 if a_num
|
|
333
|
+
return 1 if b_num
|
|
334
|
+
|
|
335
|
+
a <=> b
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Compares two all-digit strings as non-negative integers without parsing
|
|
339
|
+
# (overflow-free): strip leading zeros, then the longer string is the
|
|
340
|
+
# larger number; equal lengths compare ordinally. Returns -1, 0, or 1.
|
|
341
|
+
def compare_numeric_string(a, b)
|
|
342
|
+
a = a.sub(/\A0+/, "")
|
|
343
|
+
b = b.sub(/\A0+/, "")
|
|
344
|
+
return a.length <=> b.length unless a.length == b.length
|
|
345
|
+
|
|
346
|
+
a <=> b
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Reports whether `s` is non-empty and contains only ASCII digits.
|
|
350
|
+
def all_digits?(s)
|
|
351
|
+
s.match?(/\A[0-9]+\z/)
|
|
352
|
+
end
|
|
86
353
|
end
|
|
87
354
|
end
|
|
88
355
|
end
|
|
@@ -37,12 +37,21 @@ module Featureflip
|
|
|
37
37
|
|
|
38
38
|
sorted_rules = flag.rules.sort_by(&:priority)
|
|
39
39
|
sorted_rules.each do |rule|
|
|
40
|
-
conditions_match = if rule.segment_key &&
|
|
41
|
-
segment
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
conditions_match = if rule.segment_key && !rule.segment_key.empty?
|
|
41
|
+
# A segment-keyed rule must resolve its segment to match. If the
|
|
42
|
+
# segment source isn't wired (get_segment is nil), or the segment
|
|
43
|
+
# can't be found, fail closed (no match) -- mirroring the engine +
|
|
44
|
+
# C# SDK -- rather than falling through to the rule's condition
|
|
45
|
+
# groups (which match unconditionally when empty). See #1459.
|
|
46
|
+
if get_segment
|
|
47
|
+
segment = get_segment.call(rule.segment_key)
|
|
48
|
+
if segment
|
|
49
|
+
@condition_evaluator.evaluate_conditions(
|
|
50
|
+
segment.conditions, segment.condition_logic, context
|
|
51
|
+
)
|
|
52
|
+
else
|
|
53
|
+
false
|
|
54
|
+
end
|
|
46
55
|
else
|
|
47
56
|
false
|
|
48
57
|
end
|
|
@@ -138,6 +147,21 @@ module Featureflip
|
|
|
138
147
|
bucket_value = context["userId"] if bucket_value.nil? && bucket_by == "user_id"
|
|
139
148
|
bucket_value_str = bucket_value.nil? ? "" : bucket_value.to_s
|
|
140
149
|
|
|
150
|
+
# A Rollout serve can arrive with no weighted variations -- env-level PercentageRollout
|
|
151
|
+
# has nowhere to store per-variation weights, so the mapper emits type=Rollout with no
|
|
152
|
+
# variations (#1469). Degrade to the default fixed variation instead of returning an
|
|
153
|
+
# empty key. Mirrors the engine + C#/Java SDK evaluators.
|
|
154
|
+
return serve.variation || "" if (serve.variations || []).empty?
|
|
155
|
+
|
|
156
|
+
# Keyless user contexts can't be bucketed. Rather than hashing the empty value
|
|
157
|
+
# into an arbitrary salt-dependent bucket, serve the control (first) variation
|
|
158
|
+
# deterministically. The engine assigns a random GUID per eval (spreading
|
|
159
|
+
# anonymous users over HTTP); local SDK eval is deterministic, so parity is
|
|
160
|
+
# guaranteed only for keyed contexts (#1457).
|
|
161
|
+
if bucket_value_str == "" && %w[userId user_id].include?(bucket_by) && !(serve.variations || []).empty?
|
|
162
|
+
return serve.variations[0].key
|
|
163
|
+
end
|
|
164
|
+
|
|
141
165
|
bucket = Bucketing.compute_bucket(serve.salt || "", bucket_value_str)
|
|
142
166
|
|
|
143
167
|
cumulative = 0
|
|
@@ -146,7 +170,8 @@ module Featureflip
|
|
|
146
170
|
return wv.key if bucket < cumulative
|
|
147
171
|
end
|
|
148
172
|
|
|
149
|
-
|
|
173
|
+
# Guaranteed non-empty: the no-variations case returned the default above.
|
|
174
|
+
serve.variations.last.key
|
|
150
175
|
end
|
|
151
176
|
end
|
|
152
177
|
end
|
data/lib/featureflip/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: featureflip
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.2.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-06-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: logger
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0'
|
|
13
27
|
- !ruby/object:Gem::Dependency
|
|
14
28
|
name: rspec
|
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|