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: edea6a4841539f13194e1e03e76bd7db298e713a0d79ec66327838cc56ed8408
4
- data.tar.gz: 5a2d3f0946e19fb1734bc7db35dc1006f5d39f22626cbd147f527c31aa8dbddd
3
+ metadata.gz: 3d0798d6f9dde45580a5345467cfcbc108b01a0bda1ed9c0224a6e58305ec9bd
4
+ data.tar.gz: d7a7ceca0d72477e0987ee83b0af3bdcee069f47add6ecc89a42bb7c9cb635e4
5
5
  SHA512:
6
- metadata.gz: 894e77cfd032b59b62a8b1f9832ad5370d042c20290604b7f836bf9f35c427503f1b29eb3495c80fa02f816ae60e9d6c45c2d9f2074a9621aeb2cdaf1dd9d235
7
- data.tar.gz: 472b4f2dbeb2dc1806a7e2dba1128b26bbe52a63833fcdd0c4571abc7b300b7d61ee153fe80cd35462c3782bbedee3b76695093c617a14600972e296f8c3ea53
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 healthy stream just
199
- # closed cleanly; still apply the base floor so we don't busy-loop.
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
- return Time.iso8601(iso).utc
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
- # A syntactically-valid but non-existent date (e.g. 2024-02-31) --
282
- # fall through to the Unix-seconds fallback, which will also reject it.
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
 
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.6.1"
2
+ VERSION = "2.7.0"
3
3
  end
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.6.1
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-25 00:00:00.000000000 Z
11
+ date: 2026-08-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger