featureflip 2.6.1 → 2.8.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: f3d59e135c8bde6e1955c965c186ade207bbb58961bbb95ad949497940efbd8d
4
+ data.tar.gz: 9a25588388685e6e08f206b4ed623b491e949df2fc448797ba4305cdfdedfd4f
5
5
  SHA512:
6
- metadata.gz: 894e77cfd032b59b62a8b1f9832ad5370d042c20290604b7f836bf9f35c427503f1b29eb3495c80fa02f816ae60e9d6c45c2d9f2074a9621aeb2cdaf1dd9d235
7
- data.tar.gz: 472b4f2dbeb2dc1806a7e2dba1128b26bbe52a63833fcdd0c4571abc7b300b7d61ee153fe80cd35462c3782bbedee3b76695093c617a14600972e296f8c3ea53
6
+ metadata.gz: ad445faee786f603e3702df154e78afe5ae8d3ca6868be6c4d5dcc97f03fc4006f364cf40d9c2a73d5e0d43744f5074228186b6b3f69a1a97fd03ca678aa037a
7
+ data.tar.gz: 5bdd2687ecec69386d9cde9f8b0c5190bd4bee4e49f3187f3762263be21e28e6d3dd09517c4b44451f4ddcc969fa70543a7177f91ba6f2b39fd7a785bf590147
@@ -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
@@ -14,14 +14,19 @@ module Featureflip
14
14
 
15
15
  return condition.negate if attr_value.nil?
16
16
 
17
+ # Resolve the operator ONCE (#2374). Every subsequent decision that keys
18
+ # off it -- numeric coercion, dispatch -- must read the same normalised
19
+ # label, or a mis-cased operator takes some paths and not others.
20
+ operator = normalize_operator(condition.operator)
21
+
17
22
  # Issue #1458: when the attribute is a native numeric (Integer/Float —
18
23
  # Ruby's `true`/`false` are NOT Numeric, so booleans are naturally
19
24
  # excluded), the equality-family operators coerce the condition values to
20
25
  # numbers and compare numerically, so 1.0 matches "1". This mirrors the
21
26
  # engine's type-aware path and runs BEFORE stringification — a String
22
27
  # 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)
28
+ if attr_value.is_a?(Numeric) && NUMERIC_EQUALITY_OPERATORS.include?(operator)
29
+ return evaluate_numeric_equality(condition, operator, attr_value)
25
30
  end
26
31
 
27
32
  # Pass the raw (case-preserved) strings to the operator dispatcher.
@@ -31,7 +36,7 @@ module Featureflip
31
36
  str_value = attr_value.to_s
32
37
  targets = condition.values.map(&:to_s)
33
38
 
34
- result = evaluate_operator(condition.operator, str_value, targets)
39
+ result = evaluate_operator(operator, str_value, targets)
35
40
 
36
41
  # Issue #2262: an unrecognised operator fails CLOSED. `!nil` is `true`
37
42
  # in Ruby, so without this guard a negated unknown operator would match
@@ -66,7 +71,7 @@ module Featureflip
66
71
  # The equality-family operators that get type-aware numeric coercion when
67
72
  # the attribute is a native Numeric (Issue #1458). Relational/string ops
68
73
  # are deliberately excluded — only these four coerce.
69
- NUMERIC_EQUALITY_OPERATORS = %w[Equals NotEquals In NotIn].freeze
74
+ NUMERIC_EQUALITY_OPERATORS = %w[equals notequals in notin].freeze
70
75
  private_constant :NUMERIC_EQUALITY_OPERATORS
71
76
 
72
77
  # A parsed semantic version: the release core as dot-separated numeric
