featureflip 2.1.0 → 2.3.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/data_source/streaming.rb +135 -14
- data/lib/featureflip/evaluation/condition_evaluator.rb +286 -19
- data/lib/featureflip/evaluation/evaluator.rb +32 -7
- data/lib/featureflip/http/client.rb +7 -1
- data/lib/featureflip/shared_core.rb +1 -0
- 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: fcc479d4aca550b2c17f8a988df92f7a7406a8712765767311f9f712c56f1fd1
|
|
4
|
+
data.tar.gz: c9281f409c2736e2c6b929cd39fb433e38969b496b59d7387b61de7ba0dc6ce5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8935a5f95ab48a7ce6c05af6a832aa8eca8cd1b1d5e66c20475f4a7a9fec38ec186313269f98bbd4d766eca22ff36dec814854beee2bfca77229b3e7a62f0067
|
|
7
|
+
data.tar.gz: 222fb9607a98518003f733a9abfcb3bb65143b33b05a5d4e7bf281e875573c33a4a28a83275a286328cb986cbdf622a1155f75e29188b5729a3f32a2f2ce906a
|
|
@@ -5,7 +5,38 @@ require "json"
|
|
|
5
5
|
module Featureflip
|
|
6
6
|
module DataSource
|
|
7
7
|
class StreamingHandler
|
|
8
|
-
|
|
8
|
+
# Raised into the streaming thread by #stop to interrupt a blocking read
|
|
9
|
+
# (Thread#wakeup only wakes a *sleeping* thread; it can't interrupt an
|
|
10
|
+
# MRI IO read — Thread#raise can). Inherits from Exception, not
|
|
11
|
+
# StandardError, so it bypasses handle_event's/run's generic `rescue
|
|
12
|
+
# StandardError` arms (which would otherwise swallow a stop mid-event and
|
|
13
|
+
# leave the thread blocking) and is only caught by the explicit
|
|
14
|
+
# `rescue StreamStopped`.
|
|
15
|
+
class StreamStopped < Exception; end # rubocop:disable Lint/InheritException
|
|
16
|
+
|
|
17
|
+
# The server sends a keep-alive ping this often; a finite read timeout at
|
|
18
|
+
# or below this interval would sever a healthy stream.
|
|
19
|
+
SERVER_PING_INTERVAL_SECONDS = 30
|
|
20
|
+
|
|
21
|
+
# Client-side liveness watchdog. net/http gives us no separate heartbeat,
|
|
22
|
+
# so the read timeout IS the watchdog: if no data (not even a ping) arrives
|
|
23
|
+
# for this long the socket is treated as dead — a half-open connection
|
|
24
|
+
# (LB/NAT idle-drop or a partition with no FIN/RST) — and the blocking read
|
|
25
|
+
# raises Net::ReadTimeout, which drives reconnect/backoff/polling. Set to
|
|
26
|
+
# 3× the ping (3 missed pings) so it never severs a healthy stream but still
|
|
27
|
+
# detects a dead socket within a bounded time. MUST stay finite and
|
|
28
|
+
# > SERVER_PING_INTERVAL_SECONDS. (The rest of the family runs an infinite
|
|
29
|
+
# read timeout — java readTimeout(0) / python read=None / csharp #1526 —
|
|
30
|
+
# and has the same latent half-open hole; ruby closes it here.)
|
|
31
|
+
STREAM_READ_TIMEOUT = SERVER_PING_INTERVAL_SECONDS * 3
|
|
32
|
+
|
|
33
|
+
# Base reconnect backoff; also the floor applied after a healthy stream
|
|
34
|
+
# closes cleanly, so even an accept-then-immediately-close server is
|
|
35
|
+
# throttled instead of busy-looping.
|
|
36
|
+
RECONNECT_BASE_DELAY_SECONDS = 1
|
|
37
|
+
MAX_BACKOFF_SECONDS = 30
|
|
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)
|
|
9
40
|
@sdk_key = sdk_key
|
|
10
41
|
@config = config
|
|
11
42
|
@http_client = http_client
|
|
@@ -13,12 +44,17 @@ module Featureflip
|
|
|
13
44
|
@on_flag_deleted = on_flag_deleted
|
|
14
45
|
@on_segment_updated = on_segment_updated
|
|
15
46
|
@on_error = on_error
|
|
47
|
+
@on_sync = on_sync
|
|
16
48
|
@on_give_up = on_give_up
|
|
17
49
|
@stop_flag = false
|
|
18
50
|
@thread = nil
|
|
19
51
|
@retry_count = 0
|
|
20
52
|
@current_event_type = nil
|
|
21
53
|
@current_data = nil
|
|
54
|
+
@line_buffer = String.new # ASCII-8BIT: raw read_body bytes concatenate safely
|
|
55
|
+
@delivered_frame = false
|
|
56
|
+
@wake_mutex = Mutex.new
|
|
57
|
+
@wake_cond = ConditionVariable.new
|
|
22
58
|
end
|
|
23
59
|
|
|
24
60
|
def start
|
|
@@ -29,9 +65,21 @@ module Featureflip
|
|
|
29
65
|
|
|
30
66
|
def stop
|
|
31
67
|
@stop_flag = true
|
|
32
|
-
|
|
33
|
-
@
|
|
68
|
+
# Wake an in-progress backoff wait.
|
|
69
|
+
@wake_mutex.synchronize { @wake_cond.broadcast }
|
|
70
|
+
|
|
71
|
+
thread = @thread
|
|
34
72
|
@thread = nil
|
|
73
|
+
return unless thread
|
|
74
|
+
|
|
75
|
+
# Interrupt a thread blocked in read_body. Guard the raise: the thread may
|
|
76
|
+
# finish between the alive? check and the raise (ThreadError on a dead one).
|
|
77
|
+
begin
|
|
78
|
+
thread.raise(StreamStopped.new) if thread.alive?
|
|
79
|
+
rescue ThreadError
|
|
80
|
+
# Thread already finished — nothing to interrupt.
|
|
81
|
+
end
|
|
82
|
+
thread.join(5)
|
|
35
83
|
end
|
|
36
84
|
|
|
37
85
|
private
|
|
@@ -40,26 +88,57 @@ module Featureflip
|
|
|
40
88
|
until @stop_flag
|
|
41
89
|
begin
|
|
42
90
|
connect
|
|
91
|
+
rescue StreamStopped
|
|
92
|
+
break
|
|
43
93
|
rescue StandardError => e
|
|
44
94
|
break if @stop_flag
|
|
45
95
|
@on_error.call(e)
|
|
96
|
+
end
|
|
97
|
+
break if @stop_flag
|
|
98
|
+
|
|
99
|
+
# Consult @delivered_frame (the instance var), NOT connect's return
|
|
100
|
+
# value: connect only *returns* on a clean EOF, but the common stream
|
|
101
|
+
# terminations (the liveness-watchdog Net::ReadTimeout, ECONNRESET,
|
|
102
|
+
# IOError) RAISE — and a session that delivered frames before raising
|
|
103
|
+
# must still count as healthy, or transient blips accumulate and
|
|
104
|
+
# wrongly degrade a good stream to polling. @delivered_frame survives
|
|
105
|
+
# the exception; connect resets it to false at the top of each attempt.
|
|
106
|
+
if @delivered_frame
|
|
107
|
+
# The stream genuinely stayed up (delivered ≥1 frame — the server
|
|
108
|
+
# sends `sync` first). Reset the failure counter.
|
|
109
|
+
@retry_count = 0
|
|
110
|
+
else
|
|
111
|
+
# A clean EOF (no frame) is treated as a failure for backoff/escalation
|
|
112
|
+
# purposes — otherwise an accept-then-close server never accumulates
|
|
113
|
+
# toward max_stream_retries and never degrades to polling.
|
|
46
114
|
@retry_count += 1
|
|
47
115
|
if @retry_count > @config.max_stream_retries
|
|
48
116
|
@on_give_up&.call
|
|
49
117
|
break
|
|
50
118
|
end
|
|
51
|
-
delay = [2**(@retry_count - 1), 30].min
|
|
52
|
-
sleep(delay)
|
|
53
119
|
end
|
|
120
|
+
|
|
121
|
+
# Back off before every reconnect, including after a clean EOF, so we
|
|
122
|
+
# never zero-delay busy-loop against a flapping endpoint.
|
|
123
|
+
backoff_wait(backoff_delay(@retry_count))
|
|
54
124
|
end
|
|
125
|
+
rescue StreamStopped
|
|
126
|
+
# stop() interrupted a backoff wait — clean shutdown.
|
|
55
127
|
end
|
|
56
128
|
|
|
129
|
+
# Connect to the SSE stream and process events until the connection ends.
|
|
130
|
+
# Returns true if the stream delivered at least one complete frame (a live
|
|
131
|
+
# stream), false if it returned 200 but closed without delivering one.
|
|
57
132
|
def connect
|
|
133
|
+
# Reset before anything can raise (a failed handshake / connection error
|
|
134
|
+
# must not let run() read a stale `true` from the previous session).
|
|
135
|
+
@delivered_frame = false
|
|
136
|
+
|
|
58
137
|
uri = URI("#{@config.base_url}/v1/sdk/stream")
|
|
59
138
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
60
139
|
http.use_ssl = uri.scheme == "https"
|
|
61
140
|
http.open_timeout = @config.connect_timeout
|
|
62
|
-
http.read_timeout =
|
|
141
|
+
http.read_timeout = STREAM_READ_TIMEOUT
|
|
63
142
|
|
|
64
143
|
req = Net::HTTP::Get.new(uri.request_uri)
|
|
65
144
|
req["Authorization"] = @sdk_key
|
|
@@ -71,31 +150,67 @@ module Featureflip
|
|
|
71
150
|
raise Featureflip::Error, "SSE connection failed: #{response.code}"
|
|
72
151
|
end
|
|
73
152
|
|
|
74
|
-
|
|
75
|
-
@current_event_type = nil
|
|
76
|
-
@current_data = nil
|
|
77
|
-
|
|
153
|
+
reset_stream_parser
|
|
78
154
|
response.read_body do |chunk|
|
|
79
155
|
break if @stop_flag
|
|
80
|
-
chunk
|
|
81
|
-
process_sse_line(line.strip)
|
|
82
|
-
end
|
|
156
|
+
feed_chunk(chunk)
|
|
83
157
|
end
|
|
84
158
|
end
|
|
159
|
+
|
|
160
|
+
@delivered_frame
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def reset_stream_parser
|
|
164
|
+
@current_event_type = nil
|
|
165
|
+
@current_data = nil
|
|
166
|
+
@line_buffer = String.new # ASCII-8BIT: raw read_body bytes concatenate safely
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Append a raw SSE body chunk and dispatch every *complete* line it
|
|
170
|
+
# completes. Net::HTTP#read_body yields arbitrary byte fragments with no
|
|
171
|
+
# line alignment, so a line (or a `data:` payload) can span chunk
|
|
172
|
+
# boundaries; buffer until a newline before parsing.
|
|
173
|
+
def feed_chunk(chunk)
|
|
174
|
+
@line_buffer << chunk
|
|
175
|
+
while (newline_index = @line_buffer.index("\n"))
|
|
176
|
+
line = @line_buffer.slice!(0, newline_index + 1)
|
|
177
|
+
process_sse_line(line.chomp.force_encoding(Encoding::UTF_8))
|
|
178
|
+
end
|
|
85
179
|
end
|
|
86
180
|
|
|
87
181
|
def process_sse_line(line)
|
|
88
182
|
if line.start_with?("event: ")
|
|
89
183
|
@current_event_type = line[7..]
|
|
90
184
|
elsif line.start_with?("data: ")
|
|
91
|
-
|
|
185
|
+
# Per the SSE spec multiple data: lines join with "\n" — concatenate,
|
|
186
|
+
# never overwrite, or a chunked/multi-line payload loses everything but
|
|
187
|
+
# its last fragment.
|
|
188
|
+
fragment = line[6..]
|
|
189
|
+
@current_data = @current_data.nil? ? fragment : "#{@current_data}\n#{fragment}"
|
|
92
190
|
elsif line.empty? && @current_event_type && @current_data
|
|
191
|
+
@delivered_frame = true
|
|
93
192
|
handle_event(@current_event_type, @current_data)
|
|
94
193
|
@current_event_type = nil
|
|
95
194
|
@current_data = nil
|
|
96
195
|
end
|
|
97
196
|
end
|
|
98
197
|
|
|
198
|
+
# Capped exponential backoff. failures == 0 means a healthy stream just
|
|
199
|
+
# closed cleanly; still apply the base floor so we don't busy-loop.
|
|
200
|
+
def backoff_delay(failures)
|
|
201
|
+
exponent = failures <= 0 ? 0 : failures - 1
|
|
202
|
+
[RECONNECT_BASE_DELAY_SECONDS * (2**exponent), MAX_BACKOFF_SECONDS].min
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Sleep for `seconds`, but return immediately if stop() fires — so a pending
|
|
206
|
+
# shutdown isn't blocked behind a long backoff.
|
|
207
|
+
def backoff_wait(seconds)
|
|
208
|
+
@wake_mutex.synchronize do
|
|
209
|
+
return if @stop_flag
|
|
210
|
+
@wake_cond.wait(@wake_mutex, seconds)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
99
214
|
def handle_event(event_type, data)
|
|
100
215
|
case event_type
|
|
101
216
|
when "flag.created", "flag.updated"
|
|
@@ -112,6 +227,12 @@ module Featureflip
|
|
|
112
227
|
when "segment.updated"
|
|
113
228
|
flags, segments = @http_client.get_flags
|
|
114
229
|
@on_segment_updated.call(flags, segments)
|
|
230
|
+
when "sync"
|
|
231
|
+
# Full config snapshot the server sends on (re)connect. Replace the
|
|
232
|
+
# whole store so flags changed OR deleted during a disconnect are
|
|
233
|
+
# re-synced. Full replace, never a per-key merge.
|
|
234
|
+
flags, segments = @http_client.parse_flags_response(JSON.parse(data))
|
|
235
|
+
@on_sync&.call(flags, segments)
|
|
115
236
|
end
|
|
116
237
|
rescue StandardError
|
|
117
238
|
# Swallow event processing errors
|
|
@@ -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
|
|
@@ -13,7 +13,13 @@ module Featureflip
|
|
|
13
13
|
|
|
14
14
|
def get_flags
|
|
15
15
|
response = request(:get, "/v1/sdk/flags")
|
|
16
|
-
|
|
16
|
+
parse_flags_response(JSON.parse(response.body))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parse a GET /v1/sdk/flags-shaped snapshot into models. Reused for the
|
|
20
|
+
# connect-time `sync` SSE snapshot, which carries the identical payload
|
|
21
|
+
# shape inline (no extra HTTP round-trip).
|
|
22
|
+
def parse_flags_response(data)
|
|
17
23
|
flags = (data["flags"] || []).map { |f| parse_flag(f) }
|
|
18
24
|
segments = (data["segments"] || []).map { |s| parse_segment(s) }
|
|
19
25
|
[flags, segments]
|
|
@@ -278,6 +278,7 @@ module Featureflip
|
|
|
278
278
|
on_flag_updated: ->(flag) { @store.upsert(flag) },
|
|
279
279
|
on_flag_deleted: ->(key) { @store.remove_flag(key) },
|
|
280
280
|
on_segment_updated: ->(flags, segments) { @store.init(flags, segments) },
|
|
281
|
+
on_sync: ->(flags, segments) { @store.init(flags, segments) },
|
|
281
282
|
on_error: ->(_err) { },
|
|
282
283
|
on_give_up: -> { fallback_to_polling }
|
|
283
284
|
)
|
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.3.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-07-13 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
|