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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3d59e135c8bde6e1955c965c186ade207bbb58961bbb95ad949497940efbd8d
|
|
4
|
+
data.tar.gz: 9a25588388685e6e08f206b4ed623b491e949df2fc448797ba4305cdfdedfd4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
@@ -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?(
|
|
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(
|
|
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[
|
|
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 "
|
|
137
|
+
when "equals"
|
|
111
138
|
ci_targets.any? { |t| ci_value == t }
|
|
112
|
-
when "
|
|
139
|
+
when "notequals"
|
|
113
140
|
ci_targets.all? { |t| ci_value != t }
|
|
114
|
-
when "
|
|
141
|
+
when "contains"
|
|
115
142
|
ci_targets.any? { |t| ci_value.include?(t) }
|
|
116
|
-
when "
|
|
143
|
+
when "notcontains"
|
|
117
144
|
ci_targets.all? { |t| !ci_value.include?(t) }
|
|
118
|
-
when "
|
|
145
|
+
when "startswith"
|
|
119
146
|
ci_targets.any? { |t| ci_value.start_with?(t) }
|
|
120
|
-
when "
|
|
147
|
+
when "endswith"
|
|
121
148
|
ci_targets.any? { |t| ci_value.end_with?(t) }
|
|
122
|
-
when "
|
|
149
|
+
when "in"
|
|
123
150
|
ci_targets.include?(ci_value)
|
|
124
|
-
when "
|
|
151
|
+
when "notin"
|
|
125
152
|
!ci_targets.include?(ci_value)
|
|
126
|
-
when "
|
|
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 "
|
|
171
|
+
when "greaterthan"
|
|
145
172
|
ci_targets.any? { |t| compare_numeric(ci_value, t, :>) }
|
|
146
|
-
when "
|
|
173
|
+
when "greaterthanorequal"
|
|
147
174
|
ci_targets.any? { |t| compare_numeric(ci_value, t, :>=) }
|
|
148
|
-
when "
|
|
175
|
+
when "lessthan"
|
|
149
176
|
ci_targets.any? { |t| compare_numeric(ci_value, t, :<) }
|
|
150
|
-
when "
|
|
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 "
|
|
185
|
+
when "before"
|
|
159
186
|
targets.any? { |t| compare_datetime(value, t, :<) }
|
|
160
|
-
when "
|
|
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 "
|
|
193
|
+
when "semverequals"
|
|
167
194
|
targets.any? { |t| compare_semver(value, t, :==) }
|
|
168
|
-
when "
|
|
195
|
+
when "semvergreaterthan"
|
|
169
196
|
targets.any? { |t| compare_semver(value, t, :>) }
|
|
170
|
-
when "
|
|
197
|
+
when "semvergreaterthanorequal"
|
|
171
198
|
targets.any? { |t| compare_semver(value, t, :>=) }
|
|
172
|
-
when "
|
|
199
|
+
when "semverlessthan"
|
|
173
200
|
targets.any? { |t| compare_semver(value, t, :<) }
|
|
174
|
-
when "
|
|
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?(
|
|
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[
|
|
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
|
-
|
|
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
|
-
#
|
|
282
|
-
#
|
|
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
|
|
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.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
|
|
11
|
+
date: 2026-09-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: logger
|