@@ -101,29 +106,51 @@ module Featureflip
101
106
  /\A(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?\z/
102
107
  private_constant :ISO_OPERAND
103
108
 
109
+ # Length of each month in a non-leap year, indexed 1..12. Index 0 is unused padding.
110
+ DAYS_IN_MONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
111
+ private_constant :DAYS_IN_MONTH
112
+
113
+ # The single definition of "recognised operator" shared by the four
114
+ # string-typed SDKs (js, go, ruby, php) -- see #2374. Strips underscores
115
+ # and folds case, so the canonical PascalCase the API emits ("NotEquals"),
116
+ # the concatenated form go accepted ("notequals", "NOTEQUALS") and the
117
+ # snake_case form php accepted ("not_equals") all resolve to one label.
118
+ #
119
+ # This SDK previously matched the PascalCase labels EXACTLY, so every
120
+ # mis-cased spelling was simply unknown and failed closed. Normalising by
121
+ # removal makes the shared rule a superset of what each SDK accepted
122
+ # before, so no SDK gets stricter and no configuration that evaluated
123
+ # before stops doing so. The concatenated labels stay unambiguous under
124
+ # this mapping -- no two operator names collide once underscores go.
125
+ def normalize_operator(operator)
126
+ operator.to_s.delete("_").downcase
127
+ end
128
+
129
+ # `operator` arrives already normalised, so the labels below are the
130
+ # concatenated lowercase form rather than the wire PascalCase.
104
131
  def evaluate_operator(operator, value, targets)
105
132
  # Case-insensitive views for the string/relational/date operators.
106
133
  ci_value = value.downcase
107
134
  ci_targets = targets.map(&:downcase)
108
135
 
109
136
  case operator
110
- when "Equals"
137
+ when "equals"
111
138
  ci_targets.any? { |t| ci_value == t }
112
- when "NotEquals"
139
+ when "notequals"
113
140
  ci_targets.all? { |t| ci_value != t }
114
- when "Contains"
141
+ when "contains"
115
142
  ci_targets.any? { |t| ci_value.include?(t) }
116
- when "NotContains"
143
+ when "notcontains"
117
144
  ci_targets.all? { |t| !ci_value.include?(t) }
118
- when "StartsWith"
145
+ when "startswith"
119
146
  ci_targets.any? { |t| ci_value.start_with?(t) }
120
- when "EndsWith"
147
+ when "endswith"
121
148
  ci_targets.any? { |t| ci_value.end_with?(t) }
122
- when "In"
149
+ when "in"
123
150
  ci_targets.include?(ci_value)
124
- when "NotIn"
151
+ when "notin"
125
152
  !ci_targets.include?(ci_value)
126
- when "MatchesRegex"
153
+ when "matchesregex"
127
154
  # Case-sensitive matching on the original-case value and pattern,
128
155
  # mirroring the engine (RegexOptions.None). Case-insensitivity is
129
156
  # opt-in via the (?i) inline flag in the pattern.
@@ -141,13 +168,13 @@ module Featureflip
141
168
  # against ANY condition value (mirroring the server engine), not just
142
169
  # values[0]. `.any?` over an empty array is false, so empty values
143
170
  # returns false without error.
144
- when "GreaterThan"
171
+ when "greaterthan"
145
172
  ci_targets.any? { |t| compare_numeric(ci_value, t, :>) }
146
- when "GreaterThanOrEqual"
173
+ when "greaterthanorequal"
147
174
  ci_targets.any? { |t| compare_numeric(ci_value, t, :>=) }
148
- when "LessThan"
175
+ when "lessthan"
149
176
  ci_targets.any? { |t| compare_numeric(ci_value, t, :<) }
150
- when "LessThanOrEqual"
177
+ when "lessthanorequal"
151
178
  ci_targets.any? { |t| compare_numeric(ci_value, t, :<=) }
152
179
  # Date operators compare against the RAW value/targets, not the
153
180
  # lowercased copies: downcasing breaks ISO-8601 parsing (the "Z" UTC
@@ -155,23 +182,23 @@ module Featureflip
155
182
  # to UTC instants — offsets are honored, no-offset strings are assumed
156
183
  # UTC, and a bare integer is treated as Unix seconds — so an unparseable
157
184
  # operand matches nothing instead of falling back to a string compare.
158
- when "Before"
185
+ when "before"
159
186
  targets.any? { |t| compare_datetime(value, t, :<) }
160
- when "After"
187
+ when "after"
161
188
  targets.any? { |t| compare_datetime(value, t, :>) }
162
189
  # Semantic-version operators compare against the RAW value/targets:
163
190
  # prerelease precedence is case-sensitive (semver §11), so the casing
164
191
  # preserved by `evaluate_condition` must not be folded here. An
165
192
  # unparseable version matches nothing, like the numeric/date operators.
166
- when "SemverEquals"
193
+ when "semverequals"
167
194
  targets.any? { |t| compare_semver(value, t, :==) }
168
- when "SemverGreaterThan"
195
+ when "semvergreaterthan"
169
196
  targets.any? { |t| compare_semver(value, t, :>) }
170
- when "SemverGreaterThanOrEqual"
197
+ when "semvergreaterthanorequal"
171
198
  targets.any? { |t| compare_semver(value, t, :>=) }
172
- when "SemverLessThan"
199
+ when "semverlessthan"
173
200
  targets.any? { |t| compare_semver(value, t, :<) }
174
- when "SemverLessThanOrEqual"
201
+ when "semverlessthanorequal"
175
202
  targets.any? { |t| compare_semver(value, t, :<=) }
176
203
  else
177
204
  # Unrecognised operator. `nil` — NOT `false` — so the caller can tell
@@ -186,21 +213,21 @@ module Featureflip
186
213
  # numerically against the attribute. Equals/In match if ANY value is equal;
187
214
  # NotEquals/NotIn are their negation. The `negate` flag is then applied,
188
215
  # mirroring `evaluate_condition`'s tail.
189
- def evaluate_numeric_equality(condition, attr_value)
216
+ def evaluate_numeric_equality(condition, operator, attr_value)
190
217
  target = attr_value.to_f
191
218
  any_equal = condition.values.any? do |v|
192
219
  n = parse_numeric(v)
193
220
  n && n == target
194
221
  end
195
222
 
196
- positive = NUMERIC_EQUALITY_POSITIVE_OPERATORS.include?(condition.operator)
223
+ positive = NUMERIC_EQUALITY_POSITIVE_OPERATORS.include?(operator)
197
224
  result = positive ? any_equal : !any_equal
198
225
  condition.negate ? !result : result
199
226
  end
200
227
 
201
228
  # Equals/In are the "positive" members of the equality family (match on
202
229
  # equality); NotEquals/NotIn negate the same any-equal test.
203
- NUMERIC_EQUALITY_POSITIVE_OPERATORS = %w[Equals In].freeze
230
+ NUMERIC_EQUALITY_POSITIVE_OPERATORS = %w[equals in].freeze
204
231
  private_constant :NUMERIC_EQUALITY_POSITIVE_OPERATORS
205
232
 
206
233
  # Strict literal parse of a condition value to a Float, reusing the same
@@ -232,15 +259,42 @@ module Featureflip
232
259
  left.send(op, right)
233
260
  end
234
261
 
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
262
  # DateTimeOffset.MinValue / MaxValue as unix seconds -- the exact bounds the
240
263
  # engine's FromUnixTimeSeconds accepts before throwing (#2432).
241
264
  MIN_UNIX_SECONDS = -62_135_596_800
242
265
  MAX_UNIX_SECONDS = 253_402_300_799
243
266
 
267
+ # Whether +date+ -- always "YYYY-MM-DD", since only canonicalize_iso calls this --
268
+ # names a day that exists.
269
+ #
270
+ # ISO_OPERAND matches the SHAPE of a calendar date and a character class cannot
271
+ # express "is a real day", so "2024-02-30", "2023-02-29" and "2024-04-31" all pass
272
+ # the grammar. The engine, csharp, go, python and java then reject them at parse;
273
+ # ruby, js and php ROLLED THEM OVER into the 1st of the following month, so one
274
+ # saved rule served different variations to two users purely by which SDK their
275
+ # service ran (#2491).
276
+ #
277
+ # Hand-rolled rather than delegated to Date.valid_date?, which applies the Italian
278
+ # calendar reform by DEFAULT and so rejects 1582-10-05..14 -- dates the engine
279
+ # resolves normally. Passing Date::GREGORIAN would fix that, but computing the
280
+ # arithmetic identically in ruby, js and php is what keeps the accepted set a
281
+ # property of THIS contract rather than of three separate calendars.
282
+ #
283
+ # Proleptic Gregorian, matching the engine: the leap rule applies at every year
284
+ # rather than from a reform date onward.
285
+ def real_calendar_day?(date)
286
+ year = date[0, 4].to_i
287
+ month = date[5, 2].to_i
288
+ day = date[8, 2].to_i
289
+
290
+ # Month 0 and day 0 are the shapes only php mishandled, rolling each BACKWARDS
291
+ # into the previous year ("2024-00-01" -> 2023-12-01, "2024-01-00" -> 2023-12-31).
292
+ return false if month < 1 || month > 12 || day < 1
293
+
294
+ leap_day = month == 2 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) ? 1 : 0
295
+ day <= DAYS_IN_MONTH[month] + leap_day
296
+ end
297
+
244
298
  # Rewrites an accepted ISO operand into the strict extended form Time.iso8601
245
299
  # parses: "T" separator, seconds present, offset spelled "+HH:MM" or "Z".
246
300
  # Returns nil when the operand is not an accepted ISO shape.
@@ -249,6 +303,12 @@ module Featureflip
249
303
  return nil if m.nil?
250
304
 
251
305
  date, hh, mm, ss, frac, off = m.captures
306
+
307
+ # Checked on the WRITTEN date, before any offset is applied. Validating the
308
+ # resolved UTC components instead would accept "2024-02-30T00:00:00+05:00",
309
+ # which lands on 2024-02-29T19:00Z -- a date that does exist.
310
+ return nil unless real_calendar_day?(date)
311
+
252
312
  return "#{date}T00:00:00Z" if hh.nil?
253
313
 
254
314
  # The engine's DateTimeOffset.TryParse rejects hour 24 outright rather than
@@ -264,6 +324,10 @@ module Featureflip
264
324
  "#{date}T#{hh}:#{mm}:#{ss}#{frac}#{off}"
265
325
  end
266
326
 
327
+ # Parses a date-time to a UTC `Time`, mirroring the engine's
328
+ # TryParseDateTime. ISO-8601 strings honor any timezone offset; a string
329
+ # without an offset is assumed UTC. A bare integer is treated as Unix time
330
+ # in seconds. Returns nil when the input parses as neither.
267
331
  def parse_datetime(value)
