featureflip 2.5.1 → 2.7.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 +28 -3
- data/lib/featureflip/errors.rb +36 -0
- data/lib/featureflip/evaluation/condition_evaluator.rb +168 -30
- data/lib/featureflip/events/event_processor.rb +273 -18
- data/lib/featureflip/http/client.rb +119 -9
- data/lib/featureflip/shared_core.rb +4 -1
- data/lib/featureflip/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d0798d6f9dde45580a5345467cfcbc108b01a0bda1ed9c0224a6e58305ec9bd
|
|
4
|
+
data.tar.gz: d7a7ceca0d72477e0987ee83b0af3bdcee069f47add6ecc89a42bb7c9cb635e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2546ac239e8b8c0b120caa1d8286ad9a58e4fe23900a58cb63343ad469e9e324548ebac72ea194c102c023e5de2ef2e88fd344c80d15beb49dfc18803a3bd711
|
|
7
|
+
data.tar.gz: 6393ec68c36a42813a537fa699e9801132606d273264c142b8d75082c0fc7d260160b27aac53306d49a14bd2d895de2da57e8284690c8affb031076a76500a47
|
|
@@ -195,11 +195,27 @@ module Featureflip
|
|
|
195
195
|
end
|
|
196
196
|
end
|
|
197
197
|
|
|
198
|
-
# Capped exponential backoff. failures == 0 means a
|
|
199
|
-
# closed cleanly;
|
|
198
|
+
# Capped exponential backoff, jittered at every level. failures == 0 means a
|
|
199
|
+
# healthy stream just closed cleanly; the jitter band's lower bound keeps the
|
|
200
|
+
# base floor in force so we don't busy-loop.
|
|
201
|
+
#
|
|
202
|
+
# Jittering the FIRST reconnect is load-bearing, not cosmetic: the drops this
|
|
203
|
+
# absorbs are fleet-wide — one edge event severs every stream at once (#2457)
|
|
204
|
+
# — so every client re-enters here at failures == 0 together. A constant there
|
|
205
|
+
# replayed the drop's own synchronisation as a reconnect spike one base delay
|
|
206
|
+
# later (#2508).
|
|
200
207
|
def backoff_delay(failures)
|
|
201
208
|
exponent = failures <= 0 ? 0 : failures - 1
|
|
202
|
-
[RECONNECT_BASE_DELAY_SECONDS * (2**exponent), MAX_BACKOFF_SECONDS].min
|
|
209
|
+
with_jitter([RECONNECT_BASE_DELAY_SECONDS * (2**exponent), MAX_BACKOFF_SECONDS].min)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Returns a value in [d/2, d] to de-correlate reconnects across many SDK
|
|
213
|
+
# instances (thundering-herd avoidance after a shared outage).
|
|
214
|
+
def with_jitter(delay)
|
|
215
|
+
return delay if delay <= 0
|
|
216
|
+
|
|
217
|
+
half = delay / 2.0
|
|
218
|
+
half + (rand * half)
|
|
203
219
|
end
|
|
204
220
|
|
|
205
221
|
# Sleep for `seconds`, but return immediately if stop() fires — so a pending
|
|
@@ -234,6 +250,15 @@ module Featureflip
|
|
|
234
250
|
flags, segments = @http_client.parse_flags_response(JSON.parse(data))
|
|
235
251
|
@on_sync&.call(flags, segments)
|
|
236
252
|
end
|
|
253
|
+
rescue UnevaluableEntityError => e
|
|
254
|
+
# Not a malformed payload: the frame was well-formed and simply described
|
|
255
|
+
# behaviour this build cannot evaluate, so the entity was dropped rather than
|
|
256
|
+
# the payload discarded (#2402). Logged at the same volume — a flag that
|
|
257
|
+
# silently stopped updating is exactly as confusing as one that never arrived.
|
|
258
|
+
@config.logger&.warn(
|
|
259
|
+
"Featureflip: dropping #{event_type} update: #{e.message}. This SDK version " \
|
|
260
|
+
"may be older than the flag configuration."
|
|
261
|
+
)
|
|
237
262
|
rescue MalformedPayloadError => e
|
|
238
263
|
# A payload that violates the wire contract is discarded WHOLESALE rather
|
|
239
264
|
# than partially applied — a half-parsed snapshot silently mis-evaluates
|
data/lib/featureflip/errors.rb
CHANGED
|
@@ -11,4 +11,40 @@ module Featureflip
|
|
|
11
11
|
# operator to no-match. Only a TYPE violation is rejected, because that can never
|
|
12
12
|
# be a legitimate newer-server payload. See #2285.
|
|
13
13
|
class MalformedPayloadError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when the API answers a request with a non-success status. Carries the status
|
|
16
|
+
# so a caller can decide what to do about it.
|
|
17
|
+
#
|
|
18
|
+
# The events flush is the caller that has to: it re-queues a batch a later attempt could
|
|
19
|
+
# plausibly deliver (5xx, 429) and drops one the server will reject identically forever
|
|
20
|
+
# (401/403 = key rejected, 400 = malformed body). Before this the status only existed
|
|
21
|
+
# inside the message string, so every failure looked alike and the batch was dropped
|
|
22
|
+
# either way (#2456).
|
|
23
|
+
#
|
|
24
|
+
# Subclasses Error and keeps the historical "HTTP <code>: <path>" message, so callers
|
|
25
|
+
# rescuing Featureflip::Error — or matching on that message — are unaffected.
|
|
26
|
+
class HttpStatusError < Error
|
|
27
|
+
attr_reader :status
|
|
28
|
+
|
|
29
|
+
def initialize(status, path)
|
|
30
|
+
@status = status
|
|
31
|
+
super("HTTP #{status}: #{path}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raised when a flag or segment carries an enum value this SDK build cannot
|
|
36
|
+
# EVALUATE — an unrecognised `serve.type` or `conditionLogic` (#2402).
|
|
37
|
+
#
|
|
38
|
+
# Distinct from MalformedPayloadError, and the distinction is the whole point. That
|
|
39
|
+
# one means the payload is the wrong SHAPE and is discarded wholesale. This one means
|
|
40
|
+
# the payload is perfectly well-formed and simply describes behaviour a newer server
|
|
41
|
+
# understands and this build does not — so only the containing ENTITY is dropped and
|
|
42
|
+
# the rest of the configuration still applies.
|
|
43
|
+
#
|
|
44
|
+
# Not raised for an unrecognised condition OPERATOR: the evaluator already fails an
|
|
45
|
+
# unknown operator closed (#2262), so the condition just does not match and the flag
|
|
46
|
+
# remains perfectly evaluable. `serve.type` and `conditionLogic` have no such
|
|
47
|
+
# fail-closed arm — each dispatches on a two-way branch and an unrecognised value
|
|
48
|
+
# takes the ELSE arm, silently serving a rollout or matching ANY condition.
|
|
49
|
+
class UnevaluableEntityError < Error; end
|
|
14
50
|
end
|
|
@@ -74,16 +74,36 @@ module Featureflip
|
|
|
74
74
|
SemverVersion = Struct.new(:release, :prerelease)
|
|
75
75
|
private_constant :SemverVersion
|
|
76
76
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
77
|
+
# The ONLY characters trimmed from a date operand, and the whole of the
|
|
78
|
+
# operand's permitted whitespace: U+0009..U+000D plus U+0020 -- exactly the
|
|
79
|
+
# class the engine's NumberStyles.Integer accepts via AllowLeadingWhite |
|
|
80
|
+
# AllowTrailingWhite.
|
|
81
|
+
#
|
|
82
|
+
# String#strip is deliberately NOT used: it also strips NUL, so "\0005" was
|
|
83
|
+
# trimmed to "5" and matched here while the engine rejected it (#2468).
|
|
84
|
+
OPERAND_WHITESPACE = "\t\n\v\f\r "
|
|
85
|
+
private_constant :OPERAND_WHITESPACE
|
|
86
|
+
|
|
87
|
+
# Characters no date operand may contain: a NUL or other control character, or
|
|
88
|
+
# a non-ASCII whitespace/format character. An interior ASCII space is allowed
|
|
89
|
+
# -- it is the ISO-8601 date/time separator.
|
|
90
|
+
FORBIDDEN_OPERAND_CHAR =
|
|
91
|
+
/[\u0000-\u001f\u007f-\u009f\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/
|
|
92
|
+
private_constant :FORBIDDEN_OPERAND_CHAR
|
|
93
|
+
|
|
94
|
+
# The ISO-8601 grammar a date operand may use: a calendar date, optionally
|
|
95
|
+
# followed by a time (seconds and fractional seconds optional) and an optional
|
|
96
|
+
# offset in either extended (+05:00 / Z) or basic (+0500) form. The separator
|
|
97
|
+
# may be "T" or a space -- the engine accepts both, but Time.iso8601 rejects
|
|
98
|
+
# the space, which is why ruby alone read "2024-01-01 00:00:00" as no-match
|
|
99
|
+
# (#2468).
|
|
100
|
+
ISO_OPERAND =
|
|
101
|
+
/\A(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?\z/
|
|
102
|
+
private_constant :ISO_OPERAND
|
|
103
|
+
|
|
104
|
+
# Length of each month in a non-leap year, indexed 1..12. Index 0 is unused padding.
|
|
105
|
+
DAYS_IN_MONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
|
|
106
|
+
private_constant :DAYS_IN_MONTH
|
|
87
107
|
|
|
88
108
|
def evaluate_operator(operator, value, targets)
|
|
89
109
|
# Case-insensitive views for the string/relational/date operators.
|
|
@@ -216,35 +236,153 @@ module Featureflip
|
|
|
216
236
|
left.send(op, right)
|
|
217
237
|
end
|
|
218
238
|
|
|
239
|
+
# DateTimeOffset.MinValue / MaxValue as unix seconds -- the exact bounds the
|
|
240
|
+
# engine's FromUnixTimeSeconds accepts before throwing (#2432).
|
|
241
|
+
MIN_UNIX_SECONDS = -62_135_596_800
|
|
242
|
+
MAX_UNIX_SECONDS = 253_402_300_799
|
|
243
|
+
|
|
244
|
+
# Whether +date+ -- always "YYYY-MM-DD", since only canonicalize_iso calls this --
|
|
245
|
+
# names a day that exists.
|
|
246
|
+
#
|
|
247
|
+
# ISO_OPERAND matches the SHAPE of a calendar date and a character class cannot
|
|
248
|
+
# express "is a real day", so "2024-02-30", "2023-02-29" and "2024-04-31" all pass
|
|
249
|
+
# the grammar. The engine, csharp, go, python and java then reject them at parse;
|
|
250
|
+
# ruby, js and php ROLLED THEM OVER into the 1st of the following month, so one
|
|
251
|
+
# saved rule served different variations to two users purely by which SDK their
|
|
252
|
+
# service ran (#2491).
|
|
253
|
+
#
|
|
254
|
+
# Hand-rolled rather than delegated to Date.valid_date?, which applies the Italian
|
|
255
|
+
# calendar reform by DEFAULT and so rejects 1582-10-05..14 -- dates the engine
|
|
256
|
+
# resolves normally. Passing Date::GREGORIAN would fix that, but computing the
|
|
257
|
+
# arithmetic identically in ruby, js and php is what keeps the accepted set a
|
|
258
|
+
# property of THIS contract rather than of three separate calendars.
|
|
259
|
+
#
|
|
260
|
+
# Proleptic Gregorian, matching the engine: the leap rule applies at every year
|
|
261
|
+
# rather than from a reform date onward.
|
|
262
|
+
def real_calendar_day?(date)
|
|
263
|
+
year = date[0, 4].to_i
|
|
264
|
+
month = date[5, 2].to_i
|
|
265
|
+
day = date[8, 2].to_i
|
|
266
|
+
|
|
267
|
+
# Month 0 and day 0 are the shapes only php mishandled, rolling each BACKWARDS
|
|
268
|
+
# into the previous year ("2024-00-01" -> 2023-12-01, "2024-01-00" -> 2023-12-31).
|
|
269
|
+
return false if month < 1 || month > 12 || day < 1
|
|
270
|
+
|
|
271
|
+
leap_day = month == 2 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) ? 1 : 0
|
|
272
|
+
day <= DAYS_IN_MONTH[month] + leap_day
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Rewrites an accepted ISO operand into the strict extended form Time.iso8601
|
|
276
|
+
# parses: "T" separator, seconds present, offset spelled "+HH:MM" or "Z".
|
|
277
|
+
# Returns nil when the operand is not an accepted ISO shape.
|
|
278
|
+
def canonicalize_iso(s)
|
|
279
|
+
m = ISO_OPERAND.match(s)
|
|
280
|
+
return nil if m.nil?
|
|
281
|
+
|
|
282
|
+
date, hh, mm, ss, frac, off = m.captures
|
|
283
|
+
|
|
284
|
+
# Checked on the WRITTEN date, before any offset is applied. Validating the
|
|
285
|
+
# resolved UTC components instead would accept "2024-02-30T00:00:00+05:00",
|
|
286
|
+
# which lands on 2024-02-29T19:00Z -- a date that does exist.
|
|
287
|
+
return nil unless real_calendar_day?(date)
|
|
288
|
+
|
|
289
|
+
return "#{date}T00:00:00Z" if hh.nil?
|
|
290
|
+
|
|
291
|
+
# The engine's DateTimeOffset.TryParse rejects hour 24 outright rather than
|
|
292
|
+
# rolling it over to 00:00 the next day, which is what Time.iso8601 does.
|
|
293
|
+
return nil if hh >= "24"
|
|
294
|
+
|
|
295
|
+
ss ||= "00"
|
|
296
|
+
off =
|
|
297
|
+
if off.nil? then "Z"
|
|
298
|
+
elsif off.length == 5 && off != "Z" then "#{off[0, 3]}:#{off[3, 2]}"
|
|
299
|
+
else off
|
|
300
|
+
end
|
|
301
|
+
"#{date}T#{hh}:#{mm}:#{ss}#{frac}#{off}"
|
|
302
|
+
end
|
|
303
|
+
|
|
219
304
|
# Parses a date-time to a UTC `Time`, mirroring the engine's
|
|
220
305
|
# TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
|
|
221
306
|
# without an offset is assumed UTC. A bare integer is treated as Unix time
|
|
222
307
|
# in seconds. Returns nil when the input parses as neither.
|
|
223
308
|
def parse_datetime(value)
|
|
224
|
-
s = value.to_s
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
309
|
+
s = value.to_s
|
|
310
|
+
# Trim exactly the engine's whitespace class, then reject anything still
|
|
311
|
+
# carrying a character no operand may contain.
|
|
312
|
+
s = s.gsub(/\A[#{Regexp.escape(OPERAND_WHITESPACE)}]+|[#{Regexp.escape(OPERAND_WHITESPACE)}]+\z/, "")
|
|
313
|
+
return nil if s.empty? || s.match?(FORBIDDEN_OPERAND_CHAR)
|
|
314
|
+
|
|
315
|
+
iso = canonicalize_iso(s)
|
|
316
|
+
if iso
|
|
317
|
+
begin
|
|
318
|
+
# Offset-less forms were canonicalized to an explicit "Z", mirroring
|
|
319
|
+
# DateTimeOffset.TryParse with AssumeUniversal.
|
|
320
|
+
t = Time.iso8601(iso).utc
|
|
321
|
+
|
|
322
|
+
# The SAME range the integer branch below enforces, applied to the
|
|
323
|
+
# RESOLVED instant. The engine parses with DateTimeOffset.TryParse, so its
|
|
324
|
+
# accepted set is bounded by DateTimeOffset's range and it returns false
|
|
325
|
+
# outside it; ruby, js, php, go and java all resolve past both ends --
|
|
326
|
+
# year 0 to a real instant, and a 4-digit year plus an offset to one
|
|
327
|
+
# beyond either bound (#2500).
|
|
328
|
+
#
|
|
329
|
+
# Checked on the RESOLVED instant, deliberately unlike the WRITTEN-triple
|
|
330
|
+
# check in real_calendar_day?. The two answer different questions: whether
|
|
331
|
+
# the operand names a real DAY is a property of what was written
|
|
332
|
+
# ("2024-02-30T00:00:00+05:00" lands on a real UTC day but names none),
|
|
333
|
+
# whereas whether it is REPRESENTABLE is a property of what it resolves to
|
|
334
|
+
# -- the offset is exactly what carries "0001-01-01T00:00:00+05:00" under
|
|
335
|
+
# the floor and "9999-12-31T23:59:59-05:00" over the ceiling.
|
|
336
|
+
#
|
|
337
|
+
# to_i floors, matching the other SDKs: a fractional second is always a
|
|
338
|
+
# non-negative addend, so "0000-12-31T23:59:59.5Z" floors to MIN-1 and is
|
|
339
|
+
# rejected while "0001-01-01T00:00:00.5Z" floors to MIN and is kept.
|
|
340
|
+
seconds = t.to_i
|
|
341
|
+
return nil if seconds < MIN_UNIX_SECONDS || seconds > MAX_UNIX_SECONDS
|
|
342
|
+
|
|
343
|
+
return t
|
|
344
|
+
rescue ArgumentError
|
|
345
|
+
# An unreal day is already gone (real_calendar_day?), so this now only
|
|
346
|
+
# catches the out-of-range minute and second the grammar's \d{2} still
|
|
347
|
+
# admits ("00:99", "00:00:99"), which every other SDK rejects too. Falls
|
|
348
|
+
# through to the Unix-seconds fallback, which rejects a non-integer.
|
|
349
|
+
end
|
|
242
350
|
end
|
|
243
351
|
|
|
244
352
|
# Integer fallback: treat a bare integer as Unix time in seconds.
|
|
245
|
-
|
|
353
|
+
#
|
|
354
|
+
# Out-of-range seconds match NOTHING rather than resolving to a far-future
|
|
355
|
+
# instant: the engine's FromUnixTimeSeconds throws outside DateTimeOffset's
|
|
356
|
+
# range and TryParseDateTime returns false. Ruby's Time has a far wider range
|
|
357
|
+
# and would happily accept the value, so the bound has to be explicit. The
|
|
358
|
+
# case that matters in practice is a MILLISECONDS timestamp pasted where
|
|
359
|
+
# seconds belong, which would otherwise land in the year 55829 and satisfy
|
|
360
|
+
# every `After` comparison (#2432).
|
|
361
|
+
#
|
|
362
|
+
# The sign class matches the engine's `long.TryParse` with
|
|
363
|
+
# `NumberStyles.Integer` (`AllowLeadingWhite | AllowTrailingWhite |
|
|
364
|
+
# AllowLeadingSign`), so a leading "+" is accepted deliberately rather than
|
|
365
|
+
# incidentally, and `Integer()` reads it the same way. Omitting it made "+5"
|
|
366
|
+
# an unparseable string matching NOTHING here while the engine and four other
|
|
367
|
+
# SDKs read it as five seconds past the epoch (#2458).
|
|
368
|
+
#
|
|
369
|
+
# The whitespace flags now match too: the trim above is exactly
|
|
370
|
+
# `AllowLeadingWhite`/`AllowTrailingWhite`'s class, and anything outside it
|
|
371
|
+
# was already rejected by FORBIDDEN_OPERAND_CHAR (#2468).
|
|
372
|
+
if s.match?(/\A[+-]?\d+\z/)
|
|
246
373
|
begin
|
|
247
|
-
|
|
374
|
+
# Base 10 EXPLICITLY. Bare `Integer(s)` honours Ruby's literal base
|
|
375
|
+
# prefixes, so a leading zero means OCTAL: "0500" became 320 rather than
|
|
376
|
+
# 500, and "0800" raised ArgumentError (8 is not an octal digit) and
|
|
377
|
+
# matched nothing at all. Every other implementation parses base 10 --
|
|
378
|
+
# the engine's `long.TryParse`, go's `ParseInt(s, 10, 64)`, java's
|
|
379
|
+
# `Long.parseLong`, python's `int()`, php's `(int)` cast and js's
|
|
380
|
+
# `Number()` -- so ruby was alone in reading a zero-padded unix timestamp
|
|
381
|
+
# as a different instant. Pinned by `c-date-unix-leading-zero-*` (#2458).
|
|
382
|
+
seconds = Integer(s, 10)
|
|
383
|
+
return nil if seconds < MIN_UNIX_SECONDS || seconds > MAX_UNIX_SECONDS
|
|
384
|
+
|
|
385
|
+
return Time.at(seconds).utc
|
|
248
386
|
rescue RangeError, ArgumentError
|
|
249
387
|
return nil
|
|
250
388
|
end
|
|
@@ -1,38 +1,127 @@
|
|
|
1
1
|
module Featureflip
|
|
2
2
|
module Events
|
|
3
3
|
class EventProcessor
|
|
4
|
-
|
|
4
|
+
# Upper bound on buffered events.
|
|
5
|
+
#
|
|
6
|
+
# Only reachable once #flush starts putting batches back faster than they drain --
|
|
7
|
+
# i.e. a sustained outage of the events endpoint. Past the bound the OLDEST events
|
|
8
|
+
# are shed, which caps memory and keeps the freshest analytics. It also means a long
|
|
9
|
+
# outage sheds the stale re-queued batches rather than starving new events, so the
|
|
10
|
+
# SDK degrades to the old drop-on-failure behaviour instead of hoarding data it
|
|
11
|
+
# cannot send.
|
|
12
|
+
DEFAULT_MAX_QUEUE_SIZE = 10_000
|
|
13
|
+
|
|
14
|
+
def initialize(http_client, flush_interval: 30, flush_batch_size: 100,
|
|
15
|
+
max_queue_size: DEFAULT_MAX_QUEUE_SIZE, logger: nil)
|
|
5
16
|
@http_client = http_client
|
|
6
17
|
@flush_interval = flush_interval
|
|
7
|
-
@flush_batch_size
|
|
18
|
+
# Clamped to at least 1: #flush drains @flush_batch_size events per pass and stops
|
|
19
|
+
# when the queue is empty, so a non-positive size would shift nothing off a
|
|
20
|
+
# non-empty queue and spin forever. Config#validate! already rejects such values,
|
|
21
|
+
# but this class is constructed directly too.
|
|
22
|
+
@flush_batch_size = flush_batch_size.to_i.positive? ? flush_batch_size.to_i : 1
|
|
23
|
+
@max_queue_size = max_queue_size.to_i.positive? ? max_queue_size.to_i : DEFAULT_MAX_QUEUE_SIZE
|
|
24
|
+
@logger = logger
|
|
8
25
|
@queue = []
|
|
9
26
|
@mutex = Mutex.new
|
|
10
27
|
@stop_flag = false
|
|
28
|
+
@stopped = false
|
|
11
29
|
@thread = nil
|
|
30
|
+
|
|
31
|
+
# Monotonic instant before which the batch-size trigger must not start another
|
|
32
|
+
# flush, and a latch held while a size-triggered flush is in flight. Both exist to
|
|
33
|
+
# stop a re-queued batch from turning every subsequent event into another request
|
|
34
|
+
# — see #auto_flush.
|
|
35
|
+
@next_auto_flush_at = 0.0
|
|
36
|
+
@auto_flush_in_flight = false
|
|
37
|
+
|
|
38
|
+
# Coalescing state for the drain loop. @auto_flush_in_flight above only ever
|
|
39
|
+
# guarded the SIZE trigger; nothing stopped the background thread's interval
|
|
40
|
+
# tick, an explicit Client#flush and a size-triggered flush from entering the
|
|
41
|
+
# loop together. Two concurrent drains mean two request streams against the
|
|
42
|
+
# endpoint the backoff gate exists to protect — and a success in one clears
|
|
43
|
+
# the gate a failure in the other has just armed, re-opening the
|
|
44
|
+
# one-request-per-event behaviour outright (#2477).
|
|
45
|
+
#
|
|
46
|
+
# Generation counters rather than a bare flag: a waiter has to be able to
|
|
47
|
+
# tell "the drain I was waiting for has finished" from "a later drain is
|
|
48
|
+
# running", or it would sleep through its own completion.
|
|
49
|
+
@drain_in_flight = false
|
|
50
|
+
@drain_started = 0
|
|
51
|
+
@drain_finished = 0
|
|
52
|
+
@drain_done = ConditionVariable.new
|
|
12
53
|
end
|
|
13
54
|
|
|
14
55
|
def queue_event(event)
|
|
15
|
-
|
|
56
|
+
dropped = 0
|
|
16
57
|
@mutex.synchronize do
|
|
58
|
+
# After #stop nothing will flush again, so buffering here would only leak.
|
|
59
|
+
return if @stopped
|
|
60
|
+
|
|
17
61
|
@queue << event
|
|
18
|
-
|
|
62
|
+
dropped = trim_to_bound
|
|
19
63
|
end
|
|
20
|
-
|
|
64
|
+
|
|
65
|
+
warn_overflow(dropped)
|
|
66
|
+
auto_flush
|
|
21
67
|
end
|
|
22
68
|
|
|
69
|
+
# Drains the queue a batch at a time, one request per batch.
|
|
70
|
+
#
|
|
71
|
+
# This used to post the WHOLE queue in a single request, which was harmless while a
|
|
72
|
+
# failure emptied the queue: it never grew far past @flush_batch_size. Re-queuing
|
|
73
|
+
# failures (#2456) is what changed that — after a sustained outage the queue can sit
|
|
74
|
+
# at its 10,000-event bound, and posting all of that at once risks a body the server
|
|
75
|
+
# rejects outright. A 413 is non-retryable, so the entire backlog would be dropped by
|
|
76
|
+
# the very path added to preserve it.
|
|
77
|
+
# At most one drain runs at a time. A caller arriving while one is already
|
|
78
|
+
# going waits for it and returns — it does NOT start its own, and it does NOT
|
|
79
|
+
# return early, because a caller that asked for a flush is asking for its
|
|
80
|
+
# events to be sent. This matches the js/node SDKs, whose flush() has always
|
|
81
|
+
# returned the in-flight promise (#2477).
|
|
23
82
|
def flush
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
83
|
+
mine = @mutex.synchronize do
|
|
84
|
+
if @drain_in_flight
|
|
85
|
+
nil
|
|
86
|
+
else
|
|
87
|
+
@drain_in_flight = true
|
|
88
|
+
@drain_started += 1
|
|
89
|
+
end
|
|
29
90
|
end
|
|
30
91
|
|
|
31
|
-
|
|
92
|
+
if mine.nil?
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
waiting_for = @drain_started
|
|
95
|
+
@drain_done.wait(@mutex) while @drain_finished < waiting_for
|
|
96
|
+
end
|
|
97
|
+
return
|
|
98
|
+
end
|
|
32
99
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
100
|
+
begin
|
|
101
|
+
drain
|
|
102
|
+
ensure
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
@drain_in_flight = false
|
|
105
|
+
@drain_finished = mine
|
|
106
|
+
@drain_done.broadcast
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The drain loop itself, callable when coalescing must be bypassed.
|
|
112
|
+
# Private: #flush is the public entry point, and #stop reaches this directly.
|
|
113
|
+
private def drain
|
|
114
|
+
loop do
|
|
115
|
+
batch = drain_batch
|
|
116
|
+
return if batch.empty?
|
|
117
|
+
|
|
118
|
+
# False means the batch went back on the queue, and it is at the HEAD of the very
|
|
119
|
+
# queue this loop drains — carrying on would re-send it immediately and spin for
|
|
120
|
+
# as long as the endpoint stayed down. A dropped batch returns true instead: the
|
|
121
|
+
# queue has shrunk, so the loop still terminates, and one poison batch must not
|
|
122
|
+
# block the backlog behind it.
|
|
123
|
+
return unless send_batch(batch)
|
|
124
|
+
end
|
|
36
125
|
end
|
|
37
126
|
|
|
38
127
|
def start
|
|
@@ -42,10 +131,15 @@ module Featureflip
|
|
|
42
131
|
until @stop_flag
|
|
43
132
|
sleep(1)
|
|
44
133
|
elapsed += 1
|
|
45
|
-
|
|
46
|
-
|
|
134
|
+
next if @stop_flag
|
|
135
|
+
|
|
136
|
+
if elapsed >= @flush_interval
|
|
137
|
+
# The interval tick is the retry vehicle for a re-queued batch, so it is
|
|
138
|
+
# deliberately NOT subject to the size trigger's backoff gate.
|
|
139
|
+
elapsed = 0
|
|
140
|
+
flush
|
|
141
|
+
elsif auto_flush
|
|
47
142
|
elapsed = 0
|
|
48
|
-
flush unless @stop_flag
|
|
49
143
|
end
|
|
50
144
|
end
|
|
51
145
|
end
|
|
@@ -56,7 +150,168 @@ module Featureflip
|
|
|
56
150
|
@thread&.wakeup rescue nil
|
|
57
151
|
@thread&.join(5)
|
|
58
152
|
@thread = nil
|
|
59
|
-
|
|
153
|
+
|
|
154
|
+
# Closed BEFORE the final flush so a failure there is dropped rather than
|
|
155
|
+
# re-queued: nothing will flush again, and retrying until the queue drains would
|
|
156
|
+
# hang shutdown for as long as the endpoint stayed down. One attempt, then let go.
|
|
157
|
+
@mutex.synchronize { @stopped = true }
|
|
158
|
+
# drain, not flush: shutdown must never be the call that gets coalesced
|
|
159
|
+
# away. If the interval tick's drain happens to be in flight, flush would
|
|
160
|
+
# wait for it and return, and anything queued after that loop's last look
|
|
161
|
+
# would be discarded unsent. Two drains overlapping is safe here precisely
|
|
162
|
+
# because @stopped is already set, so neither can re-queue and there is no
|
|
163
|
+
# backoff left to disarm.
|
|
164
|
+
drain
|
|
165
|
+
@mutex.synchronize { @queue.clear }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# Batch-size-triggered flush. Returns true only if it actually sent.
|
|
171
|
+
#
|
|
172
|
+
# A re-queued batch leaves the queue at or above @flush_batch_size, so without a gate
|
|
173
|
+
# every subsequent event would trigger another flush — turning a failing endpoint
|
|
174
|
+
# into one request per evaluation, which is worse for the server than the dropping
|
|
175
|
+
# this replaced. Two guards, and both are load-bearing:
|
|
176
|
+
#
|
|
177
|
+
# * the backoff gate suppresses the size trigger for one flush interval after a
|
|
178
|
+
# retryable failure, leaving the background thread's interval tick as the retry
|
|
179
|
+
# vehicle;
|
|
180
|
+
# * the in-flight latch covers what the gate cannot, because the gate is only armed
|
|
181
|
+
# once a flush has already FAILED and the size trigger fires again long before
|
|
182
|
+
# the first round-trip returns. Without it a tight loop of events starts a pile
|
|
183
|
+
# of concurrent flushes.
|
|
184
|
+
#
|
|
185
|
+
# An explicit #flush (the public API, and the interval tick) bypasses both: the
|
|
186
|
+
# caller asked for a send.
|
|
187
|
+
def auto_flush
|
|
188
|
+
return false unless @mutex.synchronize { claim_size_trigger }
|
|
189
|
+
|
|
190
|
+
begin
|
|
191
|
+
flush
|
|
192
|
+
ensure
|
|
193
|
+
@mutex.synchronize { @auto_flush_in_flight = false }
|
|
194
|
+
end
|
|
195
|
+
true
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Whether a size-triggered flush may start, claiming the latch if so.
|
|
199
|
+
# Caller holds @mutex.
|
|
200
|
+
def claim_size_trigger
|
|
201
|
+
return false if @stopped
|
|
202
|
+
return false if @auto_flush_in_flight
|
|
203
|
+
return false if @queue.length < @flush_batch_size
|
|
204
|
+
return false if monotonic_now < @next_auto_flush_at
|
|
205
|
+
|
|
206
|
+
@auto_flush_in_flight = true
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Never called with @mutex held: the POST blocks for as long as the endpoint takes to
|
|
210
|
+
# answer — and Http::Client sleeps a second before its own single inline retry — so
|
|
211
|
+
# the background thread and every queue_event caller would block behind it.
|
|
212
|
+
# Sends one batch. Returns true if #flush may go on to the next one — see the comment
|
|
213
|
+
# at its call site for why a re-queued batch must stop the drain.
|
|
214
|
+
def send_batch(events)
|
|
215
|
+
@http_client.post_events(events)
|
|
216
|
+
@mutex.synchronize { @next_auto_flush_at = 0.0 }
|
|
217
|
+
true
|
|
218
|
+
rescue StandardError => e
|
|
219
|
+
unless retryable_failure?(e)
|
|
220
|
+
@logger&.warn(
|
|
221
|
+
"Featureflip: dropped #{events.length} analytics event(s) the events endpoint " \
|
|
222
|
+
"rejected (#{e.class}: #{e.message}); the failure is not retryable"
|
|
223
|
+
)
|
|
224
|
+
return true
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
dropped = requeue(events)
|
|
228
|
+
# Armed here rather than at the first attempt, so the interval is counted from the
|
|
229
|
+
# moment post_events' inline retry finally gave up.
|
|
230
|
+
@mutex.synchronize { @next_auto_flush_at = monotonic_now + @flush_interval }
|
|
231
|
+
|
|
232
|
+
if dropped.nil?
|
|
233
|
+
@logger&.warn(
|
|
234
|
+
"Featureflip: dropped #{events.length} analytics event(s) " \
|
|
235
|
+
"(#{e.class}: #{e.message}); the processor is shutting down and will not flush again"
|
|
236
|
+
)
|
|
237
|
+
else
|
|
238
|
+
@logger&.warn(
|
|
239
|
+
"Featureflip: failed to send #{events.length} analytics event(s) " \
|
|
240
|
+
"(#{e.class}: #{e.message}); re-queued for the next flush"
|
|
241
|
+
)
|
|
242
|
+
warn_overflow(dropped)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
false
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Whether the same batch could succeed if it were sent again.
|
|
249
|
+
#
|
|
250
|
+
# For an HTTP answer the status decides: any 5xx — the production edge answers this
|
|
251
|
+
# endpoint with a 503 at a low but constant rate (#2456) — and 429, where the server
|
|
252
|
+
# is explicitly asking us to come back later. Anything else will fail identically next
|
|
253
|
+
# time: 401/403 means the SDK key was rejected, 400 means the body is malformed, and
|
|
254
|
+
# retrying either forever would pin the queue at its bound and starve every later
|
|
255
|
+
# event.
|
|
256
|
+
#
|
|
257
|
+
# Everything that is NOT an HTTP answer is treated as transient. A transport fault
|
|
258
|
+
# (connection reset, DNS, TLS) or a timeout carries no status at all and is exactly
|
|
259
|
+
# the kind of failure a later flush gets past, so the default has to be "keep it"
|
|
260
|
+
# rather than "not a 5xx, therefore permanent".
|
|
261
|
+
def retryable_failure?(error)
|
|
262
|
+
return error.status >= 500 || error.status == 429 if error.is_a?(Featureflip::HttpStatusError)
|
|
263
|
+
|
|
264
|
+
true
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Takes up to @flush_batch_size of the OLDEST events, leaving the rest queued.
|
|
268
|
+
def drain_batch
|
|
269
|
+
@mutex.synchronize do
|
|
270
|
+
return [] if @queue.empty?
|
|
271
|
+
|
|
272
|
+
@queue.shift([@flush_batch_size, @queue.length].min)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Puts a batch that failed to send back at the FRONT of the queue, so the next flush
|
|
277
|
+
# retries it ahead of newer events and rough chronological order survives.
|
|
278
|
+
#
|
|
279
|
+
# Returns how many events were shed to stay within the bound — or nil if it refused
|
|
280
|
+
# the batch entirely because #stop has closed the queue. The caller needs to tell
|
|
281
|
+
# those apart: "re-queued for the next flush" is a lie once there will be no next
|
|
282
|
+
# flush, and this is the branch a shutdown during an outage takes.
|
|
283
|
+
def requeue(events)
|
|
284
|
+
@mutex.synchronize do
|
|
285
|
+
return nil if @stopped
|
|
286
|
+
|
|
287
|
+
@queue.unshift(*events)
|
|
288
|
+
trim_to_bound
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Sheds oldest-first until the queue fits the bound, returning how many went.
|
|
293
|
+
# Caller holds @mutex.
|
|
294
|
+
def trim_to_bound
|
|
295
|
+
overflow = @queue.length - @max_queue_size
|
|
296
|
+
return 0 if overflow <= 0
|
|
297
|
+
|
|
298
|
+
@queue.shift(overflow)
|
|
299
|
+
overflow
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def warn_overflow(dropped)
|
|
303
|
+
return if dropped.zero?
|
|
304
|
+
|
|
305
|
+
@logger&.warn(
|
|
306
|
+
"Featureflip: event queue is full (#{@max_queue_size}); " \
|
|
307
|
+
"dropped #{dropped} of the oldest analytics event(s)"
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Wall-clock time can jump backwards (NTP, a suspended host); the backoff gate must
|
|
312
|
+
# not be extended or skipped by that.
|
|
313
|
+
def monotonic_now
|
|
314
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
60
315
|
end
|
|
61
316
|
end
|
|
62
317
|
end
|
|
@@ -20,18 +20,37 @@ module Featureflip
|
|
|
20
20
|
# connect-time `sync` SSE snapshot, which carries the identical payload
|
|
21
21
|
# shape inline (no extra HTTP round-trip).
|
|
22
22
|
def parse_flags_response(data)
|
|
23
|
-
flags = (data["flags"] || []).map { |f| parse_flag(f) }
|
|
24
|
-
|
|
23
|
+
flags = drop_unevaluable((data["flags"] || []).map { |f| parse_flag(f) }, "flag") do |flag|
|
|
24
|
+
unevaluable_flag_reason(flag)
|
|
25
|
+
end
|
|
26
|
+
segments = drop_unevaluable((data["segments"] || []).map { |s| parse_segment(s) }, "segment") do |segment|
|
|
27
|
+
unevaluable_segment_reason(segment)
|
|
28
|
+
end
|
|
25
29
|
[flags, segments]
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def get_flag(key)
|
|
29
33
|
response = request(:get, "/v1/sdk/flags/#{key}")
|
|
30
|
-
parse_flag(JSON.parse(response.body))
|
|
34
|
+
flag = parse_flag(JSON.parse(response.body))
|
|
35
|
+
|
|
36
|
+
# An unevaluable enum drops the flag rather than upserting it (#2402). For a
|
|
37
|
+
# delta whose whole scope is one flag that means leaving the store's previous
|
|
38
|
+
# copy alone: replacing it with one this build would mis-evaluate is the outcome
|
|
39
|
+
# the drop exists to prevent, and FLAG_NOT_FOUND is the honest answer if there
|
|
40
|
+
# was no previous copy.
|
|
41
|
+
reason = unevaluable_flag_reason(flag)
|
|
42
|
+
raise UnevaluableEntityError, "flag #{key.inspect}: #{reason}" if reason
|
|
43
|
+
|
|
44
|
+
flag
|
|
31
45
|
end
|
|
32
46
|
|
|
33
47
|
def post_events(events)
|
|
34
|
-
|
|
48
|
+
# The one caller that retries inline. EventProcessor#flush drains the queue before
|
|
49
|
+
# sending, so a batch only survives a failure because the processor puts it back --
|
|
50
|
+
# this absorbs a single transient 5xx before that machinery is needed, which keeps
|
|
51
|
+
# the common blip off the re-queue path entirely. The processor's backoff gate is
|
|
52
|
+
# measured from the moment this finally gives up, not from the first attempt.
|
|
53
|
+
request(:post, "/v1/sdk/events", { events: events }, retry_server_errors: true)
|
|
35
54
|
end
|
|
36
55
|
|
|
37
56
|
def close
|
|
@@ -40,7 +59,13 @@ module Featureflip
|
|
|
40
59
|
|
|
41
60
|
private
|
|
42
61
|
|
|
43
|
-
|
|
62
|
+
# retry_server_errors: retry once on a 5xx. Off by default — the poller re-fetches
|
|
63
|
+
# every poll_interval and the streaming source reconnects with backoff, so for flag
|
|
64
|
+
# reads an inner retry buys nothing and doubles request volume against a dependency
|
|
65
|
+
# that is already failing (it also blocked for a second inside the init_timeout
|
|
66
|
+
# budget on cold start). eval-api answers 503 when it cannot reach the Management
|
|
67
|
+
# API, which is exactly the status this used to trip on.
|
|
68
|
+
def request(method, path, body = nil, retries: 1, retry_server_errors: false)
|
|
44
69
|
uri = URI("#{@base_url}#{path}")
|
|
45
70
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
46
71
|
http.use_ssl = uri.scheme == "https"
|
|
@@ -62,13 +87,16 @@ module Featureflip
|
|
|
62
87
|
|
|
63
88
|
response = http.request(req)
|
|
64
89
|
|
|
65
|
-
if response.is_a?(Net::HTTPServerError) && retries > 0
|
|
90
|
+
if retry_server_errors && response.is_a?(Net::HTTPServerError) && retries > 0
|
|
66
91
|
sleep(1)
|
|
67
|
-
return request(method, path, body, retries: retries - 1)
|
|
92
|
+
return request(method, path, body, retries: retries - 1, retry_server_errors: retry_server_errors)
|
|
68
93
|
end
|
|
69
94
|
|
|
70
95
|
unless response.is_a?(Net::HTTPSuccess)
|
|
71
|
-
|
|
96
|
+
# HttpStatusError, not a bare Error: the events flush branches on the status to
|
|
97
|
+
# decide whether the batch is worth keeping (#2456). Same message and same
|
|
98
|
+
# ancestry, so nothing that rescues Featureflip::Error changes behaviour.
|
|
99
|
+
raise HttpStatusError.new(response.code.to_i, path)
|
|
72
100
|
end
|
|
73
101
|
|
|
74
102
|
response
|
|
@@ -76,7 +104,7 @@ module Featureflip
|
|
|
76
104
|
Net::OpenTimeout, Net::ReadTimeout => e
|
|
77
105
|
raise if retries <= 0
|
|
78
106
|
sleep(1)
|
|
79
|
-
request(method, path, body, retries: retries - 1)
|
|
107
|
+
request(method, path, body, retries: retries - 1, retry_server_errors: retry_server_errors)
|
|
80
108
|
end
|
|
81
109
|
|
|
82
110
|
# Enum fields are strings on the wire. Ruby keeps whatever it is handed and the
|
|
@@ -165,6 +193,88 @@ module Featureflip
|
|
|
165
193
|
condition_logic: require_enum_string!(data["conditionLogic"], "segment.conditionLogic") || "And"
|
|
166
194
|
)
|
|
167
195
|
end
|
|
196
|
+
|
|
197
|
+
# Entity-level drop for enum values this SDK build cannot evaluate (#2402).
|
|
198
|
+
#
|
|
199
|
+
# `serve.type` and `conditionLogic` are the two enums that are BOTH carried on the
|
|
200
|
+
# wire as strings AND consulted by the evaluator, and each dispatches on a two-way
|
|
201
|
+
# branch with no third arm:
|
|
202
|
+
#
|
|
203
|
+
# serve.type == "Fixed" ... else ROLLOUT
|
|
204
|
+
# logic == "And" ... else ANY (OR)
|
|
205
|
+
#
|
|
206
|
+
# So an unrecognised value does not fail — it takes the ELSE arm. A segment
|
|
207
|
+
# carrying conditionLogic "Xor" evaluates as OR, so a segment meant to require ALL
|
|
208
|
+
# of its conditions matches ANY of them: the rule fails OPEN and over-targets.
|
|
209
|
+
#
|
|
210
|
+
# Neither obvious fix works. Tolerating the value — as an unknown flag.type is
|
|
211
|
+
# tolerated — IS that silent mis-evaluation; flag.type is safe to tolerate only
|
|
212
|
+
# because nothing evaluates it. Raising MalformedPayloadError would discard the
|
|
213
|
+
# whole payload, so one additive server change takes down every flag on a pinned
|
|
214
|
+
# client (the #2372/#2395 outage shape).
|
|
215
|
+
#
|
|
216
|
+
# So the containing entity goes instead. Dropping a segment leaves rules pointing
|
|
217
|
+
# at it dangling, which is safe: Evaluation::Evaluator already treats an
|
|
218
|
+
# unresolvable segment_key as no-match (#1459), so the cascade fails CLOSED. The
|
|
219
|
+
# engine-generated `f-segment-unresolvable` golden vector pins that.
|
|
220
|
+
#
|
|
221
|
+
# Scoped deliberately to a NON-EMPTY unrecognised value. An absent field already
|
|
222
|
+
# defaults to "And" above, and the missing-required-field axis is a separate
|
|
223
|
+
# concern that the SDKs deliberately disagree on; checking only values that are
|
|
224
|
+
# present and unrecognised keeps this change purely additive.
|
|
225
|
+
SERVE_TYPES = ["Fixed", "Rollout"].freeze
|
|
226
|
+
CONDITION_LOGIC = ["And", "Or"].freeze
|
|
227
|
+
|
|
228
|
+
def drop_unevaluable(entities, kind)
|
|
229
|
+
entities.reject do |entity|
|
|
230
|
+
reason = yield(entity)
|
|
231
|
+
next false unless reason
|
|
232
|
+
|
|
233
|
+
@config.logger&.warn(
|
|
234
|
+
"Featureflip: dropping #{kind} #{entity.key.inspect}: #{reason}. This SDK " \
|
|
235
|
+
"version may be older than the flag configuration; the rest of the " \
|
|
236
|
+
"configuration was applied."
|
|
237
|
+
)
|
|
238
|
+
true
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Why this flag cannot be evaluated, or nil if it can. A reason rather than a
|
|
243
|
+
# boolean so the diagnostic can name the field and the value actually received.
|
|
244
|
+
def unevaluable_flag_reason(flag)
|
|
245
|
+
reason = unevaluable_serve_reason(flag.fallthrough, "fallthrough")
|
|
246
|
+
return reason if reason
|
|
247
|
+
|
|
248
|
+
(flag.rules || []).each do |rule|
|
|
249
|
+
reason = unevaluable_serve_reason(rule.serve, "rule[#{rule.id}].serve")
|
|
250
|
+
return reason if reason
|
|
251
|
+
|
|
252
|
+
(rule.condition_groups || []).each do |group|
|
|
253
|
+
next if group.operator.nil? || group.operator.empty?
|
|
254
|
+
next if CONDITION_LOGIC.include?(group.operator)
|
|
255
|
+
|
|
256
|
+
return "rule[#{rule.id}].conditionGroup.operator #{group.operator.inspect} " \
|
|
257
|
+
"is not a condition logic this SDK version understands"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Why this segment cannot be evaluated, or nil if it can.
|
|
265
|
+
def unevaluable_segment_reason(segment)
|
|
266
|
+
logic = segment.condition_logic
|
|
267
|
+
return nil if logic.nil? || logic.empty? || CONDITION_LOGIC.include?(logic)
|
|
268
|
+
|
|
269
|
+
"conditionLogic #{logic.inspect} is not a condition logic this SDK version understands"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def unevaluable_serve_reason(serve, path)
|
|
273
|
+
type = serve&.type
|
|
274
|
+
return nil if type.nil? || type.empty? || SERVE_TYPES.include?(type)
|
|
275
|
+
|
|
276
|
+
"#{path}.type #{type.inspect} is not a serve type this SDK version understands"
|
|
277
|
+
end
|
|
168
278
|
end
|
|
169
279
|
end
|
|
170
280
|
end
|
|
@@ -386,7 +386,10 @@ module Featureflip
|
|
|
386
386
|
@event_processor = Events::EventProcessor.new(
|
|
387
387
|
@http_client,
|
|
388
388
|
flush_interval: @config.flush_interval,
|
|
389
|
-
flush_batch_size: @config.flush_batch_size
|
|
389
|
+
flush_batch_size: @config.flush_batch_size,
|
|
390
|
+
# A dropped or re-queued batch must be visible: this fix keeps analytics that the
|
|
391
|
+
# edge briefly rejects, and it must not also hide the rejections themselves.
|
|
392
|
+
logger: @config.logger
|
|
390
393
|
)
|
|
391
394
|
@event_processor.start
|
|
392
395
|
end
|
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.7.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-
|
|
11
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|