featureflip 2.6.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
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
|
|
@@ -101,6 +101,10 @@ module Featureflip
|
|
|
101
101
|
/\A(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?\z/
|
|
102
102
|
private_constant :ISO_OPERAND
|
|
103
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
|
|
107
|
+
|
|
104
108
|
def evaluate_operator(operator, value, targets)
|
|
105
109
|
# Case-insensitive views for the string/relational/date operators.
|
|
106
110
|
ci_value = value.downcase
|
|
@@ -232,15 +236,42 @@ module Featureflip
|
|
|
232
236
|
left.send(op, right)
|
|
233
237
|
end
|
|
234
238
|
|
|
235
|
-
# Parses a date-time to a UTC `Time`, mirroring the engine's
|
|
236
|
-
# TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
|
|
237
|
-
# without an offset is assumed UTC. A bare integer is treated as Unix time
|
|
238
|
-
# in seconds. Returns nil when the input parses as neither.
|
|
239
239
|
# DateTimeOffset.MinValue / MaxValue as unix seconds -- the exact bounds the
|
|
240
240
|
# engine's FromUnixTimeSeconds accepts before throwing (#2432).
|
|
241
241
|
MIN_UNIX_SECONDS = -62_135_596_800
|
|
242
242
|
MAX_UNIX_SECONDS = 253_402_300_799
|
|
243
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
|
+
|
|
244
275
|
# Rewrites an accepted ISO operand into the strict extended form Time.iso8601
|
|
245
276
|
# parses: "T" separator, seconds present, offset spelled "+HH:MM" or "Z".
|
|
246
277
|
# Returns nil when the operand is not an accepted ISO shape.
|
|
@@ -249,6 +280,12 @@ module Featureflip
|
|
|
249
280
|
return nil if m.nil?
|
|
250
281
|
|
|
251
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
|
+
|
|
252
289
|
return "#{date}T00:00:00Z" if hh.nil?
|
|
253
290
|
|
|
254
291
|
# The engine's DateTimeOffset.TryParse rejects hour 24 outright rather than
|
|
@@ -264,6 +301,10 @@ module Featureflip
|
|
|
264
301
|
"#{date}T#{hh}:#{mm}:#{ss}#{frac}#{off}"
|
|
265
302
|
end
|
|
266
303
|
|
|
304
|
+
# Parses a date-time to a UTC `Time`, mirroring the engine's
|
|
305
|
+
# TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
|
|
306
|
+
# without an offset is assumed UTC. A bare integer is treated as Unix time
|
|
307
|
+
# in seconds. Returns nil when the input parses as neither.
|
|
267
308
|
def parse_datetime(value)
|
|
268
309
|
s = value.to_s
|
|
269
310
|
# Trim exactly the engine's whitespace class, then reject anything still
|
|
@@ -276,10 +317,35 @@ module Featureflip
|
|
|
276
317
|
begin
|
|
277
318
|
# Offset-less forms were canonicalized to an explicit "Z", mirroring
|
|
278
319
|
# DateTimeOffset.TryParse with AssumeUniversal.
|
|
279
|
-
|
|
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
|
|
280
344
|
rescue ArgumentError
|
|
281
|
-
#
|
|
282
|
-
#
|
|
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.
|
|
283
349
|
end
|
|
284
350
|
end
|
|
285
351
|
|
|
@@ -34,6 +34,22 @@ module Featureflip
|
|
|
34
34
|
# — see #auto_flush.
|
|
35
35
|
@next_auto_flush_at = 0.0
|
|
36
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
|
|
37
53
|
end
|
|
38
54
|
|
|
39
55
|
def queue_event(event)
|
|
@@ -58,7 +74,43 @@ module Featureflip
|
|
|
58
74
|
# at its 10,000-event bound, and posting all of that at once risks a body the server
|
|
59
75
|
# rejects outright. A 413 is non-retryable, so the entire backlog would be dropped by
|
|
60
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).
|
|
61
82
|
def flush
|
|
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
|
|
90
|
+
end
|
|
91
|
+
|
|
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
|
|
99
|
+
|
|
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
|
|
62
114
|
loop do
|
|
63
115
|
batch = drain_batch
|
|
64
116
|
return if batch.empty?
|
|
@@ -103,7 +155,13 @@ module Featureflip
|
|
|
103
155
|
# re-queued: nothing will flush again, and retrying until the queue drains would
|
|
104
156
|
# hang shutdown for as long as the endpoint stayed down. One attempt, then let go.
|
|
105
157
|
@mutex.synchronize { @stopped = true }
|
|
106
|
-
flush
|
|
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
|
|
107
165
|
@mutex.synchronize { @queue.clear }
|
|
108
166
|
end
|
|
109
167
|
|
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
|