268
332
  s = value.to_s
269
333
  # Trim exactly the engine's whitespace class, then reject anything still
@@ -276,10 +340,35 @@ module Featureflip
276
340
  begin
277
341
  # Offset-less forms were canonicalized to an explicit "Z", mirroring
278
342
  # DateTimeOffset.TryParse with AssumeUniversal.
279
- return Time.iso8601(iso).utc
343
+ t = Time.iso8601(iso).utc
344
+
345
+ # The SAME range the integer branch below enforces, applied to the
346
+ # RESOLVED instant. The engine parses with DateTimeOffset.TryParse, so its
347
+ # accepted set is bounded by DateTimeOffset's range and it returns false
348
+ # outside it; ruby, js, php, go and java all resolve past both ends --
349
+ # year 0 to a real instant, and a 4-digit year plus an offset to one
350
+ # beyond either bound (#2500).
351
+ #
352
+ # Checked on the RESOLVED instant, deliberately unlike the WRITTEN-triple
353
+ # check in real_calendar_day?. The two answer different questions: whether
354
+ # the operand names a real DAY is a property of what was written
355
+ # ("2024-02-30T00:00:00+05:00" lands on a real UTC day but names none),
356
+ # whereas whether it is REPRESENTABLE is a property of what it resolves to
357
+ # -- the offset is exactly what carries "0001-01-01T00:00:00+05:00" under
358
+ # the floor and "9999-12-31T23:59:59-05:00" over the ceiling.
359
+ #
360
+ # to_i floors, matching the other SDKs: a fractional second is always a
361
+ # non-negative addend, so "0000-12-31T23:59:59.5Z" floors to MIN-1 and is
362
+ # rejected while "0001-01-01T00:00:00.5Z" floors to MIN and is kept.
363
+ seconds = t.to_i
364
+ return nil if seconds < MIN_UNIX_SECONDS || seconds > MAX_UNIX_SECONDS
365
+
366
+ return t
280
367
  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.
368
+ # An unreal day is already gone (real_calendar_day?), so this now only
369
+ # catches the out-of-range minute and second the grammar's \d{2} still
370
+ # admits ("00:99", "00:00:99"), which every other SDK rejects too. Falls
371
+ # through to the Unix-seconds fallback, which rejects a non-integer.
283
372
  end
284
373
  end
285
374
 
@@ -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.8.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.8.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-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger